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
This commit is contained in:
Mattsson
2026-09-09 11:19:39 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 500806f001
commit 6ea92f3152
58 changed files with 4069 additions and 18 deletions
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { isRevokedCredentialsError, ZettleApiError } from '../lib/api-client'
describe('zettle api-client', () => {
it('treats 401/403 as revoked credentials', () => {
expect(isRevokedCredentialsError(new ZettleApiError('denied', 401))).toBe(true)
expect(isRevokedCredentialsError(new ZettleApiError('denied', 403))).toBe(true)
expect(isRevokedCredentialsError(new ZettleApiError('oops', 500))).toBe(false)
expect(isRevokedCredentialsError(new Error('nope'))).toBe(false)
})
})
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) }
})
vi.mock('../lib/oauth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/oauth')>()
return {
...actual,
refreshAccessToken: vi.fn(),
disconnectApplication: vi.fn().mockResolvedValue(undefined),
}
})
vi.mock('../lib/order-sync', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/order-sync')>()
return { ...actual, syncZettlePurchases: vi.fn() }
})
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => ({ service: true })),
}))
vi.mock('@/lib/auth/oauth-flows', () => ({
resolveOAuthOrigin: vi.fn().mockResolvedValue('https://brand.testbrand.example'),
}))
import { zettleExtension } from '../index'
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { syncZettlePurchases } from '../lib/order-sync'
import { resolveOAuthOrigin } from '@/lib/auth/oauth-flows'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
function findRoute(method: string, path: string) {
const route = zettleExtension.apiRoutes?.find((r) => r.method === method && r.path === path)
expect(route, `${method} ${path} must be registered`).toBeDefined()
return route!
}
function makeRequest(method: string, body?: unknown): Request {
return new Request('https://test.local/api/extensions/ext/zettle/x', {
method,
headers: { 'Content-Type': 'application/json' },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
}
function makeContext(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'zettle',
requestId: 'req_test',
supabase,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
const USER = { id: 'user-1', is_anonymous: false }
describe('zettle extension routes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('ZETTLE_CLIENT_ID', 'cid')
vi.stubEnv('ZETTLE_CLIENT_SECRET', 'csecret')
vi.stubEnv('ZETTLE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com')
vi.mocked(requireCapability).mockResolvedValue(null)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('GET /status returns configured + connection', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: [
{
id: 'c1',
status: 'active',
organization_uuid: 'org-1',
organization_name: null,
currency: 'SEK',
error_message: null,
connected_at: '2026-09-01T00:00:00.000Z',
transaction_sync_enabled: true,
last_order_synced_at: null,
},
],
})
const res = await findRoute('GET', '/status').handler(makeRequest('GET'), makeContext(supabase))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.configured).toBe(true)
expect(body.connection.id).toBe('c1')
})
it('POST /connect stages pending and returns authorize url', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } }) // guardSandbox
enqueue({ data: [] }) // no active connection
enqueue({ data: [] }) // clear stale pending
enqueue({ data: { id: 'pending-1' } }) // insert pending
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
const body = await res.json()
const url = new URL(body.url)
expect(url.origin + url.pathname).toBe('https://oauth.zettle.com/authorize')
expect(url.searchParams.get('client_id')).toBe('cid')
expect(url.searchParams.get('scope')).toBe('READ:PURCHASE READ:USERINFO')
// The validated brand/app origin is frozen on the pending row so the
// callback can send the browser back to the domain it started on.
expect(resolveOAuthOrigin).toHaveBeenCalledTimes(1)
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
expect(requireCapability).toHaveBeenCalledWith(
expect.anything(),
'company-1',
CAPABILITY.zettle_sync,
)
})
it('POST /connect invalidates a prior pending row before staging a new one', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } }) // guardSandbox
enqueue({ data: [] }) // no active connection
enqueue({ data: [] }) // clear stale pending (status -> error)
enqueue({ data: { id: 'pending-2' } }) // insert replacement pending
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
// Second connect must leave the abandoned oauth_state unusable so a late
// callback for the first flow cannot activate that row (see callback test).
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
})
it('POST /connect refuses when capability is blocked', async () => {
vi.mocked(requireCapability).mockResolvedValue(capabilityBlockedResponse(CAPABILITY.zettle_sync))
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect([402, 403]).toContain(res.status)
})
it('POST /sync calls syncZettlePurchases', async () => {
vi.mocked(syncZettlePurchases).mockResolvedValue({
fetched: 1,
refundsFetched: 0,
inserted: 1,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
needsReview: 0,
skippedUnsupported: 0,
})
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: {
id: 'c1',
status: 'active',
company_id: 'company-1',
user_id: 'user-1',
organization_uuid: 'org-1',
refresh_token_encrypted: 'enc',
},
})
const res = await findRoute('POST', '/sync').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
expect(syncZettlePurchases).toHaveBeenCalled()
})
})
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
import { decryptCredential, encryptCredential, isZettleConfigured } from '../lib/credentials'
describe('zettle credentials', () => {
it('round-trips AES-GCM ciphertext', () => {
const cipher = encryptCredential('IZSEC-refresh-token')
expect(cipher).not.toContain('IZSEC')
expect(decryptCredential(cipher)).toBe('IZSEC-refresh-token')
})
it('is configured only when all three env vars are set', () => {
const prev = {
id: process.env.ZETTLE_CLIENT_ID,
secret: process.env.ZETTLE_CLIENT_SECRET,
key: process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY,
}
process.env.ZETTLE_CLIENT_ID = 'id'
process.env.ZETTLE_CLIENT_SECRET = 'secret'
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = 'key'
expect(isZettleConfigured()).toBe(true)
delete process.env.ZETTLE_CLIENT_ID
expect(isZettleConfigured()).toBe(false)
process.env.ZETTLE_CLIENT_ID = prev.id
process.env.ZETTLE_CLIENT_SECRET = prev.secret
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = prev.key
})
})
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { buildAuthorizeUrl, ZETTLE_OAUTH_SCOPES } from '../lib/oauth'
describe('zettle oauth', () => {
const env = { ...process.env }
beforeEach(() => {
process.env.ZETTLE_CLIENT_ID = 'client-123'
process.env.ZETTLE_CLIENT_SECRET = 'secret-456'
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = 'enc-key'
process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com'
})
afterEach(() => {
process.env = { ...env }
})
it('builds the authorize URL with purchase + userinfo scopes', () => {
const url = new URL(buildAuthorizeUrl('state-abc'))
expect(url.origin + url.pathname).toBe('https://oauth.zettle.com/authorize')
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('client_id')).toBe('client-123')
expect(url.searchParams.get('state')).toBe('state-abc')
expect(url.searchParams.get('scope')).toBe(ZETTLE_OAUTH_SCOPES)
expect(url.searchParams.get('redirect_uri')).toBe(
'https://app.example.com/api/extensions/zettle/callback',
)
})
})
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
const refreshAccessToken = vi.fn()
vi.mock('../lib/oauth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/oauth')>()
return { ...actual, refreshAccessToken: (...args: unknown[]) => refreshAccessToken(...args) }
})
vi.mock('../lib/credentials', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/credentials')>()
return { ...actual, refreshTokenOf: () => 'refresh-token', encryptCredential: (v: string) => `enc:${v}` }
})
import { syncZettlePurchases } from '../lib/order-sync'
import type { ZettleConnection } from '../types'
const connection: ZettleConnection = {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
organization_uuid: 'org-1',
organization_name: 'Caféet',
refresh_token_encrypted: 'enc:old',
oauth_state: null,
return_origin: null,
sync_lock_until: '1970-01-01T00:00:00.000Z',
status: 'active',
currency: 'SEK',
transaction_sync_enabled: true,
last_order_synced_at: null,
error_message: null,
connected_at: '2026-09-01T00:00:00.000Z',
disconnected_at: null,
created_at: '2026-09-01T00:00:00.000Z',
updated_at: '2026-09-01T00:00:00.000Z',
}
describe('syncZettlePurchases sync claim', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not touch the rotating refresh token when another run holds the claim', async () => {
const { supabase, enqueue, calls } = createQueuedMockSupabase()
enqueue({ data: [] }) // claim update matched no row: locked by another run
const summary = await syncZettlePurchases(
supabase as unknown as SupabaseClient,
{ ...connection },
{ info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never,
)
expect(summary.locked).toBe(true)
expect(refreshAccessToken).not.toHaveBeenCalled()
const claim = calls.find((c) => c.method === 'update')
expect(claim?.table).toBe('zettle_connections')
expect(claim?.args[0]).toHaveProperty('sync_lock_until')
})
})
@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest'
import {
buildVatBreakdown,
fromMinor,
mapLineItems,
mapPurchaseToWebshopRows,
purchaseQualifiesAsPaidSale,
purchaseQualifiesAsRefund,
unsupportedReason,
zettlePurchaseExternalId,
zettleStoreScope,
} from '../lib/order-sync'
import type { ZettlePurchase } from '../types'
function sale(overrides: Partial<ZettlePurchase> = {}): ZettlePurchase {
return {
purchaseUUID1: '11111111-1111-1111-1111-111111111111',
purchaseNumber: 42,
globalPurchaseNumber: 42,
amount: 12500,
vatAmount: 2500,
currency: 'SEK',
country: 'SE',
created: '2026-09-01T12:00:00.000+0000',
refund: false,
products: [
{
quantity: '1',
type: 'PRODUCT',
name: 'Kaffe',
vatPercentage: 25,
rowTaxableAmount: 10000,
},
],
payments: [{ type: 'IZETTLE_CARD', uuid: 'pay-1', amount: 12500 }],
groupedVatAmounts: { '25.0': 2500 },
...overrides,
}
}
describe('zettle order-sync mapping', () => {
it('freezes the external_id template', () => {
expect(zettleStoreScope('org-abc')).toBe('org-abc')
expect(zettlePurchaseExternalId('org-abc', 'p-1')).toBe('zettle_org-abc_purchase_p-1')
})
it('qualifies card sales and rejects invoice-only purchases', () => {
expect(purchaseQualifiesAsPaidSale(sale())).toBe(true)
expect(
purchaseQualifiesAsPaidSale(
sale({ payments: [{ type: 'IZETTLE_INVOICE', amount: 12500 }] }),
),
).toBe(false)
expect(purchaseQualifiesAsPaidSale(sale({ amount: 0 }))).toBe(false)
})
it('qualifies refunds by the refund flag', () => {
expect(
purchaseQualifiesAsRefund(
sale({
refund: true,
amount: -12500,
vatAmount: -2500,
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
purchaseUUID1: '22222222-2222-2222-2222-222222222222',
}),
),
).toBe(true)
})
it('builds VAT breakdown from groupedVatAmounts', () => {
expect(buildVatBreakdown(sale())).toEqual([{ rate: 25, net: 100, tax: 25 }])
})
it('maps line items that reconstruct the charged total', () => {
const lines = mapLineItems(sale())
expect(lines).toHaveLength(1)
expect(lines[0]).toMatchObject({ name: 'Kaffe', quantity: 1, total: 100, total_tax: 25 })
})
it('maps a paid sale into a webshop order row with underlag', () => {
const rows = mapPurchaseToWebshopRows(
{ id: 'conn-1', organization_name: 'Caféet' },
'org-1',
sale(),
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
platform: 'zettle',
row_type: 'order',
is_paid: true,
total: 125,
total_tax: 25,
order_number: '42',
payment_method: 'IZETTLE_CARD',
external_id: 'zettle_org-1_purchase_11111111-1111-1111-1111-111111111111',
})
expect(rows[0].line_items).toHaveLength(1)
expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 100, tax: 25 }])
})
it('maps a refund with parent external_id', () => {
const rows = mapPurchaseToWebshopRows(
{ id: 'conn-1', organization_name: 'Caféet' },
'org-1',
sale({
purchaseUUID1: '22222222-2222-2222-2222-222222222222',
refund: true,
amount: -12500,
vatAmount: -2500,
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
products: [
{
quantity: '-1',
type: 'PRODUCT',
name: 'Kaffe',
vatPercentage: 25,
rowTaxableAmount: -10000,
},
],
groupedVatAmounts: { '25.0': -2500 },
}),
)
expect(rows).toHaveLength(1)
expect(rows[0].row_type).toBe('refund')
expect(rows[0].total).toBe(-125)
expect(rows[0].parent_external_id).toBe(
'zettle_org-1_purchase_11111111-1111-1111-1111-111111111111',
)
})
it('keeps line items and row-derived net when Zettle rounding drifts one öre per row', () => {
// 33.37 kr at 25%: Zettle net 26.70, re-derived tax 6.68, row sum 33.38.
const drifted = sale({
amount: 3337,
vatAmount: 667,
products: [
{ quantity: '1', type: 'PRODUCT', name: 'Bulle', vatPercentage: 25, rowTaxableAmount: 2670 },
],
payments: [{ type: 'IZETTLE_CARD', uuid: 'pay-2', amount: 3337 }],
groupedVatAmounts: { '25.0': 667 },
})
expect(mapLineItems(drifted)).toHaveLength(1)
// Net comes from the row (26.70), not tax / rate (26.68).
expect(buildVatBreakdown(drifted)).toEqual([{ rate: 25, net: 26.7, tax: 6.67 }])
// Two öre off on a single row is not rounding: drop the snapshot.
expect(mapLineItems(sale({ amount: 3340, products: drifted.products }))).toEqual([])
})
it('falls back to tax / rate when rows carry no net', () => {
expect(buildVatBreakdown(sale({ products: [] }))).toEqual([{ rate: 25, net: 100, tax: 25 }])
})
it('imports sales the row model cannot book as unpaid needs_review rows', () => {
const conn = { id: 'conn-1', organization_name: 'Caféet' }
const split = mapPurchaseToWebshopRows(conn, 'org-1', sale({
payments: [
{ type: 'IZETTLE_CARD', uuid: 'p1', amount: 7500 },
{ type: 'IZETTLE_CASH', uuid: 'p2', amount: 5000 },
],
}))
expect(split[0]).toMatchObject({
is_paid: false,
status: 'needs_review',
payment_method_title: 'Delad betalning: bokför manuellt',
})
// Card + unpaid invoice split must not count as paid either.
expect(unsupportedReason(sale({
payments: [{ type: 'IZETTLE_CARD', amount: 5000 }, { type: 'IZETTLE_INVOICE', amount: 7500 }],
}))).toBe('split_tender')
expect(unsupportedReason(sale({ payments: [{ type: 'GIFTCARD', amount: 12500 }] }))).toBe(
'voucher_tender',
)
expect(unsupportedReason(sale({
products: [{ quantity: '1', type: 'GIFTCARD', name: 'Presentkort', vatPercentage: 0, rowTaxableAmount: 12500 }],
}))).toBe('giftcard_sale')
expect(unsupportedReason(sale({
payments: [{ type: 'IZETTLE_CARD', amount: 12500, gratuityAmount: 1000 }],
}))).toBe('gratuity')
// Two card payments go to the same account: still one tender.
expect(unsupportedReason(sale({
payments: [{ type: 'IZETTLE_CARD', amount: 6000 }, { type: 'IZETTLE_CARD', amount: 6500 }],
}))).toBeNull()
expect(unsupportedReason(sale())).toBeNull()
})
it('does not import a refund of an unsupported sale', () => {
const rows = mapPurchaseToWebshopRows(
{ id: 'conn-1', organization_name: 'Caféet' },
'org-1',
sale({
purchaseUUID1: '33333333-3333-3333-3333-333333333333',
refund: true,
amount: -12500,
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
payments: [{ type: 'GIFTCARD', amount: -12500 }],
}),
)
expect(rows).toEqual([])
})
it('converts minor units via fromMinor', () => {
expect(fromMinor(12500)).toBe(125)
expect(fromMinor(-50)).toBe(-0.5)
})
})
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { syncSummary } from '../lib/settings-actions'
describe('zettle syncSummary', () => {
it('classifies revoked / empty / feed / errors / partial', () => {
expect(syncSummary({ transactions: { fetched: 1, revoked: true } }).reason).toBe(
'revoked',
)
expect(syncSummary({ transactions: { fetched: 0, inserted: 0 } }).reason).toBe('empty')
expect(syncSummary({ transactions: { fetched: 3, inserted: 2 } })).toEqual({
reason: 'feed',
values: { fetched: 3, imported: 2, needsReview: 0 },
})
expect(syncSummary({ transactions: { fetched: 3, inserted: 1, errors: 2 } })).toEqual({
reason: 'errors',
values: { fetched: 3, imported: 1, needsReview: 0, errors: 2 },
})
expect(
syncSummary({
transactions: { fetched: 5, inserted: 4, errors: 0, deadlineReached: true },
}),
).toEqual({
reason: 'partial',
values: { fetched: 5, imported: 4, needsReview: 0, errors: 0 },
})
})
})
+351
View File
@@ -0,0 +1,351 @@
import { NextResponse } from 'next/server'
import crypto from 'crypto'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { resolveOAuthOrigin } from '@/lib/auth/oauth-flows'
import { isZettleConfigured } from './lib/credentials'
import { buildAuthorizeUrl, disconnectApplication, refreshAccessToken } from './lib/oauth'
import { refreshTokenOf } from './lib/credentials'
import { syncZettlePurchases } from './lib/order-sync'
import type { ZettleConnection, ZettleStatusResponse } from './types'
const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
const NOT_CONFIGURED_MESSAGE =
'Zettle-integrationen är inte konfigurerad på den här installationen.'
const STATUS_COLUMNS =
'id, status, organization_uuid, organization_name, currency, error_message, connected_at, transaction_sync_enabled, last_order_synced_at'
type AuthedContext = {
supabase: ExtensionContext['supabase']
userId: string
isAnonymous: boolean
companyId: string
}
async function requireUserAndCompany(
ctx: ExtensionContext | undefined,
): Promise<AuthedContext | NextResponse> {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
return {
supabase,
userId: user.id,
isAnonymous: Boolean(user.is_anonymous),
companyId: ctx.companyId,
}
}
async function guardConnectPreconditions(auth: AuthedContext): Promise<NextResponse | null> {
if (auth.isAnonymous) return sandboxBlockedResponse()
const sandboxBlocked = await guardSandbox(auth.supabase, auth.companyId)
if (sandboxBlocked) return sandboxBlocked
return requireCapability(auth.supabase, auth.companyId, CAPABILITY.zettle_sync)
}
export const zettleApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/status',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const { data: rows } = await auth.supabase
.from('zettle_connections')
.select(STATUS_COLUMNS)
.eq('company_id', auth.companyId)
.order('created_at', { ascending: false })
.limit(10)
const connection = rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
const payload: ZettleStatusResponse = {
configured: isZettleConfigured(),
connection,
}
return NextResponse.json(payload)
},
},
{
method: 'POST',
path: '/connect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const blocked = await guardConnectPreconditions(auth)
if (blocked) return blocked
const rl = await checkRateLimit({
prefix: 'zettle:connect',
identifier: auth.userId,
...RATE_LIMIT_CONNECT,
})
if (!rl.ok) return rl.response!
if (!isZettleConfigured()) {
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
}
const { data: existing } = await auth.supabase
.from('zettle_connections')
.select('id')
.eq('company_id', auth.companyId)
.eq('status', 'active')
if (existing && existing.length > 0) {
return NextResponse.json(
{ error: 'Företaget har redan ett anslutet Zettle-konto. Koppla från det först.' },
{ status: 409 },
)
}
// Drop stale pending rows for this company so a retry starts clean.
await auth.supabase
.from('zettle_connections')
.update({ status: 'error', oauth_state: null, error_message: 'Ersatt av ny anslutning' })
.eq('company_id', auth.companyId)
.eq('status', 'pending')
const oauthState = crypto.randomUUID()
// Remember the validated origin (app or brand domain) the merchant
// started on: Zettle redirects to the one registered callback URL, so
// the callback cannot see which brand the browser came from.
const returnOrigin = await resolveOAuthOrigin(request)
const { data: created, error: insertError } = await auth.supabase
.from('zettle_connections')
.insert({
company_id: auth.companyId,
user_id: auth.userId,
status: 'pending',
oauth_state: oauthState,
return_origin: returnOrigin,
})
.select('id')
.single()
if (insertError || !created) {
log.error('[zettle] Failed to create pending connection', {
message: insertError?.message,
code: insertError?.code,
companyId: auth.companyId,
})
return NextResponse.json(
{ error: 'Kunde inte starta anslutningen. Försök igen.' },
{ status: 500 },
)
}
try {
return NextResponse.json({ url: buildAuthorizeUrl(oauthState) })
} catch (err) {
log.error('[zettle] Failed to build authorize URL', {
message: err instanceof Error ? err.message : String(err),
})
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
}
},
},
{
method: 'POST',
path: '/sync',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const capabilityBlocked = await requireCapability(
auth.supabase,
auth.companyId,
CAPABILITY.zettle_sync,
)
if (capabilityBlocked) return capabilityBlocked
const rl = await checkRateLimit({
prefix: 'zettle:sync',
identifier: auth.userId,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
const { data: connection } = await auth.supabase
.from('zettle_connections')
.select('*')
.eq('company_id', auth.companyId)
.eq('status', 'active')
.maybeSingle()
if (!connection) {
return NextResponse.json({ error: 'Inget anslutet Zettle-konto.' }, { status: 404 })
}
try {
const serviceClient = createServiceClientNoCookies()
const summary = await syncZettlePurchases(
serviceClient,
connection as ZettleConnection,
undefined,
Date.now() + 240_000,
)
if (summary.locked) {
return NextResponse.json(
{ error: 'En synkronisering pågår redan. Försök igen om en stund.' },
{ status: 409 },
)
}
return NextResponse.json({ success: true, transactions: summary })
} catch (error) {
log.error('[zettle] Manual sync failed', {
message: error instanceof Error ? error.message : String(error),
connection_id: connection.id,
})
return NextResponse.json(
{ error: 'Synkroniseringen misslyckades. Försök igen.' },
{ status: 502 },
)
}
},
},
{
method: 'POST',
path: '/transaction-sync',
handler: async (request: Request, ctx?: ExtensionContext) => {
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const capabilityBlocked = await requireCapability(
auth.supabase,
auth.companyId,
CAPABILITY.zettle_sync,
)
if (capabilityBlocked) return capabilityBlocked
const rl = await checkRateLimit({
prefix: 'zettle:transaction-sync-toggle',
identifier: auth.userId,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
if (typeof body.enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 })
}
const { data: updated, error: updateError } = await auth.supabase
.from('zettle_connections')
.update({ transaction_sync_enabled: body.enabled })
.eq('company_id', auth.companyId)
.eq('status', 'active')
.select('id')
if (updateError) {
return NextResponse.json(
{ error: 'Kunde inte spara inställningen. Försök igen.' },
{ status: 500 },
)
}
if (!updated || updated.length === 0) {
return NextResponse.json({ error: 'Inget anslutet Zettle-konto.' }, { status: 404 })
}
return NextResponse.json({ success: true, enabled: body.enabled })
},
},
{
method: 'DELETE',
path: '/disconnect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const rl = await checkRateLimit({
prefix: 'zettle:disconnect',
identifier: auth.userId,
...RATE_LIMIT_DISCONNECT,
})
if (!rl.ok) return rl.response!
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
const base = auth.supabase
.from('zettle_connections')
.select('id, status, organization_uuid, refresh_token_encrypted')
.eq('company_id', auth.companyId)
const query = body.connection_id
? base.eq('id', body.connection_id).limit(1)
: base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1)
const { data: rows, error: findError } = await query
const connection = rows?.[0]
if (findError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.refresh_token_encrypted) {
try {
const tokens = await refreshAccessToken(refreshTokenOf(connection))
await disconnectApplication(tokens.access_token)
} catch (err) {
log.warn('[zettle] Remote disconnect failed; continuing local revoke', {
message: err instanceof Error ? err.message : String(err),
connection_id: connection.id,
})
}
}
const { error: updateError } = await auth.supabase
.from('zettle_connections')
.update({
status: 'revoked',
refresh_token_encrypted: null,
oauth_state: null,
disconnected_at: new Date().toISOString(),
})
.eq('id', connection.id)
.eq('company_id', auth.companyId)
if (updateError) {
log.error('[zettle] Failed to mark connection revoked', {
message: updateError.message,
connection_id: connection.id,
})
return NextResponse.json(
{ error: 'Kunde inte koppla från. Försök igen.' },
{ status: 500 },
)
}
if (ctx?.emit) {
try {
await ctx.emit({
type: 'zettle.disconnected',
payload: {
connectionId: connection.id,
organizationUuid: connection.organization_uuid ?? null,
reason: 'user',
userId: auth.userId,
companyId: auth.companyId,
},
})
} catch {
// Audit event failure must not block disconnect.
}
}
return NextResponse.json({ success: true })
},
},
]
@@ -0,0 +1,386 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useFormat } from '@/lib/hooks/use-format'
import { failureDescription } from '@/lib/browser/action-failure'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react'
import {
zettleRequest,
syncSummary,
ZETTLE_CONNECT_TIMEOUT_MS,
ZETTLE_SYNC_TIMEOUT_MS,
type ZettleSyncPayload,
} from '../lib/settings-actions'
import type { ZettleStatusResponse } from '../types'
type ConnectionInfo = NonNullable<ZettleStatusResponse['connection']>
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
active: 'success',
pending: 'secondary',
revoked: 'warning',
error: 'destructive',
}
export default function ZettleSettingsPanel() {
const t = useTranslations('zettle')
const tCommon = useTranslations('common')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const { formatDateLong } = useFormat()
const [loading, setLoading] = useState(true)
const [loadFailed, setLoadFailed] = useState(false)
const [configured, setConfigured] = useState(false)
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
const [connecting, setConnecting] = useState(false)
const [disconnecting, setDisconnecting] = useState(false)
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [syncing, setSyncing] = useState(false)
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
const failureCopy = { timeout: t('action_timeout'), network: t('action_network') }
const loadStatus = useCallback(async () => {
const result = await zettleRequest<ZettleStatusResponse>({
url: '/api/extensions/ext/zettle/status',
method: 'GET',
locale,
})
setLoading(false)
if (!result.ok || !result.data) {
setLoadFailed(true)
return
}
setLoadFailed(false)
setConfigured(result.data.configured)
setConnection(result.data.connection)
}, [locale])
useEffect(() => {
void loadStatus()
}, [loadStatus])
useEffect(() => {
const connected = searchParams.get('zettle_connected')
const error = searchParams.get('zettle_error')
if (!connected && !error) return
if (connected === 'true') {
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
} else if (error) {
// searchParams.get already returns the decoded value; decoding again
// throws URIError when the message contains a literal % character.
toast({
title: t('connect_failed_title'),
description: error,
variant: 'destructive',
})
}
router.replace('/import?mode=zettle')
}, [searchParams, toast, t, router])
function retryLoadStatus() {
setLoading(true)
void loadStatus()
}
async function handleConnect() {
if (connecting) return
setConnecting(true)
try {
const result = await zettleRequest<{ url?: string }>({
url: '/api/extensions/ext/zettle/connect',
locale,
timeoutMs: ZETTLE_CONNECT_TIMEOUT_MS,
})
if (!result.ok) {
toast({
title: t('connect_failed_title'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
if (!result.data?.url) {
toast({ title: t('connect_failed_title'), variant: 'destructive' })
return
}
window.location.href = result.data.url
} finally {
setConnecting(false)
}
}
async function handleSyncNow() {
if (syncing) return
setSyncing(true)
try {
const result = await zettleRequest<ZettleSyncPayload>({
url: '/api/extensions/ext/zettle/sync',
locale,
timeoutMs: ZETTLE_SYNC_TIMEOUT_MS,
})
if (!result.ok) {
toast({
title: t('sync_failed_title'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
const summary = syncSummary(result.data)
if (summary.reason === 'revoked') {
toast({
title: t('sync_failed_title'),
description: t('sync_revoked'),
variant: 'destructive',
})
} else if (summary.reason === 'partial') {
toast({ title: t('sync_partial_title'), description: t('sync_partial', summary.values) })
} else if (summary.reason === 'empty') {
toast({ title: t('sync_done_title'), description: t('sync_done_empty') })
} else if (summary.reason === 'errors') {
toast({ title: t('sync_done_title'), description: t('sync_done_feed_errors', summary.values) })
} else if (summary.reason === 'feed') {
toast({ title: t('sync_done_title'), description: t('sync_done_feed', summary.values) })
} else {
toast({ title: t('sync_done_title') })
}
await loadStatus()
} finally {
setSyncing(false)
}
}
async function handleToggleTransactionSync(enabled: boolean) {
if (togglingTransactionSync) return
setTogglingTransactionSync(true)
try {
const result = await zettleRequest({
url: '/api/extensions/ext/zettle/transaction-sync',
body: { enabled },
locale,
})
if (!result.ok) {
toast({
title: t('transaction_sync_toggle_failed'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
toast({
title: enabled
? t('transaction_sync_enabled_toast')
: t('transaction_sync_disabled_toast'),
})
await loadStatus()
} finally {
setTogglingTransactionSync(false)
}
}
async function handleDisconnect() {
if (!connection || disconnecting) return
setDisconnecting(true)
try {
const result = await zettleRequest({
url: '/api/extensions/ext/zettle/disconnect',
method: 'DELETE',
body: { connection_id: connection.id },
locale,
})
if (!result.ok) {
toast({
title: t('disconnect_failed_title'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
toast({ title: t('disconnected_toast_title'), description: t('disconnected_toast_description') })
setConfirmDisconnect(false)
await loadStatus()
} finally {
setDisconnecting(false)
}
}
if (loading) {
return (
<Card>
<CardContent className="space-y-3 p-6">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-10 w-40" />
</CardContent>
</Card>
)
}
if (loadFailed) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<p className="text-sm text-destructive">{t('load_failed')}</p>
<Button variant="outline" size="sm" onClick={retryLoadStatus}>
<RefreshCw className="mr-2 h-4 w-4" />
{tCommon('retry')}
</Button>
</CardContent>
</Card>
)
}
if (!configured) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
</CardContent>
</Card>
)
}
const isActive = connection?.status === 'active'
const showConnect = !connection || !isActive
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 pt-0">
<p className="text-sm text-muted-foreground">{t('description')}</p>
{connection && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border p-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{connection.organization_name ||
connection.organization_uuid ||
t('unnamed_store')}
</span>
<Badge variant={STATUS_VARIANT[connection.status]}>
{t(`status_${connection.status}`)}
</Badge>
</div>
{connection.organization_uuid && connection.organization_name && (
<p className="mt-1 text-sm text-muted-foreground">
{connection.organization_uuid}
</p>
)}
{isActive && connection.connected_at && (
<p className="mt-1 text-sm text-muted-foreground">
{t('connected_since', { date: formatDateLong(connection.connected_at) })}
</p>
)}
{connection.error_message && (
<p className="mt-1 text-sm text-destructive">{connection.error_message}</p>
)}
</div>
</div>
{isActive &&
(confirmDisconnect ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleDisconnect}
disabled={disconnecting}
>
{t('disconnect_confirm')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDisconnect(false)}
disabled={disconnecting}
>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
{syncing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{syncing ? t('syncing') : t('sync_now')}
</Button>
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(true)}>
<Unlink className="mr-2 h-4 w-4" />
{t('disconnect')}
</Button>
</div>
))}
</div>
)}
{showConnect && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('connect_hint')}</p>
<Button onClick={handleConnect} disabled={connecting}>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Link2 className="mr-2 h-4 w-4" />
)}
{connecting ? t('connecting') : t('connect')}
</Button>
</div>
)}
{isActive && connection && (
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border p-4">
<div className="min-w-0 max-w-prose space-y-1">
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
<p className="text-sm text-muted-foreground">{t('transaction_sync_description')}</p>
{connection.transaction_sync_enabled ? (
<p className="text-xs text-muted-foreground">
{connection.last_order_synced_at
? t('transaction_sync_last_synced', {
date: formatDateLong(connection.last_order_synced_at),
})
: t('transaction_sync_never_synced')}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t('transaction_sync_backfill_note')}
</p>
)}
</div>
<Switch
checked={connection.transaction_sync_enabled}
onCheckedChange={handleToggleTransactionSync}
disabled={togglingTransactionSync}
aria-label={t('transaction_sync_title')}
/>
</div>
)}
</CardContent>
</Card>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { Extension } from '@/lib/extensions/types'
import { zettleApiRoutes } from './api-routes'
/**
* Zettle extension
*
* Connects a company's PayPal Zettle merchant account via partner-hosted
* OAuth (authorization code grant + rotating refresh token) and upserts
* paid purchases and refunds into webshop_orders (the Orders page), with
* per-rate VAT and a line-item snapshot as booking underlag. Feed-only
* (same doctrine as WooCommerce/Shopify): nothing is auto-booked. Finance
* API payouts/fees are out of scope for v1.
*
* Required environment variables:
* - ZETTLE_CLIENT_ID
* - ZETTLE_CLIENT_SECRET
* - ZETTLE_CREDENTIALS_ENCRYPTION_KEY
*/
export const zettleExtension: Extension = {
id: 'zettle',
name: 'Zettle',
version: '1.0.0',
sector: 'general',
settingsPanel: {
label: 'Zettle',
path: '/import?mode=zettle',
},
apiRoutes: zettleApiRoutes,
}
export default zettleExtension
+128
View File
@@ -0,0 +1,128 @@
/**
* 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)
}
@@ -0,0 +1,58 @@
import crypto from 'crypto'
/**
* At-rest encryption for Zettle OAuth refresh tokens.
*
* AES-256-GCM with a dedicated env key, mirroring the Shopify/WooCommerce
* credential stores: 12-byte IV, 16-byte auth tag, layout iv|tag|ciphertext,
* base64url encoded. The key is deployment-wide; what makes rows useless
* off-server is that ZETTLE_CREDENTIALS_ENCRYPTION_KEY never leaves the
* environment.
*/
const ALGORITHM = 'aes-256-gcm'
/** Whether the integration is configured on this deployment. */
export function isZettleConfigured(): boolean {
return Boolean(
process.env.ZETTLE_CLIENT_ID &&
process.env.ZETTLE_CLIENT_SECRET &&
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY,
)
}
function getEncryptionKey(): Buffer {
const key = process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY
if (!key) throw new Error('ZETTLE_CREDENTIALS_ENCRYPTION_KEY is required')
return crypto.createHash('sha256').update(key).digest()
}
export function encryptCredential(plaintext: string): string {
const key = getEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
}
export function decryptCredential(ciphertext: string): string {
const key = getEncryptionKey()
const combined = Buffer.from(ciphertext, 'base64url')
const iv = combined.subarray(0, 12)
const tag = combined.subarray(12, 28)
const encrypted = combined.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}
/** Decrypted refresh token for an active connection. */
export function refreshTokenOf(connection: {
refresh_token_encrypted: string | null
}): string {
if (!connection.refresh_token_encrypted) {
throw new Error('Connection has no stored refresh token')
}
return decryptCredential(connection.refresh_token_encrypted)
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Zettle partner-hosted OAuth 2.0 (authorization code grant).
*
* Scopes: READ:PURCHASE (Purchase API) + READ:USERINFO (users/self for the
* organization UUID that becomes store_scope). Refresh tokens rotate: every
* refresh returns a new refresh token that MUST replace the previous one.
*/
import {
fetchWithTimeout,
OAUTH_TIMEOUT_MS,
OAUTH_REVOKE_TIMEOUT_MS,
} from '@/lib/http/fetch-with-timeout'
import { isZettleConfigured } from './credentials'
import type { ZettleTokenPair, ZettleUserSelf } from '../types'
const AUTH_ENDPOINT = 'https://oauth.zettle.com/authorize'
const TOKEN_ENDPOINT = 'https://oauth.zettle.com/token'
const USERS_SELF_ENDPOINT = 'https://oauth.zettle.com/users/self'
const DISCONNECT_ENDPOINT = 'https://oauth.zettle.com/application-connections/self'
/** Space-separated scopes requested at authorize time. */
export const ZETTLE_OAUTH_SCOPES = 'READ:PURCHASE READ:USERINFO'
export function getZettleClientCredentials(): { clientId: string; clientSecret: string } {
const clientId = process.env.ZETTLE_CLIENT_ID
const clientSecret = process.env.ZETTLE_CLIENT_SECRET
if (!clientId || !clientSecret) {
throw new Error('Zettle OAuth is not configured: set ZETTLE_CLIENT_ID and ZETTLE_CLIENT_SECRET')
}
return { clientId, clientSecret }
}
export function getZettleRedirectUri(): string {
const base = process.env.NEXT_PUBLIC_APP_URL
if (!base) {
throw new Error('NEXT_PUBLIC_APP_URL is required for Zettle OAuth')
}
return `${base.replace(/\/$/, '')}/api/extensions/zettle/callback`
}
export function buildAuthorizeUrl(state: string): string {
if (!isZettleConfigured()) {
throw new Error('Zettle is not configured')
}
const { clientId } = getZettleClientCredentials()
const params = new URLSearchParams({
response_type: 'code',
scope: ZETTLE_OAUTH_SCOPES,
client_id: clientId,
redirect_uri: getZettleRedirectUri(),
state,
})
return `${AUTH_ENDPOINT}?${params.toString()}`
}
async function postToken(body: URLSearchParams): Promise<ZettleTokenPair> {
const res = await fetchWithTimeout(
TOKEN_ENDPOINT,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: body.toString(),
// Node fetch can replay POST bodies across 307/308 redirects, including
// cross-origin. Refuse redirects so client_secret never leaves oauth.zettle.com.
redirect: 'error',
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Zettle token exchange' },
)
if (!res.ok) {
const errText = await res.text().catch(() => '')
throw new ZettleOAuthError(
`Zettle token exchange failed: ${res.status}${errText ? ` ${errText}` : ''}`,
res.status,
)
}
const json = (await res.json()) as Partial<ZettleTokenPair>
if (typeof json.access_token !== 'string' || !json.access_token) {
throw new ZettleOAuthError('Zettle token exchange returned no access token', 0)
}
if (typeof json.refresh_token !== 'string' || !json.refresh_token) {
throw new ZettleOAuthError('Zettle token exchange returned no refresh token', 0)
}
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
expires_in: typeof json.expires_in === 'number' ? json.expires_in : 7200,
}
}
export async function exchangeCodeForTokens(code: string): Promise<ZettleTokenPair> {
const { clientId, clientSecret } = getZettleClientCredentials()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: getZettleRedirectUri(),
})
return postToken(body)
}
/**
* Exchange a refresh token for a new access + refresh pair. Callers MUST
* persist the new refresh_token (Zettle rotates it on every refresh).
*/
export async function refreshAccessToken(refreshToken: string): Promise<ZettleTokenPair> {
const { clientId, clientSecret } = getZettleClientCredentials()
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
})
return postToken(body)
}
export async function fetchUserSelf(accessToken: string): Promise<ZettleUserSelf> {
const res = await fetchWithTimeout(
USERS_SELF_ENDPOINT,
{
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Zettle users/self' },
)
if (!res.ok) {
throw new ZettleOAuthError(`Zettle users/self failed: ${res.status}`, res.status)
}
const json = (await res.json()) as Partial<ZettleUserSelf>
if (typeof json.organizationUuid !== 'string' || !json.organizationUuid) {
throw new ZettleOAuthError('Zettle users/self returned no organizationUuid', 0)
}
return {
uuid: typeof json.uuid === 'string' ? json.uuid : '',
organizationUuid: json.organizationUuid,
}
}
/** Best-effort remote revoke; local revoke still proceeds if this fails. */
export async function disconnectApplication(accessToken: string): Promise<void> {
try {
await fetchWithTimeout(
DISCONNECT_ENDPOINT,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
},
{ timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Zettle disconnect' },
)
} catch {
// Remote revoke is best-effort.
}
}
export class ZettleOAuthError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
this.name = 'ZettleOAuthError'
}
}
export function isRevokedOAuthError(error: unknown): boolean {
if (!(error instanceof ZettleOAuthError)) return false
return error.status === 400 || error.status === 401 || error.status === 403
}
+629
View File
@@ -0,0 +1,629 @@
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
}
@@ -0,0 +1,28 @@
import { resolveBrandByHost } from '@/lib/branding/resolve'
/**
* Re-validate the origin stored on a pending connection before redirecting
* to it. The connect route stores a validated value, but company members can
* UPDATE zettle_connections through RLS, so the column is not an authorization
* boundary: an edited row must never turn the OAuth callback into an open
* redirect. Accepted: the canonical app origin, or an https origin whose host
* resolves to a brand in the brands table. Anything else falls back to the
* app origin.
*/
export async function validateReturnOrigin(
stored: string | null | undefined,
appOrigin: string,
): Promise<string> {
if (!stored) return appOrigin
let url: URL
try {
url = new URL(stored)
} catch {
return appOrigin
}
if (url.origin === new URL(appOrigin).origin) return appOrigin
if (url.protocol !== 'https:' || url.port) return appOrigin
const brand = await resolveBrandByHost(url.hostname)
if (!brand || brand.domain.toLowerCase() !== url.hostname.toLowerCase()) return appOrigin
return `https://${url.hostname}`
}
@@ -0,0 +1,72 @@
/**
* The Zettle settings panel's server calls, each classified into exactly one
* outcome. Same doctrine as the Shopify/Stripe panels' settings-actions.
*/
import {
panelRequest,
type PanelRequestOptions,
type PanelRequestResult,
} from '@/lib/browser/panel-request'
export const ZETTLE_ACTION_TIMEOUT_MS = 15_000
export const ZETTLE_CONNECT_TIMEOUT_MS = 30_000
export const ZETTLE_SYNC_TIMEOUT_MS = 310_000
export type ZettleRequestResult<T> = PanelRequestResult<T>
export type ZettleRequestOptions = PanelRequestOptions
export { serverErrorMessage } from '@/lib/browser/panel-request'
export function zettleRequest<T>(options: ZettleRequestOptions): Promise<ZettleRequestResult<T>> {
return panelRequest<T>({ timeoutMs: ZETTLE_ACTION_TIMEOUT_MS, ...options })
}
export interface ZettleSyncPayload {
success?: boolean
transactions?: {
fetched?: number
refundsFetched?: number
inserted?: number
updated?: number
unchanged?: number
errors?: number
revoked?: boolean
deadlineReached?: boolean
needsReview?: number
} | null
}
type SyncCounts = {
fetched: number
imported: number
/** Sales imported unbookable (split tender, gift card, tip); see order-sync. */
needsReview: number
}
export type ZettleSyncOutcome =
| { reason: 'revoked' }
| { reason: 'empty' }
| { reason: 'partial'; values: SyncCounts & { errors: number } }
| { reason: 'errors'; values: SyncCounts & { errors: number } }
| { reason: 'feed'; values: SyncCounts }
| { reason: 'unknown' }
export function syncSummary(payload: ZettleSyncPayload | null): ZettleSyncOutcome {
const summary = payload?.transactions
if (!summary) return { reason: 'unknown' }
if (summary.revoked === true) return { reason: 'revoked' }
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
const fetched = summary.fetched
const imported = typeof summary.inserted === 'number' ? summary.inserted : 0
const errors = typeof summary.errors === 'number' ? summary.errors : 0
const needsReview = typeof summary.needsReview === 'number' ? summary.needsReview : 0
if (summary.deadlineReached === true) {
return { reason: 'partial', values: { fetched, imported, needsReview, errors } }
}
if (fetched === 0) return { reason: 'empty' }
if (errors > 0) return { reason: 'errors', values: { fetched, imported, needsReview, errors } }
return { reason: 'feed', values: { fetched, imported, needsReview } }
}
+23
View File
@@ -0,0 +1,23 @@
{
"id": "zettle",
"sector": "general",
"exportName": "zettleExtension",
"entryPoint": "@/extensions/general/zettle",
"workspace": null,
"requiredEnvVars": [
"ZETTLE_CLIENT_ID",
"ZETTLE_CLIENT_SECRET",
"ZETTLE_CREDENTIALS_ENCRYPTION_KEY"
],
"optionalEnvVars": [],
"npmDependencies": [],
"definition": {
"name": "Zettle",
"category": "import",
"icon": "CreditCard",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Hämta betalda köp och återbetalningar från Zettle till Ordersidan",
"longDescription": "Anslut ditt Zettle-konto så hämtas betalda köp och återbetalningar automatiskt varje natt till Ordersidan, med belopp, betalsätt, moms per sats och radunderlag. Inget bokförs automatiskt: du bokför varje köp själv från Ordersidan."
}
}
+116
View File
@@ -0,0 +1,116 @@
/** Row shape of public.zettle_connections. */
export interface ZettleConnection {
id: string
company_id: string
user_id: string
/**
* Merchant organization UUID from GET users/self. Frozen into store_scope /
* external_id so disconnect/reconnect of the same merchant stays deduped.
*/
organization_uuid: string | null
organization_name: string | null
/** AES-256-GCM encrypted OAuth refresh token. */
refresh_token_encrypted: string | null
oauth_state: string | null
/** Validated app or brand origin the connect flow started on; the callback returns there. */
return_origin: string | null
/** Sync claim held by a running cron/manual sync; a past timestamp (default epoch) = free. */
sync_lock_until: string
status: 'pending' | 'active' | 'revoked' | 'error'
currency: string | null
/** Opt-in: nightly purchase-feed cron (the manual sync button ignores it). */
transaction_sync_enabled: boolean
/** Purchase-polling cursor (max purchase timestamp processed). */
last_order_synced_at: string | null
error_message: string | null
connected_at: string | null
disconnected_at: string | null
created_at: string
updated_at: string
}
/** Status payload returned by GET /api/extensions/ext/zettle/status. */
export interface ZettleStatusResponse {
configured: boolean
connection: Pick<
ZettleConnection,
| 'id'
| 'status'
| 'organization_uuid'
| 'organization_name'
| 'currency'
| 'error_message'
| 'connected_at'
| 'transaction_sync_enabled'
| 'last_order_synced_at'
> | null
}
/** One product line on a Zettle purchase. */
export interface ZettleProduct {
quantity: string
type?: string
name?: string | null
variantName?: string | null
vatPercentage?: number | null
/** Net (excl. VAT) in minor currency units. */
rowTaxableAmount?: number | null
unitPrice?: number | null
unitName?: string | null
comment?: string | null
}
/** One payment on a Zettle purchase. */
export interface ZettlePayment {
uuid?: string
type: string
amount?: number
gratuityAmount?: number
}
/** Optional purchase-level service charge (e.g. shipping). */
export interface ZettleServiceCharge {
amount: number
title?: string | null
vatPercentage?: number | null
quantity?: string | null
}
/**
* Minimal Purchase API v2 shape consumed by the feed. Timestamps are ISO 8601
* (often with +0000 offset rather than Z).
*/
export interface ZettlePurchase {
purchaseUUID1: string
purchaseNumber?: number
globalPurchaseNumber?: number
/** Gross amount (incl. VAT) in minor units; negative on refunds. */
amount: number
/** VAT amount in minor units. */
vatAmount?: number
currency: string
country?: string
created?: string
timestamp?: string
refund?: boolean
refunded?: boolean
refundsPurchaseUUID1?: string | null
products?: ZettleProduct[]
payments?: ZettlePayment[]
/** Map of VAT rate percent string → tax amount in minor units. */
groupedVatAmounts?: Record<string, number> | null
serviceCharge?: ZettleServiceCharge | null
customAmountSale?: boolean
source?: string
}
export interface ZettleUserSelf {
uuid: string
organizationUuid: string
}
export interface ZettleTokenPair {
access_token: string
refresh_token: string
expires_in: number
}