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
157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
import { NextResponse } from 'next/server'
|
|
import { withCronContext } from '@/lib/api/with-cron-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { hasCapability } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { loadExtensions } from '@/lib/extensions/loader'
|
|
import { extensionRegistry } from '@/lib/extensions/registry'
|
|
import { isZettleConfigured } from '@/extensions/general/zettle/lib/credentials'
|
|
import { syncZettlePurchases } from '@/extensions/general/zettle/lib/order-sync'
|
|
import type { ZettleConnection } from '@/extensions/general/zettle/types'
|
|
|
|
export const maxDuration = 300
|
|
|
|
/** Cap of entitled connections synced per cron invocation. */
|
|
const MAX_SYNCED = 50
|
|
/** Page size when scanning candidates ordered by purchase cursor. */
|
|
const CANDIDATE_PAGE_SIZE = 100
|
|
/** Hard stop so a flood of non-entitled rows cannot burn the whole budget scanning. */
|
|
const MAX_CANDIDATES_SCANNED = 2000
|
|
|
|
/**
|
|
* GET /api/extensions/zettle/orders/cron
|
|
* Nightly purchase sync for connections that opted in (transaction_sync_enabled):
|
|
* upserts each connected org's paid purchases and refunds into webshop_orders.
|
|
*
|
|
* Candidates are paged by last_order_synced_at. Entitlement skips do not advance
|
|
* that cursor (it controls purchase recovery) and do not consume the sync cap,
|
|
* so a front of non-entitled rows cannot starve eligible connections behind them.
|
|
*/
|
|
export const GET = withCronContext('cron.zettle_order_sync', async (_request, ctx) => {
|
|
loadExtensions()
|
|
if (!extensionRegistry.get('zettle')) {
|
|
ctx.log.warn('zettle extension is not enabled; cron refused')
|
|
return NextResponse.json(
|
|
{ error: 'Zettle extension is not enabled', code: 'EXTENSION_DISABLED' },
|
|
{ status: 503 },
|
|
)
|
|
}
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !supabaseServiceKey) {
|
|
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { reason: 'Missing Supabase configuration' },
|
|
})
|
|
}
|
|
if (!isZettleConfigured()) {
|
|
return NextResponse.json({ message: 'Zettle not configured', processed: 0 })
|
|
}
|
|
|
|
const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey)
|
|
|
|
const startTime = Date.now()
|
|
const TIME_BUDGET_MS = 240_000
|
|
const deadlineMs = startTime + TIME_BUDGET_MS
|
|
|
|
const results: Array<{
|
|
connectionId: string
|
|
inserted: number
|
|
updated: number
|
|
status: 'synced' | 'revoked' | 'locked' | 'error'
|
|
}> = []
|
|
|
|
// Snapshot the candidate list BEFORE syncing any of it. Each sync moves its
|
|
// row's last_order_synced_at to "now" (to the tail of this ordering), so
|
|
// paging with a live offset would re-fetch already-synced rows on page 2
|
|
// and never reach the eligible rows that slid into the gap.
|
|
const candidates: ZettleConnection[] = []
|
|
for (let offset = 0; offset < MAX_CANDIDATES_SCANNED; offset += CANDIDATE_PAGE_SIZE) {
|
|
const { data: page, error: connError } = await supabase
|
|
.from('zettle_connections')
|
|
.select('*')
|
|
.eq('status', 'active')
|
|
.eq('transaction_sync_enabled', true)
|
|
.order('last_order_synced_at', { ascending: true, nullsFirst: true })
|
|
.range(offset, offset + CANDIDATE_PAGE_SIZE - 1)
|
|
|
|
if (connError) {
|
|
ctx.log.error('failed to fetch zettle connections', connError, {
|
|
message: connError.message,
|
|
code: connError.code,
|
|
})
|
|
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
|
|
if (!page || page.length === 0) break
|
|
candidates.push(...(page as ZettleConnection[]))
|
|
if (page.length < CANDIDATE_PAGE_SIZE) break
|
|
}
|
|
|
|
let scanned = 0
|
|
for (const connection of candidates) {
|
|
if (results.length >= MAX_SYNCED) break
|
|
if (Date.now() >= deadlineMs) {
|
|
ctx.log.info('time budget reached', { processedSoFar: results.length, scanned })
|
|
break
|
|
}
|
|
scanned += 1
|
|
|
|
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.zettle_sync))) {
|
|
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const summary = await syncZettlePurchases(supabase, connection, ctx.log, deadlineMs)
|
|
if (summary.locked) {
|
|
// A manual sync holds the claim; it will advance the cursor itself.
|
|
results.push({ connectionId: connection.id, inserted: 0, updated: 0, status: 'locked' })
|
|
continue
|
|
}
|
|
if (summary.deadlineReached) {
|
|
ctx.log.info('connection stopped early on time budget; remaining rows resume next run', {
|
|
connectionId: connection.id,
|
|
})
|
|
}
|
|
results.push({
|
|
connectionId: connection.id,
|
|
inserted: summary.inserted,
|
|
updated: summary.updated,
|
|
status: summary.revoked ? 'revoked' : 'synced',
|
|
})
|
|
} catch (error) {
|
|
ctx.log.error('zettle purchase sync failed for connection', error as Error, {
|
|
connectionId: connection.id,
|
|
companyId: connection.company_id,
|
|
})
|
|
results.push({
|
|
connectionId: connection.id,
|
|
inserted: 0,
|
|
updated: 0,
|
|
status: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
return NextResponse.json({
|
|
message: 'No connections with transaction sync enabled',
|
|
processed: 0,
|
|
})
|
|
}
|
|
|
|
const totalInserted = results.reduce((acc, r) => acc + r.inserted, 0)
|
|
ctx.log.info('zettle purchase sync summary', {
|
|
processed: results.length,
|
|
scanned,
|
|
totalInserted,
|
|
failed: results.filter((r) => r.status === 'error').length,
|
|
})
|
|
|
|
return NextResponse.json({ processed: results.length, inserted: totalInserted, results })
|
|
})
|