Files
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

630 lines
23 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
import { createLogger, type Logger } from '@/lib/logger'
import { roundOre as round } from '@/lib/money'
import type { WebshopOrderLineItem, WebshopVatBreakdownLine } from '@/types'
import { isRevokedCredentialsError, listPurchasesPage } from './api-client'
import { encryptCredential, refreshTokenOf } from './credentials'
import { isRevokedOAuthError, refreshAccessToken } from './oauth'
import type { ZettleConnection, ZettlePayment, ZettlePurchase } from '../types'
const defaultLog = createLogger('zettle/order-sync')
/**
* Zettle purchase sync: paid POS/online purchases and refunds as rich rows
* in public.webshop_orders (the Orders page). Feed-only doctrine: nothing
* here books anything.
*
* Qualification: positive-amount non-refund purchases with at least one
* non-invoice payment (or no payments array, treated as settled POS sale).
* Purchases whose only payment type is IZETTLE_INVOICE are skipped (unpaid
* invoice). Refunds (refund:true) land as separate negative rows.
*
* Underlag: per-rate VAT from groupedVatAmounts, line-item snapshot from
* products (+ serviceCharge). Customer fields stay null (POS rarely has
* orgnr/email). Currency amounts are minor units from the API.
*/
export const ZETTLE_IMPORT_SOURCE = 'zettle'
export const BACKFILL_DAYS = 90
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
const MAX_PURCHASES_PER_RUN = 10_000
const VAT_REMAINDER_TOLERANCE = 0.5
/**
* ⚠️ STORED-KEY FORMAT. Persisted to webshop_orders.external_id. Changing
* the template silently orphans every prior row. Locked by order-sync tests.
* Scope is organization_uuid, NOT connection id.
*/
export function zettleStoreScope(organizationUuid: string): string {
return organizationUuid
}
export function zettlePurchaseExternalId(storeScope: string, purchaseUuid: string): string {
return `zettle_${storeScope}_purchase_${purchaseUuid}`
}
export interface ZettleSyncSummary {
fetched: number
refundsFetched: number
inserted: number
updated: number
unchanged: number
frozenFlagged: number
crossMarked: number
errors: number
/** Sales imported unbookable (is_paid false) because the row model cannot express them yet. */
needsReview: number
/** Refunds of such sales, not imported at all. */
skippedUnsupported: number
deadlineReached?: boolean
revoked?: boolean
/** Another run holds the connection's sync claim; nothing was done. */
locked?: boolean
}
/** How long a sync claim lasts; longer than the cron's 300 s maxDuration. */
const SYNC_LOCK_MS = 6 * 60 * 1000
/** Minor units → major currency units. */
export function fromMinor(amount: number): number {
return round(amount / 100)
}
/** ISO date part from a Zettle timestamp (+0000 or Z). */
export function isoDateOf(timestamp: string): string {
const normalized = timestamp.replace(/([+-]\d{4})$/, (m) => `${m.slice(0, 3)}:${m.slice(3)}`)
const ms = Date.parse(normalized)
if (Number.isFinite(ms)) return new Date(ms).toISOString().split('T')[0]
return timestamp.split('T')[0]
}
export function purchaseTimestampIso(purchase: ZettlePurchase): string | null {
const raw = purchase.created || purchase.timestamp
if (!raw) return null
const normalized = raw.replace(/([+-]\d{4})$/, (m) => `${m.slice(0, 3)}:${m.slice(3)}`)
const ms = Date.parse(normalized)
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
}
const INVOICE_ONLY = 'IZETTLE_INVOICE'
/**
* Paid sale: not a refund, positive gross, and not invoice-only.
* Empty payments[] is treated as a settled POS sale (common for cash/card
* payloads that omit details in some historical rows).
*/
export function purchaseQualifiesAsPaidSale(purchase: ZettlePurchase): boolean {
if (purchase.refund === true) return false
if (!(purchase.amount > 0)) return false
const payments = purchase.payments ?? []
if (payments.length === 0) return true
return !payments.every((p) => p.type === INVOICE_ONLY)
}
export function purchaseQualifiesAsRefund(purchase: ZettlePurchase): boolean {
return purchase.refund === true && purchase.amount !== 0
}
const PAYMENT_TITLES: Record<string, string> = {
IZETTLE_CARD: 'Kort',
IZETTLE_CARD_ONLINE: 'Kort online',
IZETTLE_CASH: 'Kontant',
IZETTLE_INVOICE: 'Faktura',
SWISH: 'Swish',
VIPPS: 'Vipps',
MOBILE_PAY: 'MobilePay',
PAYPAL: 'PayPal',
GIFTCARD: 'Presentkort',
STORE_CREDIT: 'Tillgodohavande',
KLARNA: 'Klarna',
}
/**
* Purchases the v1 row model books wrong if treated as one paid sale to one
* payment account with revenue per VAT rate:
* - split_tender: several payment types; the whole gross would land on the
* first type's account (and a card + invoice split would count as paid).
* - voucher_tender: paid with gift card / store credit; Zettle never settles
* it, so 1686 would overstate the receivable.
* - giftcard_sale: a 0 %-rate voucher row is a liability (2421), not
* momsfri försäljning on 3004 / ruta 42.
* - gratuity: tips are never a momsfri sale; the amount semantics are
* unverified.
* Such sales are imported with is_paid = false (bookable only by hand);
* their refunds are not imported. Deterministic over guessing.
*/
export type ZettleUnsupportedReason =
| 'split_tender'
| 'voucher_tender'
| 'giftcard_sale'
| 'gratuity'
const VOUCHER_TENDERS = new Set(['GIFTCARD', 'STORE_CREDIT'])
export function unsupportedReason(purchase: ZettlePurchase): ZettleUnsupportedReason | null {
const payments = purchase.payments ?? []
const types = new Set(payments.map((p) => p.type).filter(Boolean))
if (types.size > 1) return 'split_tender'
if (payments.some((p) => VOUCHER_TENDERS.has(p.type))) return 'voucher_tender'
if ((purchase.products ?? []).some((p) => p.type === 'GIFTCARD')) return 'giftcard_sale'
if (payments.some((p) => typeof p.gratuityAmount === 'number' && p.gratuityAmount !== 0)) {
return 'gratuity'
}
return null
}
const NEEDS_REVIEW_TITLES: Record<ZettleUnsupportedReason, string> = {
split_tender: 'Delad betalning: bokför manuellt',
voucher_tender: 'Betalt med presentkort/tillgodohavande: bokför manuellt',
giftcard_sale: 'Presentkortsförsäljning: bokför manuellt',
gratuity: 'Dricks ingår: bokför manuellt',
}
export function paymentMethodOf(payments: ZettlePayment[] | undefined): {
method: string | null
title: string | null
} {
if (!payments || payments.length === 0) return { method: null, title: null }
const types = payments.map((p) => p.type).filter(Boolean)
if (types.length === 0) return { method: null, title: null }
return {
method: types[0],
title: types.map((t) => PAYMENT_TITLES[t] ?? t).join(', '),
}
}
/**
* Net per VAT rate summed from the product rows (Zettle's own öre rounding,
* rowTaxableAmount) plus the service charge. Null when any row lacks the
* fields, so the caller falls back to deriving net from the tax amount.
*/
function netByRateFromProducts(purchase: ZettlePurchase): Map<number, number> | null {
const netByRate = new Map<number, number>()
for (const product of purchase.products ?? []) {
const qty = Number.parseFloat(product.quantity)
if (!Number.isFinite(qty) || qty === 0) continue
if (typeof product.rowTaxableAmount !== 'number') return null
const rate = typeof product.vatPercentage === 'number' ? product.vatPercentage : 0
netByRate.set(rate, round((netByRate.get(rate) ?? 0) + fromMinor(product.rowTaxableAmount)))
}
const charge = purchase.serviceCharge
if (charge && typeof charge.amount === 'number') {
const rate = typeof charge.vatPercentage === 'number' ? charge.vatPercentage : 0
const gross = fromMinor(charge.amount)
const net = rate > 0 ? round(gross / (1 + rate / 100)) : gross
netByRate.set(rate, round((netByRate.get(rate) ?? 0) + net))
}
return netByRate.size > 0 ? netByRate : null
}
/**
* Per-rate VAT from groupedVatAmounts (tax in minor units). Net per rate is
* taken from the product rows when they carry it (matches what Zettle
* charged to the öre); otherwise derived as tax / rate. Remainder against
* gross becomes a 0% bucket.
*/
export function buildVatBreakdown(purchase: ZettlePurchase): WebshopVatBreakdownLine[] {
const total = fromMinor(Math.abs(purchase.amount))
if (total === 0) return []
const grouped = purchase.groupedVatAmounts
if (!grouped || typeof grouped !== 'object') return []
const netFromRows = netByRateFromProducts(purchase)
const buckets = new Map<number, { net: number; tax: number }>()
for (const [rateKey, taxMinor] of Object.entries(grouped)) {
if (typeof taxMinor !== 'number' || taxMinor === 0) continue
const rate = Number.parseFloat(rateKey)
if (!Number.isFinite(rate) || rate <= 0) return []
const tax = fromMinor(Math.abs(taxMinor))
const rowNet = netFromRows?.get(rate)
const net = rowNet !== undefined ? Math.abs(rowNet) : round(tax / (rate / 100))
const bucket = buckets.get(rate) ?? { net: 0, tax: 0 }
bucket.net = round(bucket.net + net)
bucket.tax = round(bucket.tax + tax)
buckets.set(rate, bucket)
}
const breakdown = Array.from(buckets.entries())
.map(([rate, { net, tax }]) => ({ rate, net, tax }))
.sort((a, b) => b.rate - a.rate)
const covered = round(breakdown.reduce((sum, b) => sum + b.net + b.tax, 0))
const remainder = round(total - covered)
if (remainder < -VAT_REMAINDER_TOLERANCE) return []
if (remainder > VAT_REMAINDER_TOLERANCE) {
breakdown.push({ rate: 0, net: remainder, tax: 0 })
}
return breakdown
}
/** Line snapshot: products + optional serviceCharge. Dropped if öre sum ≠ |total|. */
export function mapLineItems(purchase: ZettlePurchase): WebshopOrderLineItem[] {
const items: WebshopOrderLineItem[] = []
for (const product of purchase.products ?? []) {
const qty = Number.parseFloat(product.quantity)
if (!Number.isFinite(qty) || qty === 0) continue
const netMinor = product.rowTaxableAmount
if (typeof netMinor !== 'number') return []
// rowTaxableAmount is the row's net in minor units (signed with refunds).
const rowNet = fromMinor(netMinor)
const rate = typeof product.vatPercentage === 'number' ? product.vatPercentage : null
const tax =
rate !== null && rate > 0 ? round(rowNet * (rate / 100)) : 0
const nameParts = [product.name, product.variantName].filter(Boolean)
items.push({
name: nameParts.join(' / ') || product.type || 'Artikel',
quantity: qty,
total: rowNet,
total_tax: tax,
vat_rate: rate,
})
}
if (purchase.serviceCharge && typeof purchase.serviceCharge.amount === 'number') {
const gross = fromMinor(purchase.serviceCharge.amount)
const rate =
typeof purchase.serviceCharge.vatPercentage === 'number'
? purchase.serviceCharge.vatPercentage
: null
let net = gross
let tax = 0
if (rate !== null && rate > 0) {
net = round(gross / (1 + rate / 100))
tax = round(gross - net)
}
items.push({
name: purchase.serviceCharge.title || 'Serviceavgift',
quantity: Number.parseFloat(purchase.serviceCharge.quantity ?? '1') || 1,
total: net,
total_tax: tax,
vat_rate: rate,
})
}
// Each row's tax is re-derived from Zettle's rounded net, so net + tax can
// sit one öre off the row's charged gross (33.37 kr at 25%: net 26.70,
// tax 6.68, sum 33.38). Allow that per row; anything larger means the
// rows do not describe this purchase and the snapshot is dropped.
const total = fromMinor(Math.abs(purchase.amount))
const covered = round(
items.reduce((sum, i) => sum + Math.abs(i.total) + Math.abs(i.total_tax), 0),
)
const tolerance = 0.005 + 0.01 * items.length
if (Math.abs(covered - total) > tolerance) return []
return items
}
export function mapPurchaseToWebshopRows(
connection: Pick<ZettleConnection, 'id' | 'organization_name'>,
storeScope: string,
purchase: ZettlePurchase,
): WebshopOrderUpsert[] {
const uuid = purchase.purchaseUUID1
if (!uuid) return []
if (purchaseQualifiesAsPaidSale(purchase)) {
const total = fromMinor(purchase.amount)
if (total === 0) return []
const ts = purchaseTimestampIso(purchase)
const date = ts ? isoDateOf(ts) : isoDateOf(purchase.created || purchase.timestamp || '')
const payment = paymentMethodOf(purchase.payments)
const vat = buildVatBreakdown(purchase)
const totalTax =
typeof purchase.vatAmount === 'number'
? fromMinor(purchase.vatAmount)
: round(vat.reduce((s, b) => s + b.tax, 0))
const reason = unsupportedReason(purchase)
return [
{
platform: 'zettle',
store_scope: storeScope,
store_label: connection.organization_name,
connection_id: connection.id,
row_type: 'order',
parent_external_id: null,
external_id: zettlePurchaseExternalId(storeScope, uuid),
platform_order_id: uuid,
order_number: String(purchase.globalPurchaseNumber ?? purchase.purchaseNumber ?? uuid),
// is_paid = false keeps book-order / bulk-book from posting a row the
// model would book to the wrong accounts; the title says why.
status: reason ? 'needs_review' : purchase.refunded ? 'refunded' : 'paid',
is_paid: reason === null,
order_date: date,
paid_date: date,
currency: purchase.currency.toUpperCase(),
total,
total_tax: totalTax,
vat_breakdown: vat,
line_items: mapLineItems(purchase),
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: purchase.country ?? null,
payment_method: payment.method,
payment_method_title: reason ? NEEDS_REVIEW_TITLES[reason] : payment.title,
gateway_reference: purchase.payments?.[0]?.uuid ?? null,
refunded_total: 0,
},
]
}
if (purchaseQualifiesAsRefund(purchase)) {
// The booking guard only protects unpaid ORDER rows, so a refund of an
// unsupported sale is not imported at all rather than left bookable.
if (unsupportedReason(purchase) !== null) return []
const amount = fromMinor(purchase.amount) // already negative typically
const signedTotal = amount > 0 ? -amount : amount
if (signedTotal === 0) return []
const ts = purchaseTimestampIso(purchase)
const date = ts ? isoDateOf(ts) : isoDateOf(purchase.created || purchase.timestamp || '')
const payment = paymentMethodOf(purchase.payments)
const parentUuid = purchase.refundsPurchaseUUID1
const vat = buildVatBreakdown(purchase).map((b) => ({
rate: b.rate,
net: -Math.abs(b.net),
tax: -Math.abs(b.tax),
}))
const totalTax =
typeof purchase.vatAmount === 'number'
? -Math.abs(fromMinor(purchase.vatAmount))
: round(vat.reduce((s, b) => s + b.tax, 0))
return [
{
platform: 'zettle',
store_scope: storeScope,
store_label: connection.organization_name,
connection_id: connection.id,
row_type: 'refund',
parent_external_id: parentUuid
? zettlePurchaseExternalId(storeScope, parentUuid)
: null,
external_id: zettlePurchaseExternalId(storeScope, uuid),
platform_order_id: uuid,
order_number: String(purchase.globalPurchaseNumber ?? purchase.purchaseNumber ?? uuid),
status: 'refund',
is_paid: true,
order_date: date,
paid_date: date,
currency: purchase.currency.toUpperCase(),
total: signedTotal,
total_tax: totalTax,
vat_breakdown: vat,
line_items: mapLineItems(purchase),
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: purchase.country ?? null,
payment_method: payment.method,
payment_method_title: payment.title,
gateway_reference: purchase.payments?.[0]?.uuid ?? null,
refunded_total: 0,
},
]
}
return []
}
function resolveWindowStartIso(connection: ZettleConnection): string {
if (connection.last_order_synced_at) {
const cursorMs = Date.parse(connection.last_order_synced_at)
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
}
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
}
export async function syncZettlePurchases(
supabase: SupabaseClient,
connection: ZettleConnection,
log: Logger = defaultLog,
deadlineMs?: number,
): Promise<ZettleSyncSummary> {
const summary: ZettleSyncSummary = {
fetched: 0,
refundsFetched: 0,
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
needsReview: 0,
skippedUnsupported: 0,
}
if (
connection.status !== 'active' ||
!connection.refresh_token_encrypted ||
!connection.organization_uuid
) {
return summary
}
// Claim the connection before touching the rotating refresh token. Two
// runs (the 03:30 cron and "Synka nu", or two tabs) refreshing at once
// would make the loser's token a reused one, which Zettle answers with
// 400 invalid_grant and this code would read as a revocation.
const nowIso = new Date().toISOString()
const { data: claimed, error: claimError } = await supabase
.from('zettle_connections')
.update({ sync_lock_until: new Date(Date.now() + SYNC_LOCK_MS).toISOString() })
.eq('id', connection.id)
.eq('status', 'active')
.lt('sync_lock_until', nowIso)
.select('id')
if (claimError) {
throw new Error(`Failed to claim Zettle connection for sync: ${claimError.message}`)
}
if (!claimed || claimed.length === 0) {
summary.locked = true
log.info('sync already running for this connection; skipped', {
connectionId: connection.id,
})
return summary
}
const storeScope = zettleStoreScope(connection.organization_uuid)
const runStartMs = Date.now()
const startDate = resolveWindowStartIso(connection)
let lastPurchaseHash: string | null = null
let prevCursorMs = connection.last_order_synced_at
? Date.parse(connection.last_order_synced_at)
: 0
let failureFloorMs = Number.POSITIVE_INFINITY
let windowExhausted = false
try {
const tokens = await refreshAccessToken(refreshTokenOf(connection))
// Rotate the refresh token immediately (Zettle invalidates the old one).
// The write must succeed before we continue: losing the new token leaves
// only a dead refresh token for the next run and forces a reconnect.
const encrypted = encryptCredential(tokens.refresh_token)
const { error: rotateError } = await supabase
.from('zettle_connections')
.update({ refresh_token_encrypted: encrypted, error_message: null })
.eq('id', connection.id)
if (rotateError) {
log.error('failed to persist rotated Zettle refresh token', rotateError, {
connectionId: connection.id,
message: rotateError.message,
code: rotateError.code,
})
throw new Error(
`Failed to persist rotated Zettle refresh token: ${rotateError.message}`,
)
}
connection.refresh_token_encrypted = encrypted
for (;;) {
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
summary.deadlineReached = true
log.info('time budget exhausted; stopping purchase sync', {
connectionId: connection.id,
processed: summary.inserted + summary.updated + summary.unchanged,
})
break
}
const page = await listPurchasesPage(tokens.access_token, {
startDate,
lastPurchaseHash,
})
if (page.purchases.length === 0) {
windowExhausted = true
break
}
summary.fetched += page.purchases.length
const rows: WebshopOrderUpsert[] = []
let pageMaxMs = 0
let pageMinMs = Number.POSITIVE_INFINITY
for (const purchase of page.purchases) {
const isRefund = purchaseQualifiesAsRefund(purchase)
if (isRefund) summary.refundsFetched += 1
if (unsupportedReason(purchase) !== null) {
if (isRefund) summary.skippedUnsupported += 1
else if (purchaseQualifiesAsPaidSale(purchase)) summary.needsReview += 1
}
const ts = purchaseTimestampIso(purchase)
if (ts) {
const ms = Date.parse(ts)
if (ms > pageMaxMs) pageMaxMs = ms
if (ms < pageMinMs) pageMinMs = ms
}
rows.push(...mapPurchaseToWebshopRows(connection, storeScope, purchase))
}
if (rows.length > 0) {
const result = await upsertWebshopOrders(
supabase,
connection.company_id,
connection.user_id,
rows,
)
summary.inserted += result.inserted
summary.updated += result.updated
summary.unchanged += result.unchanged
summary.frozenFlagged += result.frozenFlagged
summary.crossMarked += result.crossMarked
summary.errors += result.errors
if (result.errors > 0 && Number.isFinite(pageMinMs)) {
failureFloorMs = Math.min(failureFloorMs, pageMinMs - 1000)
}
}
if (pageMaxMs > 0) {
const candidateMs = Math.min(pageMaxMs, failureFloorMs)
if (candidateMs > prevCursorMs) {
const cursorIso = new Date(candidateMs).toISOString()
await supabase
.from('zettle_connections')
.update({ last_order_synced_at: cursorIso, error_message: null })
.eq('id', connection.id)
connection.last_order_synced_at = cursorIso
prevCursorMs = candidateMs
}
}
if (!page.hasMore) {
windowExhausted = true
break
}
lastPurchaseHash = page.lastPurchaseHash
if (summary.fetched >= MAX_PURCHASES_PER_RUN) {
log.warn('purchase cap reached; remaining purchases resume next run', {
connectionId: connection.id,
cap: MAX_PURCHASES_PER_RUN,
})
break
}
}
if (windowExhausted) {
const watermarkMs = Math.min(runStartMs, failureFloorMs)
if (watermarkMs > prevCursorMs) {
const cursorIso = new Date(watermarkMs).toISOString()
await supabase
.from('zettle_connections')
.update({ last_order_synced_at: cursorIso, error_message: null })
.eq('id', connection.id)
connection.last_order_synced_at = cursorIso
}
}
} catch (err) {
if (isRevokedCredentialsError(err) || isRevokedOAuthError(err)) {
summary.revoked = true
await supabase
.from('zettle_connections')
.update({
status: 'revoked',
error_message: 'Zettle avvisade anslutningen. Anslut kontot igen.',
refresh_token_encrypted: null,
oauth_state: null,
disconnected_at: new Date().toISOString(),
})
.eq('id', connection.id)
.eq('status', 'active')
log.warn('credentials revoked upstream; connection flipped to revoked', {
connectionId: connection.id,
})
return summary
}
throw err
} finally {
await supabase
.from('zettle_connections')
.update({ sync_lock_until: new Date(0).toISOString() })
.eq('id', connection.id)
}
log.info('zettle purchase sync done', {
connectionId: connection.id,
...summary,
})
return summary
}