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
630 lines
23 KiB
TypeScript
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
|
|
}
|