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:
co-authored by
Claude Fable 5.1
parent
500806f001
commit
6ea92f3152
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user