Files
accounted/extensions/general/zettle/lib/api-client.ts
T
MattssonandClaude Fable 5.1 6ea92f3152 feat(zettle): sync paid purchases into webshop_orders (#2445)
Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed).

Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw.
What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body.
Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows.

Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB
2026-09-09 11:19:39 +02:00

129 lines
3.9 KiB
TypeScript

/**
* Minimal Zettle Purchase API client for the paid-purchase feed.
*
* Host is fixed (purchase.izettle.com), not tenant input, so SSRF guarding
* via safeFetch is unnecessary; fetchWithTimeout covers hung connections.
* Auth is a short-lived Bearer access token obtained by refreshing the
* stored refresh token at the start of each run.
*/
import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
import { sleep } from '@/lib/utils'
import type { ZettlePurchase } from '../types'
const PURCHASE_BASE = 'https://purchase.izettle.com'
export const ZETTLE_PAGE_SIZE = 100
const REQUEST_TIMEOUT_MS = 30_000
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
const RETRY_DELAYS_MS = [1_000, 3_000]
export class ZettleApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string | null = null,
) {
super(message)
this.name = 'ZettleApiError'
}
}
/** Whether an API error means the credentials themselves are dead. */
export function isRevokedCredentialsError(error: unknown): boolean {
if (!(error instanceof ZettleApiError)) return false
return error.status === 401 || error.status === 403
}
export interface PurchasesPage {
purchases: ZettlePurchase[]
lastPurchaseHash: string | null
hasMore: boolean
}
export interface ListPurchasesOptions {
/** Inclusive UTC start (ISO date or datetime). */
startDate: string
/** Hash from the previous page's lastPurchaseHash, or null for page one. */
lastPurchaseHash: string | null
}
async function getJson(url: string, accessToken: string): Promise<Response> {
return fetchWithTimeout(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
{ timeoutMs: REQUEST_TIMEOUT_MS, description: 'Zettle Purchase API' },
)
}
/**
* One page of purchases on/after startDate, oldest first so the caller's
* cursor advances chronologically. Pagination uses lastPurchaseHash.
*/
export async function listPurchasesPage(
accessToken: string,
options: ListPurchasesOptions,
): Promise<PurchasesPage> {
const params = new URLSearchParams({
startDate: options.startDate,
limit: String(ZETTLE_PAGE_SIZE),
descending: 'false',
})
if (options.lastPurchaseHash) {
params.set('lastPurchaseHash', options.lastPurchaseHash)
}
const url = `${PURCHASE_BASE}/purchases/v2?${params.toString()}`
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
let response: Response
try {
response = await getJson(url, accessToken)
} catch (err) {
lastError = new ZettleApiError(
`Zettle request failed: ${err instanceof Error ? err.message : String(err)}`,
0,
)
if (attempt < RETRY_DELAYS_MS.length) {
await sleep(RETRY_DELAYS_MS[attempt])
continue
}
throw lastError
}
if (!response.ok) {
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
lastError = new ZettleApiError(`Zettle API ${response.status}`, response.status)
await sleep(RETRY_DELAYS_MS[attempt])
continue
}
throw new ZettleApiError(`Zettle API ${response.status}`, response.status)
}
const body = (await response.json().catch(() => null)) as {
purchases?: ZettlePurchase[]
lastPurchaseHash?: string | null
} | null
const purchases = Array.isArray(body?.purchases) ? body!.purchases! : []
const lastPurchaseHash =
typeof body?.lastPurchaseHash === 'string' && body.lastPurchaseHash
? body.lastPurchaseHash
: null
return {
purchases,
lastPurchaseHash,
// Another page exists when this page was full and a hash was returned.
hasMore: purchases.length >= ZETTLE_PAGE_SIZE && lastPurchaseHash !== null,
}
}
throw lastError instanceof Error
? lastError
: new ZettleApiError('Zettle request failed', 0)
}