feat(zettle): sync paid purchases into webshop_orders (#2445)

Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed).

Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw.
What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body.
Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows.

Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB
This commit is contained in:
Mattsson
2026-09-09 11:19:39 +02:00
committed by GitHub
parent 500806f001
commit 6ea92f3152
58 changed files with 4069 additions and 18 deletions
+5
View File
@@ -162,6 +162,11 @@ GOOGLE_MAIL_CONNECT_COMPANY_IDS=
# SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
# SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
# SKATTEVERKET_DISABLED=true # emergency kill switch
# Zettle purchase feed (extensions/general/zettle): partner OAuth app from
# developer.zettle.com with redirect URI <app url>/api/extensions/zettle/callback.
# ZETTLE_CLIENT_ID=
# ZETTLE_CLIENT_SECRET=
# ZETTLE_CREDENTIALS_ENCRYPTION_KEY= # openssl rand -base64 32; encrypts stored refresh tokens
# Peppol e-invoicing via Qvalia (certified Access Point + SMP, partner model).
# The adapter registers itself when QVALIA_API_KEY, QVALIA_PARTNER_REG_NO and
# QVALIA_BASE_URL are all set; PEPPOL_TRANSPORT_PROVIDER=qvalia switches it on
+9
View File
@@ -1665,6 +1665,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] #2391 skeptic pass: orgNumberKey only strips hyphens and spaces and only unprefixes 12-digit values behind 16/18/19/20. Reason: 26 prod supplier rows hold a VAT number (orgnr + 01, prefixes 55/52/87) in org_number, and 'last 10 of any 12 digits' would have rewritten them to another company's identity; letters stay because BE0123456789 is not the Swedish 0123456789. The matcher scans live suppliers only (archived_at IS NULL), the list and v1 search compare without separators, the CSV import and the provider migration orchestrator key and write through the same rule.
[2026-09-08] correctEntry re-points the original entry's transaction_voucher_links rows to the corrected entry (lib/core/bookkeeping/storno-service.ts relinkTransactionsToEntry) instead of deleting them as issue #2364 proposed. Why: for a samlingsverifikat (bulk-book N>1) the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into Att bokföra; the pointer column already follows the correction and the junction now follows it the same way, so every reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC) sees one live anchor. Rejected: a relink_entry_anchors RPC moving pointer and junction atomically (a migration plus pg test for a path that is already best-effort across five other statements; revisit if a partial failure ever shows up in the surfaced transactionRelinkError). Prod repair (planned, runs after merge on the founder's go; completion gets its own dated entry): the 7 stale links (3 companies) all sit on rows whose pointer names a posted entry (4 on a correction chain, 3 from a June 2026 samlingsverifikat storno that predates the junction cleanup and were re-booked 1:1); they will be re-pointed to the pointer's entry, the same rule the fix applies, rather than deleted.
[2026-09-08] delete_last_voucher returns a correction's bank anchors (transactions.journal_entry_id and transaction_voucher_links rows) to correction_of_id before the row is deleted (migration 20260908095907). Why: the #2364 skeptic showed that once the junction follows the correction, the two-step undo (delete the correction, then the storno) cascaded the links away and restored an original that explains bank rows nobody points at, so the rows surfaced as bookable again; before, the links had stayed on the original by accident. Chosen over releasing the rows (the restored original would still explain them, same trap) and over a TS pre-step in the DELETE route (not atomic with the RPC's own guards: a refused delete would leave anchors on a reversed entry). A duplicate of a link the original already holds is dropped, not re-pointed (UNIQUE (transaction_id, journal_entry_id)).
[2026-09-08] Zettle v1 is paid purchases (and refunds) into webshop_orders via the Purchase API, Shopify-shaped feed-only: partner-hosted OAuth with rotating refresh token (encrypted), unpaid IZETTLE_INVOICE-only purchases skipped, line items + groupedVatAmounts as booking underlag. Finance API payouts/fees deliberately out of scope (phase 2, Stripe settlement style).
[2026-09-08] Zettle orders cron pages candidates (range) and caps entitled syncs at 50 instead of limit(50) before hasCapability: entitlement skips must not consume the batch or advance last_order_synced_at (purchase recovery cursor). Declined a separate cron_checked_at column for now; revisit if scanned non-entitled volume becomes a time-budget problem.
[2026-09-08] #2395 missing-underlag: split the supplier-invoice .or() into two .in() lookups instead of halving LOOKUP_CHUNK or moving the list filters into the verifikat_without_documents RPC: halving only moves the proxy-limit wall; the RPC move deletes the TS mirror (one predicate instead of two) but pulls search, series, date and sort into SQL and is the surface owner's design call. Intended next step, not taken here.
[2026-09-08] Onboarding name search picks via chip row, never the top hit blind: Typesense name ranking is fuzzy and common names ("Bygg AB", sole-trader surnames) make a blind pick a wrong company; five hits, active first, fired on Enter only to protect the 3000/mo TIC budget (issue #2418).
[2026-09-08] #2418 skeptic pass: a name-search hit's org number is derived from the Lens registrationNumber via lensRegistrationToOrgNumber (16-prefixed 12 digits and the 16-digit enskild-firma form, century plus 4-digit serial, reduce to the 10-digit key; hits that do not normalize are dropped) instead of being stored as returned. Why: the typed-orgnr path never stores Lens's number, so this was the first place a 16-digit value reached settings.org_number and createCompany refused it at the last step. Sole-trader chips name the form ("Enskild firma") instead of the number, because that number is the owner's personnummer and the repo masks those everywhere else; the number still travels in the search payload since a pick has to store it.
@@ -1681,3 +1684,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version.
[2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are.
[2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs.
[2026-09-08] Zettle v1 = paid purchases→webshop_orders via Purchase API; Finance payouts out of scope; OAuth refresh-token store; unpaid IZETTLE_INVOICE-only skipped.
[2026-09-08] Zettle v1 is paid purchases (and refunds) into webshop_orders via the Purchase API, Shopify-shaped feed-only: partner-hosted OAuth with rotating refresh token (encrypted), unpaid IZETTLE_INVOICE-only purchases skipped, line items + groupedVatAmounts as booking underlag. Finance API payouts/fees deliberately out of scope (phase 2, Stripe settlement style).
[2026-09-08] Zettle orders cron pages candidates (range) and caps entitled syncs at 50 instead of limit(50) before hasCapability: entitlement skips must not consume the batch or advance last_order_synced_at (purchase recovery cursor). Declined a separate cron_checked_at column for now; revisit if scanned non-entitled volume becomes a time-budget problem.
[2026-09-09] PR #2416 (Zettle, community-authored) adopted on the contributor's branch instead of re-implemented: kept the Shopify-shaped feed-only design, added migration 20260909100400 for the sites that enumerate platforms/connection tables (platform CHECKs on webshop_orders and webshop_store_settings, writer-role gate trigger, migration-reset snapshot and lock via the 20260826150000 wrapper pattern rather than re-issuing the 400-line reset body), renamed the four PR migrations past prod's 20260908143051, froze the validated connect origin on zettle_connections.return_origin so white-label users return to their brand domain (Zettle has one registered callback URL), and derive per-rate VAT net from Zettle's own product rows (tax / rate drifted from what was charged) with a one-öre-per-row line-sum tolerance instead of 0.005 kr (silently dropped multi-row 12%/6% underlag). Per-purchase rows kept for v1; daily kassarapport aggregation and Finance API fees/payouts are the follow-up.
[2026-09-09] Zettle v1 imports split-tender, gift-card (sale or tender) and tip-carrying purchases as is_paid = false rows titled 'bokför manuellt' and skips their refunds, instead of booking them through the one-account / revenue-per-rate model: the skeptic showed a card+cash split would put the whole gross on 1686, a card+invoice split would count as paid, a 0 % gift-card row would land on 3004 / ruta 42 (it is a 2421 liability), and tips would book as momsfri sale. Proper support needs per-payment amounts and a voucher liability on webshop_orders (follow-up). Sync runs claim the connection (sync_lock_until) before refreshing the rotating token: a concurrent cron + manual sync otherwise reuses a refresh token, Zettle answers 400, and the connection flips to revoked. The cron snapshots its candidate list before syncing because each sync moves the row to the tail of the last_order_synced_at ordering, so live offset paging re-fetched synced rows and skipped unseen ones.
+32 -2
View File
@@ -2326,11 +2326,14 @@ const WooCommercePanel = getSettingsPanel('woocommerce')
// And for the Shopify order feed: same category as the WooCommerce feed above.
const ShopifyPanel = getSettingsPanel('shopify')
// And for the Zettle purchase feed: same category as the Shopify feed above.
const ZettlePanel = getSettingsPanel('zettle')
// ============================================================
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'skattekonto' | 'sie' | 'underlag' | 'csv_data' | 'migration'
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'zettle' | 'bank' | 'skattekonto' | 'sie' | 'underlag' | 'csv_data' | 'migration'
export default function ImportPage() {
const { isSandbox, role } = useCompany()
@@ -2366,7 +2369,7 @@ export default function ImportPage() {
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
const allowedModes = isSandbox
? ['bank', 'skattekonto', 'sie', 'underlag', 'csv_data']
: ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'skattekonto', 'sie', 'underlag', 'csv_data', 'migration']
: ['psd2', 'stripe', 'woocommerce', 'shopify', 'zettle', 'bank', 'skattekonto', 'sie', 'underlag', 'csv_data', 'migration']
if (!isSandbox && searchParams.get('migration')) {
setMode('migration')
} else {
@@ -2441,6 +2444,8 @@ export default function ImportPage() {
const woocommerceDisabled = isSandbox
const hasShopifyExtension = ENABLED_EXTENSION_IDS.has('shopify')
const shopifyDisabled = isSandbox
const hasZettleExtension = ENABLED_EXTENSION_IDS.has('zettle')
const zettleDisabled = isSandbox
return (
<div className="space-y-8">
@@ -2517,6 +2522,16 @@ export default function ImportPage() {
onClick={() => setMode('shopify')}
/>
)}
{hasZettleExtension && (
<ImportRow
title={t('zettle_title')}
sub={t('zettle_description')}
chip={<BetaChip label={t('badge_beta')} />}
chips={<LogoChip src="/logos/zettle.svg" name="Zettle" />}
disabled={zettleDisabled}
onClick={() => setMode('zettle')}
/>
)}
{hasMigrationExtension && (
<ImportRow
title={t('migration_title')}
@@ -2758,6 +2773,21 @@ export default function ImportPage() {
</Card>
)
)}
{mode === 'zettle' && (
hasZettleExtension && ZettlePanel ? (
<ZettlePanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ShoppingBag className="mb-4 h-10 w-10 text-muted-foreground/40" />
<p className="mb-1 font-medium">{t('zettle_not_enabled_title')}</p>
<p className="max-w-md text-sm text-muted-foreground">
{t('zettle_not_enabled_description')}
</p>
</CardContent>
</Card>
)
)}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'skattekonto' && <SkattekontoImportWizard />}
{/* The CSV/Excel wizard opens on "Ingående balanser" by default, which is
@@ -295,6 +295,14 @@ describe('POST /api/company/[id]/delete', () => {
client_secret_encrypted: null,
})
)
expect(updateSpies.zettle_connections).toHaveBeenCalledWith(
expect.objectContaining({
status: 'revoked',
disconnected_at: expect.any(String),
refresh_token_encrypted: null,
oauth_state: null,
})
)
expect(updateSpies.stripe_connections).toHaveBeenCalledWith(
expect.objectContaining({
status: 'revoked',
@@ -324,6 +332,7 @@ describe('POST /api/company/[id]/delete', () => {
for (const table of [
'woocommerce_connections',
'shopify_connections',
'zettle_connections',
'stripe_connections',
]) {
expect(insertSpy).toHaveBeenCalledWith(
@@ -369,5 +378,8 @@ describe('POST /api/company/[id]/delete', () => {
expect(insertSpy).toHaveBeenCalledWith(
expect.objectContaining({ table_name: 'shopify_connections' }),
)
expect(insertSpy).toHaveBeenCalledWith(
expect.objectContaining({ table_name: 'zettle_connections' }),
)
})
})
+14
View File
@@ -177,6 +177,20 @@ export async function POST(
.neq('status', 'revoked')
.select('id'),
},
{
table: 'zettle_connections',
result: await service
.from('zettle_connections')
.update({
status: 'revoked',
disconnected_at: archivedAt,
refresh_token_encrypted: null,
oauth_state: null,
})
.eq('company_id', companyId)
.neq('status', 'revoked')
.select('id'),
},
{
table: 'stripe_connections',
result: await service
@@ -0,0 +1,238 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
const mockExchangeCodeForTokens = vi.fn()
const mockFetchUserSelf = vi.fn()
vi.mock('@/extensions/general/zettle/lib/oauth', () => ({
exchangeCodeForTokens: (...args: unknown[]) => mockExchangeCodeForTokens(...args),
fetchUserSelf: (...args: unknown[]) => mockFetchUserSelf(...args),
}))
vi.mock('@/extensions/general/zettle/lib/credentials', () => ({
encryptCredential: (value: string) => `enc:${value}`,
}))
const { mockFrom, mockGetUser } = vi.hoisted(() => ({
mockFrom: vi.fn(),
mockGetUser: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue({ from: mockFrom }),
createClient: vi.fn().mockResolvedValue({ auth: { getUser: mockGetUser } }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const registryGet = vi.fn((..._args: unknown[]) => ({ id: 'zettle' }) as unknown)
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: { get: (...args: unknown[]) => registryGet(...args) },
}))
// Only a host that resolves in the brands table is a valid return origin.
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandByHost: vi.fn(async (host: string) =>
host === 'brand.testbrand.example' ? { domain: 'brand.testbrand.example' } : null,
),
}))
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
import { GET } from '../route'
const CONNECTION_ID = 'connection-1'
const OAUTH_STATE = 'state-token-1'
function makeRequest(params: Record<string, string>) {
const url = new URL('http://localhost:3000/api/extensions/zettle/callback')
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v)
}
return new Request(url.toString())
}
function mockChain(result: { data?: unknown; error?: unknown }) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'update', 'insert']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.single = vi
.fn()
.mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
chain.maybeSingle = vi
.fn()
.mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
chain.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
return chain
}
describe('GET /api/extensions/zettle/callback', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null })
mockExchangeCodeForTokens.mockResolvedValue({
access_token: 'access',
refresh_token: 'refresh',
expires_in: 7200,
})
mockFetchUserSelf.mockResolvedValue({ organizationUuid: 'org-uuid-1' })
})
it('activates only while the pending oauth_state still matches', async () => {
const findChain = mockChain({
data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' },
})
const replayChain = mockChain({ error: null })
const activateChain = mockChain({
data: {
id: CONNECTION_ID,
company_id: 'company-1',
user_id: 'user-1',
organization_uuid: 'org-uuid-1',
},
})
mockFrom
.mockReturnValueOnce(findChain)
.mockReturnValueOnce(replayChain)
.mockReturnValueOnce(activateChain)
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=zettle&zettle_connected=true',
)
const eqCalls = (activateChain.eq as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => c as [string, string],
)
expect(eqCalls).toEqual(
expect.arrayContaining([
['id', CONNECTION_ID],
['status', 'pending'],
['oauth_state', OAUTH_STATE],
]),
)
expect(activateChain.maybeSingle).toHaveBeenCalled()
})
it('refuses activation when /connect invalidated the pending row mid-callback', async () => {
// Lookup still sees the original pending row (TOCTOU), then a concurrent
// POST /connect flips it to error and clears oauth_state before activate.
const findChain = mockChain({
data: { id: CONNECTION_ID, user_id: 'user-1', company_id: 'company-1' },
})
const replayChain = mockChain({ error: null })
const activateChain = mockChain({ data: null, error: null })
mockFrom
.mockReturnValueOnce(findChain)
.mockReturnValueOnce(replayChain)
.mockReturnValueOnce(activateChain)
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=zettle&zettle_error=invalid_state',
)
const eqCalls = (activateChain.eq as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => c as [string, string],
)
expect(eqCalls).toEqual(
expect.arrayContaining([
['status', 'pending'],
['oauth_state', OAUTH_STATE],
]),
)
// Must not fall through into the conflict/error cleanup update.
expect(mockFrom).toHaveBeenCalledTimes(3)
})
it('returns the browser to the brand origin the connect flow started on', async () => {
const findChain = mockChain({
data: {
id: CONNECTION_ID,
user_id: 'user-1',
company_id: 'company-1',
return_origin: 'https://brand.testbrand.example',
},
})
const replayChain = mockChain({ error: null })
const activateChain = mockChain({
data: {
id: CONNECTION_ID,
company_id: 'company-1',
user_id: 'user-1',
organization_uuid: 'org-uuid-1',
},
})
mockFrom
.mockReturnValueOnce(findChain)
.mockReturnValueOnce(replayChain)
.mockReturnValueOnce(activateChain)
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.headers.get('location')).toBe(
'https://brand.testbrand.example/import?mode=zettle&zettle_connected=true',
)
})
it('never redirects to a tampered return_origin that is not a brand domain', async () => {
// Members can UPDATE the row through RLS: the column is not trusted.
const findChain = mockChain({
data: {
id: CONNECTION_ID,
user_id: 'user-1',
company_id: 'company-1',
return_origin: 'https://evil.example',
},
})
const replayChain = mockChain({ error: null })
const activateChain = mockChain({
data: { id: CONNECTION_ID, company_id: 'company-1', user_id: 'user-1', organization_uuid: 'org-uuid-1' },
})
mockFrom.mockReturnValueOnce(findChain).mockReturnValueOnce(replayChain).mockReturnValueOnce(activateChain)
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=zettle&zettle_connected=true',
)
})
it('returns a denied authorization to the stored brand origin', async () => {
mockFrom.mockReturnValueOnce(
mockChain({ data: { return_origin: 'https://brand.testbrand.example' } }),
)
const response = await GET(makeRequest({ error: 'access_denied', state: OAUTH_STATE }))
expect(response.headers.get('location')).toBe(
'https://brand.testbrand.example/import?mode=zettle&zettle_error=access_denied',
)
expect(mockExchangeCodeForTokens).not.toHaveBeenCalled()
})
it('refuses with 503 when the zettle extension is not enabled', async () => {
registryGet.mockReturnValueOnce(undefined)
const response = await GET(makeRequest({ code: 'ac_123', state: OAUTH_STATE }))
expect(response.status).toBe(503)
expect(mockFrom).not.toHaveBeenCalled()
})
it('redirects with invalid_state when the oauth state is unknown', async () => {
mockFrom.mockReturnValueOnce(mockChain({ data: null, error: { code: 'PGRST116' } }))
const response = await GET(makeRequest({ code: 'ac_123', state: 'unknown-state' }))
expect(response.headers.get('location')).toBe(
'http://localhost:3000/import?mode=zettle&zettle_error=invalid_state',
)
expect(mockExchangeCodeForTokens).not.toHaveBeenCalled()
})
})
+245
View File
@@ -0,0 +1,245 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { eventBus } from '@/lib/events/bus'
import { hashAuthCode } from '@/lib/auth/oauth-codes'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
import { encryptCredential } from '@/extensions/general/zettle/lib/credentials'
import { validateReturnOrigin } from '@/extensions/general/zettle/lib/return-origin'
import {
exchangeCodeForTokens,
fetchUserSelf,
} from '@/extensions/general/zettle/lib/oauth'
// This route emits zettle.connected (audit trail). ensureInitialized() must
// run at module load so the event_log handler has subscribed before the first
// emit on a cold instance.
ensureInitialized()
/**
* GET /api/extensions/zettle/callback
*
* OAuth callback for Zettle partner authorization. Must be a real Next.js
* route (not an extension dispatcher handler) because Zettle redirects the
* user's browser to this URL directly.
*/
export async function GET(request: Request) {
// Physical route: refuse (503) when the extension is not enabled instead
// of quietly activating connections for a feature the deployment turned off.
loadExtensions()
if (!extensionRegistry.get('zettle')) {
return NextResponse.json(
{ error: 'Zettle extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
const appBase = (process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000').replace(/\/$/, '')
// Return the browser to the origin the connect flow started on. Zettle
// redirects to the one registered callback URL, so a white-label user
// would otherwise land on the canonical app domain. The stored value is
// re-validated here (members can update the row through RLS): the app
// origin or a brand domain, never an arbitrary URL.
const returnUrlFor = async (origin: string | null | undefined) =>
`${await validateReturnOrigin(origin, appBase)}/import?mode=zettle`
let returnUrl = `${appBase}/import?mode=zettle`
if (error) {
const errorMessage = errorDescription || error
const logDenied = error === 'access_denied' ? console.warn : console.error
logDenied('[zettle] OAuth authorization denied', {
error,
error_description: errorDescription,
has_state: !!state,
})
if (state) {
try {
const supabase = await createServiceClient()
const { data: denied } = await supabase
.from('zettle_connections')
.update({ status: 'error', error_message: errorMessage, oauth_state: null })
.eq('oauth_state', state)
.eq('status', 'pending')
.select('return_origin')
.maybeSingle()
returnUrl = await returnUrlFor(denied?.return_origin)
} catch (cleanupError) {
console.error('[zettle] Failed to clean up pending connection:', cleanupError)
}
}
return NextResponse.redirect(
`${returnUrl}&zettle_error=${encodeURIComponent(errorMessage)}`,
)
}
if (!code || !state) {
return NextResponse.redirect(`${returnUrl}&zettle_error=missing_parameters`)
}
const supabase = await createServiceClient()
try {
const { data: pendingConnection, error: findError } = await supabase
.from('zettle_connections')
.select('id, user_id, company_id, return_origin')
.eq('oauth_state', state)
.eq('status', 'pending')
.single()
if (pendingConnection) {
returnUrl = await returnUrlFor(pendingConnection.return_origin)
}
if (findError || !pendingConnection) {
console.error('[zettle] No pending connection for oauth_state', {
findError: findError
? { message: findError.message, code: findError.code }
: null,
hasCode: !!code,
})
return NextResponse.redirect(
`${returnUrl}&zettle_error=${encodeURIComponent('invalid_state')}`,
)
}
const initiator = await requireFlowInitiator(request, pendingConnection.user_id, {
flow: 'zettle.callback',
})
if (!initiator.ok) {
if (initiator.reason === 'no_session') {
return initiator.response
}
return NextResponse.redirect(
`${returnUrl}&zettle_error=${encodeURIComponent(FLOW_INITIATOR_MISMATCH_MESSAGE)}`,
)
}
const { error: replayError } = await supabase
.from('oauth_used_codes')
.insert({ code_hash: hashAuthCode(code) })
if (replayError) {
console.error('[zettle] Authorization code already used', {
connectionId: pendingConnection.id,
code: replayError.code,
})
return NextResponse.redirect(
`${returnUrl}&zettle_error=${encodeURIComponent('invalid_state')}`,
)
}
const tokens = await exchangeCodeForTokens(code)
const userSelf = await fetchUserSelf(tokens.access_token)
// Require the row still be the original pending state. POST /connect can
// invalidate this row between lookup and activate; filtering only by id
// would revive the abandoned flow and attach the wrong Zettle org.
const { data: updatedConnection, error: updateError } = await supabase
.from('zettle_connections')
.update({
organization_uuid: userSelf.organizationUuid,
organization_name: null,
refresh_token_encrypted: encryptCredential(tokens.refresh_token),
status: 'active',
connected_at: new Date().toISOString(),
error_message: null,
oauth_state: null,
transaction_sync_enabled: true,
})
.eq('id', pendingConnection.id)
.eq('status', 'pending')
.eq('oauth_state', state)
.select('id, company_id, user_id, organization_uuid')
.maybeSingle()
if (updateError) {
const isConflict = updateError.code === '23505'
console.error('[zettle] Failed to activate connection', {
connectionId: pendingConnection.id,
error: { message: updateError.message, code: updateError.code },
})
await supabase
.from('zettle_connections')
.update({
status: 'error',
error_message: isConflict
? 'Zettle-organisationen är redan ansluten till ett företag.'
: 'Anslutningen kunde inte slutföras.',
oauth_state: null,
refresh_token_encrypted: null,
})
.eq('id', pendingConnection.id)
.eq('status', 'pending')
return NextResponse.redirect(
`${returnUrl}&zettle_error=${encodeURIComponent(
isConflict ? 'account_already_connected' : 'activation_failed',
)}`,
)
}
if (!updatedConnection) {
console.error('[zettle] Pending connection invalidated before activation', {
connectionId: pendingConnection.id,
})
return NextResponse.redirect(
`${returnUrl}&zettle_error=${encodeURIComponent('invalid_state')}`,
)
}
try {
await eventBus.emit({
type: 'zettle.connected',
payload: {
connectionId: updatedConnection.id,
organizationUuid: updatedConnection.organization_uuid!,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
console.error('[zettle] Failed to emit zettle.connected event', {
connectionId: updatedConnection.id,
error: emitError instanceof Error ? emitError.message : String(emitError),
})
}
return NextResponse.redirect(`${returnUrl}&zettle_connected=true`)
} catch (error) {
console.error('[zettle] Callback error', {
message: error instanceof Error ? error.message : String(error),
name: error instanceof Error ? error.name : undefined,
hasCode: !!code,
})
try {
await supabase
.from('zettle_connections')
.update({
status: 'error',
error_message: 'Anslutningen kunde inte slutföras.',
oauth_state: null,
})
.eq('oauth_state', state)
.eq('status', 'pending')
} catch (cleanupError) {
console.error('[zettle] Callback cleanup failed:', cleanupError)
}
return NextResponse.redirect(
`${returnUrl}&zettle_error=${encodeURIComponent('connection_failed')}`,
)
}
}
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Each test re-imports the route after vi.resetModules(); the cold import
// exceeds the 5 s default under a loaded CI shard.
vi.setConfig({ testTimeout: 30_000 })
const verifyCronSecret = vi.fn((..._args: unknown[]) => null as unknown)
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: (...args: unknown[]) => verifyCronSecret(...args),
}))
const registryGet = vi.fn()
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: { get: (...args: unknown[]) => registryGet(...args) },
}))
const rangeResult = vi.fn()
vi.mock('@/lib/supabase/service-client', () => ({
createServiceRoleClient: vi.fn(() => ({
from: () => ({
select: () => ({
eq: () => ({
eq: () => ({
order: () => ({
range: (...args: unknown[]) => rangeResult(...args),
}),
}),
}),
}),
}),
})),
}))
const isZettleConfigured = vi.fn((..._args: unknown[]) => true)
vi.mock('@/extensions/general/zettle/lib/credentials', () => ({
isZettleConfigured: (...args: unknown[]) => isZettleConfigured(...args),
}))
const syncZettlePurchases = vi.fn()
vi.mock('@/extensions/general/zettle/lib/order-sync', () => ({
syncZettlePurchases: (...args: unknown[]) => syncZettlePurchases(...args),
}))
const hasCapability = vi.fn()
vi.mock('@/lib/entitlements/has-capability', () => ({
hasCapability: (...args: unknown[]) => hasCapability(...args),
}))
const CONNECTION = { id: 'conn-1', company_id: 'company-1' }
async function callRoute() {
const { GET } = await import('../route')
return GET(new Request('https://example.test/api/extensions/zettle/orders/cron'))
}
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
verifyCronSecret.mockReturnValue(null)
registryGet.mockReturnValue({ id: 'zettle' })
isZettleConfigured.mockReturnValue(true)
hasCapability.mockResolvedValue(true)
rangeResult.mockResolvedValue({ data: [CONNECTION], error: null })
syncZettlePurchases.mockResolvedValue({
fetched: 2,
refundsFetched: 0,
inserted: 2,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
})
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
})
describe('GET /api/extensions/zettle/orders/cron', () => {
it('returns 503 when the extension is disabled', async () => {
registryGet.mockReturnValue(null)
const res = await callRoute()
expect(res.status).toBe(503)
expect(syncZettlePurchases).not.toHaveBeenCalled()
})
it('syncs entitled active connections', async () => {
const res = await callRoute()
expect(res.status).toBe(200)
expect(syncZettlePurchases).toHaveBeenCalled()
const body = await res.json()
expect(body.processed).toBe(1)
expect(body.inserted).toBe(2)
})
it('pages past a front of non-entitled connections so entitled ones still sync', async () => {
// Old limit(50) before hasCapability starved eligible rows behind 50 skips.
const notEntitled = Array.from({ length: 50 }, (_, i) => ({
id: `skip-${i}`,
company_id: `co-skip-${i}`,
}))
const entitled = { id: 'conn-entitled', company_id: 'company-entitled' }
rangeResult.mockResolvedValue({ data: [...notEntitled, entitled], error: null })
hasCapability.mockImplementation(async (_sb: unknown, companyId: string) => {
return companyId === 'company-entitled'
})
const res = await callRoute()
expect(res.status).toBe(200)
const body = await res.json()
expect(body.processed).toBe(1)
expect(syncZettlePurchases).toHaveBeenCalledTimes(1)
expect(syncZettlePurchases.mock.calls[0][1]).toMatchObject(entitled)
// Skips must not touch last_order_synced_at (purchase recovery cursor).
expect(rangeResult).toHaveBeenCalledWith(0, 99)
})
})
@@ -0,0 +1,156 @@
import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { isZettleConfigured } from '@/extensions/general/zettle/lib/credentials'
import { syncZettlePurchases } from '@/extensions/general/zettle/lib/order-sync'
import type { ZettleConnection } from '@/extensions/general/zettle/types'
export const maxDuration = 300
/** Cap of entitled connections synced per cron invocation. */
const MAX_SYNCED = 50
/** Page size when scanning candidates ordered by purchase cursor. */
const CANDIDATE_PAGE_SIZE = 100
/** Hard stop so a flood of non-entitled rows cannot burn the whole budget scanning. */
const MAX_CANDIDATES_SCANNED = 2000
/**
* GET /api/extensions/zettle/orders/cron
* Nightly purchase sync for connections that opted in (transaction_sync_enabled):
* upserts each connected org's paid purchases and refunds into webshop_orders.
*
* Candidates are paged by last_order_synced_at. Entitlement skips do not advance
* that cursor (it controls purchase recovery) and do not consume the sync cap,
* so a front of non-entitled rows cannot starve eligible connections behind them.
*/
export const GET = withCronContext('cron.zettle_order_sync', async (_request, ctx) => {
loadExtensions()
if (!extensionRegistry.get('zettle')) {
ctx.log.warn('zettle extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'Zettle extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'Missing Supabase configuration' },
})
}
if (!isZettleConfigured()) {
return NextResponse.json({ message: 'Zettle not configured', processed: 0 })
}
const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey)
const startTime = Date.now()
const TIME_BUDGET_MS = 240_000
const deadlineMs = startTime + TIME_BUDGET_MS
const results: Array<{
connectionId: string
inserted: number
updated: number
status: 'synced' | 'revoked' | 'locked' | 'error'
}> = []
// Snapshot the candidate list BEFORE syncing any of it. Each sync moves its
// row's last_order_synced_at to "now" (to the tail of this ordering), so
// paging with a live offset would re-fetch already-synced rows on page 2
// and never reach the eligible rows that slid into the gap.
const candidates: ZettleConnection[] = []
for (let offset = 0; offset < MAX_CANDIDATES_SCANNED; offset += CANDIDATE_PAGE_SIZE) {
const { data: page, error: connError } = await supabase
.from('zettle_connections')
.select('*')
.eq('status', 'active')
.eq('transaction_sync_enabled', true)
.order('last_order_synced_at', { ascending: true, nullsFirst: true })
.range(offset, offset + CANDIDATE_PAGE_SIZE - 1)
if (connError) {
ctx.log.error('failed to fetch zettle connections', connError, {
message: connError.message,
code: connError.code,
})
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
}
if (!page || page.length === 0) break
candidates.push(...(page as ZettleConnection[]))
if (page.length < CANDIDATE_PAGE_SIZE) break
}
let scanned = 0
for (const connection of candidates) {
if (results.length >= MAX_SYNCED) break
if (Date.now() >= deadlineMs) {
ctx.log.info('time budget reached', { processedSoFar: results.length, scanned })
break
}
scanned += 1
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.zettle_sync))) {
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
continue
}
try {
const summary = await syncZettlePurchases(supabase, connection, ctx.log, deadlineMs)
if (summary.locked) {
// A manual sync holds the claim; it will advance the cursor itself.
results.push({ connectionId: connection.id, inserted: 0, updated: 0, status: 'locked' })
continue
}
if (summary.deadlineReached) {
ctx.log.info('connection stopped early on time budget; remaining rows resume next run', {
connectionId: connection.id,
})
}
results.push({
connectionId: connection.id,
inserted: summary.inserted,
updated: summary.updated,
status: summary.revoked ? 'revoked' : 'synced',
})
} catch (error) {
ctx.log.error('zettle purchase sync failed for connection', error as Error, {
connectionId: connection.id,
companyId: connection.company_id,
})
results.push({
connectionId: connection.id,
inserted: 0,
updated: 0,
status: 'error',
})
}
}
if (candidates.length === 0) {
return NextResponse.json({
message: 'No connections with transaction sync enabled',
processed: 0,
})
}
const totalInserted = results.reduce((acc, r) => acc + r.inserted, 0)
ctx.log.info('zettle purchase sync summary', {
processed: results.length,
scanned,
totalInserted,
failed: results.filter((r) => r.status === 'error').length,
})
return NextResponse.json({ processed: results.length, inserted: totalInserted, results })
})
+1
View File
@@ -31,6 +31,7 @@
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/zettle/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/reanchor/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
+1
View File
@@ -31,6 +31,7 @@
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/stripe/transactions/cron
45 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/woocommerce/orders/cron
15 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/shopify/orders/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/zettle/orders/cron
0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
30 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/reanchor/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["calendar","enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce","shopify","mail"]}
{"$schema":"./extensions.schema.json","extensions":["calendar","enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce","shopify","zettle","mail"]}
+2 -1
View File
@@ -34,7 +34,8 @@
"whatsapp-inbox",
"woocommerce",
"stripe",
"shopify"
"shopify",
"zettle"
]
},
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { isRevokedCredentialsError, ZettleApiError } from '../lib/api-client'
describe('zettle api-client', () => {
it('treats 401/403 as revoked credentials', () => {
expect(isRevokedCredentialsError(new ZettleApiError('denied', 401))).toBe(true)
expect(isRevokedCredentialsError(new ZettleApiError('denied', 403))).toBe(true)
expect(isRevokedCredentialsError(new ZettleApiError('oops', 500))).toBe(false)
expect(isRevokedCredentialsError(new Error('nope'))).toBe(false)
})
})
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) }
})
vi.mock('../lib/oauth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/oauth')>()
return {
...actual,
refreshAccessToken: vi.fn(),
disconnectApplication: vi.fn().mockResolvedValue(undefined),
}
})
vi.mock('../lib/order-sync', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/order-sync')>()
return { ...actual, syncZettlePurchases: vi.fn() }
})
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => ({ service: true })),
}))
vi.mock('@/lib/auth/oauth-flows', () => ({
resolveOAuthOrigin: vi.fn().mockResolvedValue('https://brand.testbrand.example'),
}))
import { zettleExtension } from '../index'
import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { syncZettlePurchases } from '../lib/order-sync'
import { resolveOAuthOrigin } from '@/lib/auth/oauth-flows'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
function findRoute(method: string, path: string) {
const route = zettleExtension.apiRoutes?.find((r) => r.method === method && r.path === path)
expect(route, `${method} ${path} must be registered`).toBeDefined()
return route!
}
function makeRequest(method: string, body?: unknown): Request {
return new Request('https://test.local/api/extensions/ext/zettle/x', {
method,
headers: { 'Content-Type': 'application/json' },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
}
function makeContext(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'zettle',
requestId: 'req_test',
supabase,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
const USER = { id: 'user-1', is_anonymous: false }
describe('zettle extension routes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('ZETTLE_CLIENT_ID', 'cid')
vi.stubEnv('ZETTLE_CLIENT_SECRET', 'csecret')
vi.stubEnv('ZETTLE_CREDENTIALS_ENCRYPTION_KEY', 'test-key')
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com')
vi.mocked(requireCapability).mockResolvedValue(null)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('GET /status returns configured + connection', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: [
{
id: 'c1',
status: 'active',
organization_uuid: 'org-1',
organization_name: null,
currency: 'SEK',
error_message: null,
connected_at: '2026-09-01T00:00:00.000Z',
transaction_sync_enabled: true,
last_order_synced_at: null,
},
],
})
const res = await findRoute('GET', '/status').handler(makeRequest('GET'), makeContext(supabase))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.configured).toBe(true)
expect(body.connection.id).toBe('c1')
})
it('POST /connect stages pending and returns authorize url', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } }) // guardSandbox
enqueue({ data: [] }) // no active connection
enqueue({ data: [] }) // clear stale pending
enqueue({ data: { id: 'pending-1' } }) // insert pending
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
const body = await res.json()
const url = new URL(body.url)
expect(url.origin + url.pathname).toBe('https://oauth.zettle.com/authorize')
expect(url.searchParams.get('client_id')).toBe('cid')
expect(url.searchParams.get('scope')).toBe('READ:PURCHASE READ:USERINFO')
// The validated brand/app origin is frozen on the pending row so the
// callback can send the browser back to the domain it started on.
expect(resolveOAuthOrigin).toHaveBeenCalledTimes(1)
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
expect(requireCapability).toHaveBeenCalledWith(
expect.anything(),
'company-1',
CAPABILITY.zettle_sync,
)
})
it('POST /connect invalidates a prior pending row before staging a new one', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } }) // guardSandbox
enqueue({ data: [] }) // no active connection
enqueue({ data: [] }) // clear stale pending (status -> error)
enqueue({ data: { id: 'pending-2' } }) // insert replacement pending
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
// Second connect must leave the abandoned oauth_state unusable so a late
// callback for the first flow cannot activate that row (see callback test).
expect(supabase.from).toHaveBeenCalledWith('zettle_connections')
})
it('POST /connect refuses when capability is blocked', async () => {
vi.mocked(requireCapability).mockResolvedValue(capabilityBlockedResponse(CAPABILITY.zettle_sync))
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({ data: { is_sandbox: false } })
const res = await findRoute('POST', '/connect').handler(makeRequest('POST'), makeContext(supabase))
expect([402, 403]).toContain(res.status)
})
it('POST /sync calls syncZettlePurchases', async () => {
vi.mocked(syncZettlePurchases).mockResolvedValue({
fetched: 1,
refundsFetched: 0,
inserted: 1,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
needsReview: 0,
skippedUnsupported: 0,
})
const { supabase, enqueue } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null })
enqueue({
data: {
id: 'c1',
status: 'active',
company_id: 'company-1',
user_id: 'user-1',
organization_uuid: 'org-1',
refresh_token_encrypted: 'enc',
},
})
const res = await findRoute('POST', '/sync').handler(makeRequest('POST'), makeContext(supabase))
expect(res.status).toBe(200)
expect(syncZettlePurchases).toHaveBeenCalled()
})
})
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
import { decryptCredential, encryptCredential, isZettleConfigured } from '../lib/credentials'
describe('zettle credentials', () => {
it('round-trips AES-GCM ciphertext', () => {
const cipher = encryptCredential('IZSEC-refresh-token')
expect(cipher).not.toContain('IZSEC')
expect(decryptCredential(cipher)).toBe('IZSEC-refresh-token')
})
it('is configured only when all three env vars are set', () => {
const prev = {
id: process.env.ZETTLE_CLIENT_ID,
secret: process.env.ZETTLE_CLIENT_SECRET,
key: process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY,
}
process.env.ZETTLE_CLIENT_ID = 'id'
process.env.ZETTLE_CLIENT_SECRET = 'secret'
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = 'key'
expect(isZettleConfigured()).toBe(true)
delete process.env.ZETTLE_CLIENT_ID
expect(isZettleConfigured()).toBe(false)
process.env.ZETTLE_CLIENT_ID = prev.id
process.env.ZETTLE_CLIENT_SECRET = prev.secret
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = prev.key
})
})
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { buildAuthorizeUrl, ZETTLE_OAUTH_SCOPES } from '../lib/oauth'
describe('zettle oauth', () => {
const env = { ...process.env }
beforeEach(() => {
process.env.ZETTLE_CLIENT_ID = 'client-123'
process.env.ZETTLE_CLIENT_SECRET = 'secret-456'
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY = 'enc-key'
process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com'
})
afterEach(() => {
process.env = { ...env }
})
it('builds the authorize URL with purchase + userinfo scopes', () => {
const url = new URL(buildAuthorizeUrl('state-abc'))
expect(url.origin + url.pathname).toBe('https://oauth.zettle.com/authorize')
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('client_id')).toBe('client-123')
expect(url.searchParams.get('state')).toBe('state-abc')
expect(url.searchParams.get('scope')).toBe(ZETTLE_OAUTH_SCOPES)
expect(url.searchParams.get('redirect_uri')).toBe(
'https://app.example.com/api/extensions/zettle/callback',
)
})
})
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
const refreshAccessToken = vi.fn()
vi.mock('../lib/oauth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/oauth')>()
return { ...actual, refreshAccessToken: (...args: unknown[]) => refreshAccessToken(...args) }
})
vi.mock('../lib/credentials', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/credentials')>()
return { ...actual, refreshTokenOf: () => 'refresh-token', encryptCredential: (v: string) => `enc:${v}` }
})
import { syncZettlePurchases } from '../lib/order-sync'
import type { ZettleConnection } from '../types'
const connection: ZettleConnection = {
id: 'conn-1',
company_id: 'company-1',
user_id: 'user-1',
organization_uuid: 'org-1',
organization_name: 'Caféet',
refresh_token_encrypted: 'enc:old',
oauth_state: null,
return_origin: null,
sync_lock_until: '1970-01-01T00:00:00.000Z',
status: 'active',
currency: 'SEK',
transaction_sync_enabled: true,
last_order_synced_at: null,
error_message: null,
connected_at: '2026-09-01T00:00:00.000Z',
disconnected_at: null,
created_at: '2026-09-01T00:00:00.000Z',
updated_at: '2026-09-01T00:00:00.000Z',
}
describe('syncZettlePurchases sync claim', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not touch the rotating refresh token when another run holds the claim', async () => {
const { supabase, enqueue, calls } = createQueuedMockSupabase()
enqueue({ data: [] }) // claim update matched no row: locked by another run
const summary = await syncZettlePurchases(
supabase as unknown as SupabaseClient,
{ ...connection },
{ info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never,
)
expect(summary.locked).toBe(true)
expect(refreshAccessToken).not.toHaveBeenCalled()
const claim = calls.find((c) => c.method === 'update')
expect(claim?.table).toBe('zettle_connections')
expect(claim?.args[0]).toHaveProperty('sync_lock_until')
})
})
@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest'
import {
buildVatBreakdown,
fromMinor,
mapLineItems,
mapPurchaseToWebshopRows,
purchaseQualifiesAsPaidSale,
purchaseQualifiesAsRefund,
unsupportedReason,
zettlePurchaseExternalId,
zettleStoreScope,
} from '../lib/order-sync'
import type { ZettlePurchase } from '../types'
function sale(overrides: Partial<ZettlePurchase> = {}): ZettlePurchase {
return {
purchaseUUID1: '11111111-1111-1111-1111-111111111111',
purchaseNumber: 42,
globalPurchaseNumber: 42,
amount: 12500,
vatAmount: 2500,
currency: 'SEK',
country: 'SE',
created: '2026-09-01T12:00:00.000+0000',
refund: false,
products: [
{
quantity: '1',
type: 'PRODUCT',
name: 'Kaffe',
vatPercentage: 25,
rowTaxableAmount: 10000,
},
],
payments: [{ type: 'IZETTLE_CARD', uuid: 'pay-1', amount: 12500 }],
groupedVatAmounts: { '25.0': 2500 },
...overrides,
}
}
describe('zettle order-sync mapping', () => {
it('freezes the external_id template', () => {
expect(zettleStoreScope('org-abc')).toBe('org-abc')
expect(zettlePurchaseExternalId('org-abc', 'p-1')).toBe('zettle_org-abc_purchase_p-1')
})
it('qualifies card sales and rejects invoice-only purchases', () => {
expect(purchaseQualifiesAsPaidSale(sale())).toBe(true)
expect(
purchaseQualifiesAsPaidSale(
sale({ payments: [{ type: 'IZETTLE_INVOICE', amount: 12500 }] }),
),
).toBe(false)
expect(purchaseQualifiesAsPaidSale(sale({ amount: 0 }))).toBe(false)
})
it('qualifies refunds by the refund flag', () => {
expect(
purchaseQualifiesAsRefund(
sale({
refund: true,
amount: -12500,
vatAmount: -2500,
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
purchaseUUID1: '22222222-2222-2222-2222-222222222222',
}),
),
).toBe(true)
})
it('builds VAT breakdown from groupedVatAmounts', () => {
expect(buildVatBreakdown(sale())).toEqual([{ rate: 25, net: 100, tax: 25 }])
})
it('maps line items that reconstruct the charged total', () => {
const lines = mapLineItems(sale())
expect(lines).toHaveLength(1)
expect(lines[0]).toMatchObject({ name: 'Kaffe', quantity: 1, total: 100, total_tax: 25 })
})
it('maps a paid sale into a webshop order row with underlag', () => {
const rows = mapPurchaseToWebshopRows(
{ id: 'conn-1', organization_name: 'Caféet' },
'org-1',
sale(),
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
platform: 'zettle',
row_type: 'order',
is_paid: true,
total: 125,
total_tax: 25,
order_number: '42',
payment_method: 'IZETTLE_CARD',
external_id: 'zettle_org-1_purchase_11111111-1111-1111-1111-111111111111',
})
expect(rows[0].line_items).toHaveLength(1)
expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 100, tax: 25 }])
})
it('maps a refund with parent external_id', () => {
const rows = mapPurchaseToWebshopRows(
{ id: 'conn-1', organization_name: 'Caféet' },
'org-1',
sale({
purchaseUUID1: '22222222-2222-2222-2222-222222222222',
refund: true,
amount: -12500,
vatAmount: -2500,
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
products: [
{
quantity: '-1',
type: 'PRODUCT',
name: 'Kaffe',
vatPercentage: 25,
rowTaxableAmount: -10000,
},
],
groupedVatAmounts: { '25.0': -2500 },
}),
)
expect(rows).toHaveLength(1)
expect(rows[0].row_type).toBe('refund')
expect(rows[0].total).toBe(-125)
expect(rows[0].parent_external_id).toBe(
'zettle_org-1_purchase_11111111-1111-1111-1111-111111111111',
)
})
it('keeps line items and row-derived net when Zettle rounding drifts one öre per row', () => {
// 33.37 kr at 25%: Zettle net 26.70, re-derived tax 6.68, row sum 33.38.
const drifted = sale({
amount: 3337,
vatAmount: 667,
products: [
{ quantity: '1', type: 'PRODUCT', name: 'Bulle', vatPercentage: 25, rowTaxableAmount: 2670 },
],
payments: [{ type: 'IZETTLE_CARD', uuid: 'pay-2', amount: 3337 }],
groupedVatAmounts: { '25.0': 667 },
})
expect(mapLineItems(drifted)).toHaveLength(1)
// Net comes from the row (26.70), not tax / rate (26.68).
expect(buildVatBreakdown(drifted)).toEqual([{ rate: 25, net: 26.7, tax: 6.67 }])
// Two öre off on a single row is not rounding: drop the snapshot.
expect(mapLineItems(sale({ amount: 3340, products: drifted.products }))).toEqual([])
})
it('falls back to tax / rate when rows carry no net', () => {
expect(buildVatBreakdown(sale({ products: [] }))).toEqual([{ rate: 25, net: 100, tax: 25 }])
})
it('imports sales the row model cannot book as unpaid needs_review rows', () => {
const conn = { id: 'conn-1', organization_name: 'Caféet' }
const split = mapPurchaseToWebshopRows(conn, 'org-1', sale({
payments: [
{ type: 'IZETTLE_CARD', uuid: 'p1', amount: 7500 },
{ type: 'IZETTLE_CASH', uuid: 'p2', amount: 5000 },
],
}))
expect(split[0]).toMatchObject({
is_paid: false,
status: 'needs_review',
payment_method_title: 'Delad betalning: bokför manuellt',
})
// Card + unpaid invoice split must not count as paid either.
expect(unsupportedReason(sale({
payments: [{ type: 'IZETTLE_CARD', amount: 5000 }, { type: 'IZETTLE_INVOICE', amount: 7500 }],
}))).toBe('split_tender')
expect(unsupportedReason(sale({ payments: [{ type: 'GIFTCARD', amount: 12500 }] }))).toBe(
'voucher_tender',
)
expect(unsupportedReason(sale({
products: [{ quantity: '1', type: 'GIFTCARD', name: 'Presentkort', vatPercentage: 0, rowTaxableAmount: 12500 }],
}))).toBe('giftcard_sale')
expect(unsupportedReason(sale({
payments: [{ type: 'IZETTLE_CARD', amount: 12500, gratuityAmount: 1000 }],
}))).toBe('gratuity')
// Two card payments go to the same account: still one tender.
expect(unsupportedReason(sale({
payments: [{ type: 'IZETTLE_CARD', amount: 6000 }, { type: 'IZETTLE_CARD', amount: 6500 }],
}))).toBeNull()
expect(unsupportedReason(sale())).toBeNull()
})
it('does not import a refund of an unsupported sale', () => {
const rows = mapPurchaseToWebshopRows(
{ id: 'conn-1', organization_name: 'Caféet' },
'org-1',
sale({
purchaseUUID1: '33333333-3333-3333-3333-333333333333',
refund: true,
amount: -12500,
refundsPurchaseUUID1: '11111111-1111-1111-1111-111111111111',
payments: [{ type: 'GIFTCARD', amount: -12500 }],
}),
)
expect(rows).toEqual([])
})
it('converts minor units via fromMinor', () => {
expect(fromMinor(12500)).toBe(125)
expect(fromMinor(-50)).toBe(-0.5)
})
})
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { syncSummary } from '../lib/settings-actions'
describe('zettle syncSummary', () => {
it('classifies revoked / empty / feed / errors / partial', () => {
expect(syncSummary({ transactions: { fetched: 1, revoked: true } }).reason).toBe(
'revoked',
)
expect(syncSummary({ transactions: { fetched: 0, inserted: 0 } }).reason).toBe('empty')
expect(syncSummary({ transactions: { fetched: 3, inserted: 2 } })).toEqual({
reason: 'feed',
values: { fetched: 3, imported: 2, needsReview: 0 },
})
expect(syncSummary({ transactions: { fetched: 3, inserted: 1, errors: 2 } })).toEqual({
reason: 'errors',
values: { fetched: 3, imported: 1, needsReview: 0, errors: 2 },
})
expect(
syncSummary({
transactions: { fetched: 5, inserted: 4, errors: 0, deadlineReached: true },
}),
).toEqual({
reason: 'partial',
values: { fetched: 5, imported: 4, needsReview: 0, errors: 0 },
})
})
})
+351
View File
@@ -0,0 +1,351 @@
import { NextResponse } from 'next/server'
import crypto from 'crypto'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { resolveOAuthOrigin } from '@/lib/auth/oauth-flows'
import { isZettleConfigured } from './lib/credentials'
import { buildAuthorizeUrl, disconnectApplication, refreshAccessToken } from './lib/oauth'
import { refreshTokenOf } from './lib/credentials'
import { syncZettlePurchases } from './lib/order-sync'
import type { ZettleConnection, ZettleStatusResponse } from './types'
const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
const NOT_CONFIGURED_MESSAGE =
'Zettle-integrationen är inte konfigurerad på den här installationen.'
const STATUS_COLUMNS =
'id, status, organization_uuid, organization_name, currency, error_message, connected_at, transaction_sync_enabled, last_order_synced_at'
type AuthedContext = {
supabase: ExtensionContext['supabase']
userId: string
isAnonymous: boolean
companyId: string
}
async function requireUserAndCompany(
ctx: ExtensionContext | undefined,
): Promise<AuthedContext | NextResponse> {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
return {
supabase,
userId: user.id,
isAnonymous: Boolean(user.is_anonymous),
companyId: ctx.companyId,
}
}
async function guardConnectPreconditions(auth: AuthedContext): Promise<NextResponse | null> {
if (auth.isAnonymous) return sandboxBlockedResponse()
const sandboxBlocked = await guardSandbox(auth.supabase, auth.companyId)
if (sandboxBlocked) return sandboxBlocked
return requireCapability(auth.supabase, auth.companyId, CAPABILITY.zettle_sync)
}
export const zettleApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/status',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const { data: rows } = await auth.supabase
.from('zettle_connections')
.select(STATUS_COLUMNS)
.eq('company_id', auth.companyId)
.order('created_at', { ascending: false })
.limit(10)
const connection = rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
const payload: ZettleStatusResponse = {
configured: isZettleConfigured(),
connection,
}
return NextResponse.json(payload)
},
},
{
method: 'POST',
path: '/connect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const blocked = await guardConnectPreconditions(auth)
if (blocked) return blocked
const rl = await checkRateLimit({
prefix: 'zettle:connect',
identifier: auth.userId,
...RATE_LIMIT_CONNECT,
})
if (!rl.ok) return rl.response!
if (!isZettleConfigured()) {
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
}
const { data: existing } = await auth.supabase
.from('zettle_connections')
.select('id')
.eq('company_id', auth.companyId)
.eq('status', 'active')
if (existing && existing.length > 0) {
return NextResponse.json(
{ error: 'Företaget har redan ett anslutet Zettle-konto. Koppla från det först.' },
{ status: 409 },
)
}
// Drop stale pending rows for this company so a retry starts clean.
await auth.supabase
.from('zettle_connections')
.update({ status: 'error', oauth_state: null, error_message: 'Ersatt av ny anslutning' })
.eq('company_id', auth.companyId)
.eq('status', 'pending')
const oauthState = crypto.randomUUID()
// Remember the validated origin (app or brand domain) the merchant
// started on: Zettle redirects to the one registered callback URL, so
// the callback cannot see which brand the browser came from.
const returnOrigin = await resolveOAuthOrigin(request)
const { data: created, error: insertError } = await auth.supabase
.from('zettle_connections')
.insert({
company_id: auth.companyId,
user_id: auth.userId,
status: 'pending',
oauth_state: oauthState,
return_origin: returnOrigin,
})
.select('id')
.single()
if (insertError || !created) {
log.error('[zettle] Failed to create pending connection', {
message: insertError?.message,
code: insertError?.code,
companyId: auth.companyId,
})
return NextResponse.json(
{ error: 'Kunde inte starta anslutningen. Försök igen.' },
{ status: 500 },
)
}
try {
return NextResponse.json({ url: buildAuthorizeUrl(oauthState) })
} catch (err) {
log.error('[zettle] Failed to build authorize URL', {
message: err instanceof Error ? err.message : String(err),
})
return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 })
}
},
},
{
method: 'POST',
path: '/sync',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const capabilityBlocked = await requireCapability(
auth.supabase,
auth.companyId,
CAPABILITY.zettle_sync,
)
if (capabilityBlocked) return capabilityBlocked
const rl = await checkRateLimit({
prefix: 'zettle:sync',
identifier: auth.userId,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
const { data: connection } = await auth.supabase
.from('zettle_connections')
.select('*')
.eq('company_id', auth.companyId)
.eq('status', 'active')
.maybeSingle()
if (!connection) {
return NextResponse.json({ error: 'Inget anslutet Zettle-konto.' }, { status: 404 })
}
try {
const serviceClient = createServiceClientNoCookies()
const summary = await syncZettlePurchases(
serviceClient,
connection as ZettleConnection,
undefined,
Date.now() + 240_000,
)
if (summary.locked) {
return NextResponse.json(
{ error: 'En synkronisering pågår redan. Försök igen om en stund.' },
{ status: 409 },
)
}
return NextResponse.json({ success: true, transactions: summary })
} catch (error) {
log.error('[zettle] Manual sync failed', {
message: error instanceof Error ? error.message : String(error),
connection_id: connection.id,
})
return NextResponse.json(
{ error: 'Synkroniseringen misslyckades. Försök igen.' },
{ status: 502 },
)
}
},
},
{
method: 'POST',
path: '/transaction-sync',
handler: async (request: Request, ctx?: ExtensionContext) => {
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const capabilityBlocked = await requireCapability(
auth.supabase,
auth.companyId,
CAPABILITY.zettle_sync,
)
if (capabilityBlocked) return capabilityBlocked
const rl = await checkRateLimit({
prefix: 'zettle:transaction-sync-toggle',
identifier: auth.userId,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
const body = (await request.json().catch(() => ({}))) as { enabled?: unknown }
if (typeof body.enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 })
}
const { data: updated, error: updateError } = await auth.supabase
.from('zettle_connections')
.update({ transaction_sync_enabled: body.enabled })
.eq('company_id', auth.companyId)
.eq('status', 'active')
.select('id')
if (updateError) {
return NextResponse.json(
{ error: 'Kunde inte spara inställningen. Försök igen.' },
{ status: 500 },
)
}
if (!updated || updated.length === 0) {
return NextResponse.json({ error: 'Inget anslutet Zettle-konto.' }, { status: 404 })
}
return NextResponse.json({ success: true, enabled: body.enabled })
},
},
{
method: 'DELETE',
path: '/disconnect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const auth = await requireUserAndCompany(ctx)
if (auth instanceof NextResponse) return auth
const rl = await checkRateLimit({
prefix: 'zettle:disconnect',
identifier: auth.userId,
...RATE_LIMIT_DISCONNECT,
})
if (!rl.ok) return rl.response!
const body = (await request.json().catch(() => ({}))) as { connection_id?: string }
const base = auth.supabase
.from('zettle_connections')
.select('id, status, organization_uuid, refresh_token_encrypted')
.eq('company_id', auth.companyId)
const query = body.connection_id
? base.eq('id', body.connection_id).limit(1)
: base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1)
const { data: rows, error: findError } = await query
const connection = rows?.[0]
if (findError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.refresh_token_encrypted) {
try {
const tokens = await refreshAccessToken(refreshTokenOf(connection))
await disconnectApplication(tokens.access_token)
} catch (err) {
log.warn('[zettle] Remote disconnect failed; continuing local revoke', {
message: err instanceof Error ? err.message : String(err),
connection_id: connection.id,
})
}
}
const { error: updateError } = await auth.supabase
.from('zettle_connections')
.update({
status: 'revoked',
refresh_token_encrypted: null,
oauth_state: null,
disconnected_at: new Date().toISOString(),
})
.eq('id', connection.id)
.eq('company_id', auth.companyId)
if (updateError) {
log.error('[zettle] Failed to mark connection revoked', {
message: updateError.message,
connection_id: connection.id,
})
return NextResponse.json(
{ error: 'Kunde inte koppla från. Försök igen.' },
{ status: 500 },
)
}
if (ctx?.emit) {
try {
await ctx.emit({
type: 'zettle.disconnected',
payload: {
connectionId: connection.id,
organizationUuid: connection.organization_uuid ?? null,
reason: 'user',
userId: auth.userId,
companyId: auth.companyId,
},
})
} catch {
// Audit event failure must not block disconnect.
}
}
return NextResponse.json({ success: true })
},
},
]
@@ -0,0 +1,386 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useFormat } from '@/lib/hooks/use-format'
import { failureDescription } from '@/lib/browser/action-failure'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react'
import {
zettleRequest,
syncSummary,
ZETTLE_CONNECT_TIMEOUT_MS,
ZETTLE_SYNC_TIMEOUT_MS,
type ZettleSyncPayload,
} from '../lib/settings-actions'
import type { ZettleStatusResponse } from '../types'
type ConnectionInfo = NonNullable<ZettleStatusResponse['connection']>
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
active: 'success',
pending: 'secondary',
revoked: 'warning',
error: 'destructive',
}
export default function ZettleSettingsPanel() {
const t = useTranslations('zettle')
const tCommon = useTranslations('common')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const { formatDateLong } = useFormat()
const [loading, setLoading] = useState(true)
const [loadFailed, setLoadFailed] = useState(false)
const [configured, setConfigured] = useState(false)
const [connection, setConnection] = useState<ConnectionInfo | null>(null)
const [connecting, setConnecting] = useState(false)
const [disconnecting, setDisconnecting] = useState(false)
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [syncing, setSyncing] = useState(false)
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
const failureCopy = { timeout: t('action_timeout'), network: t('action_network') }
const loadStatus = useCallback(async () => {
const result = await zettleRequest<ZettleStatusResponse>({
url: '/api/extensions/ext/zettle/status',
method: 'GET',
locale,
})
setLoading(false)
if (!result.ok || !result.data) {
setLoadFailed(true)
return
}
setLoadFailed(false)
setConfigured(result.data.configured)
setConnection(result.data.connection)
}, [locale])
useEffect(() => {
void loadStatus()
}, [loadStatus])
useEffect(() => {
const connected = searchParams.get('zettle_connected')
const error = searchParams.get('zettle_error')
if (!connected && !error) return
if (connected === 'true') {
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
} else if (error) {
// searchParams.get already returns the decoded value; decoding again
// throws URIError when the message contains a literal % character.
toast({
title: t('connect_failed_title'),
description: error,
variant: 'destructive',
})
}
router.replace('/import?mode=zettle')
}, [searchParams, toast, t, router])
function retryLoadStatus() {
setLoading(true)
void loadStatus()
}
async function handleConnect() {
if (connecting) return
setConnecting(true)
try {
const result = await zettleRequest<{ url?: string }>({
url: '/api/extensions/ext/zettle/connect',
locale,
timeoutMs: ZETTLE_CONNECT_TIMEOUT_MS,
})
if (!result.ok) {
toast({
title: t('connect_failed_title'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
if (!result.data?.url) {
toast({ title: t('connect_failed_title'), variant: 'destructive' })
return
}
window.location.href = result.data.url
} finally {
setConnecting(false)
}
}
async function handleSyncNow() {
if (syncing) return
setSyncing(true)
try {
const result = await zettleRequest<ZettleSyncPayload>({
url: '/api/extensions/ext/zettle/sync',
locale,
timeoutMs: ZETTLE_SYNC_TIMEOUT_MS,
})
if (!result.ok) {
toast({
title: t('sync_failed_title'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
const summary = syncSummary(result.data)
if (summary.reason === 'revoked') {
toast({
title: t('sync_failed_title'),
description: t('sync_revoked'),
variant: 'destructive',
})
} else if (summary.reason === 'partial') {
toast({ title: t('sync_partial_title'), description: t('sync_partial', summary.values) })
} else if (summary.reason === 'empty') {
toast({ title: t('sync_done_title'), description: t('sync_done_empty') })
} else if (summary.reason === 'errors') {
toast({ title: t('sync_done_title'), description: t('sync_done_feed_errors', summary.values) })
} else if (summary.reason === 'feed') {
toast({ title: t('sync_done_title'), description: t('sync_done_feed', summary.values) })
} else {
toast({ title: t('sync_done_title') })
}
await loadStatus()
} finally {
setSyncing(false)
}
}
async function handleToggleTransactionSync(enabled: boolean) {
if (togglingTransactionSync) return
setTogglingTransactionSync(true)
try {
const result = await zettleRequest({
url: '/api/extensions/ext/zettle/transaction-sync',
body: { enabled },
locale,
})
if (!result.ok) {
toast({
title: t('transaction_sync_toggle_failed'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
toast({
title: enabled
? t('transaction_sync_enabled_toast')
: t('transaction_sync_disabled_toast'),
})
await loadStatus()
} finally {
setTogglingTransactionSync(false)
}
}
async function handleDisconnect() {
if (!connection || disconnecting) return
setDisconnecting(true)
try {
const result = await zettleRequest({
url: '/api/extensions/ext/zettle/disconnect',
method: 'DELETE',
body: { connection_id: connection.id },
locale,
})
if (!result.ok) {
toast({
title: t('disconnect_failed_title'),
description: failureDescription(result, failureCopy),
variant: 'destructive',
})
return
}
toast({ title: t('disconnected_toast_title'), description: t('disconnected_toast_description') })
setConfirmDisconnect(false)
await loadStatus()
} finally {
setDisconnecting(false)
}
}
if (loading) {
return (
<Card>
<CardContent className="space-y-3 p-6">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-10 w-40" />
</CardContent>
</Card>
)
}
if (loadFailed) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<p className="text-sm text-destructive">{t('load_failed')}</p>
<Button variant="outline" size="sm" onClick={retryLoadStatus}>
<RefreshCw className="mr-2 h-4 w-4" />
{tCommon('retry')}
</Button>
</CardContent>
</Card>
)
}
if (!configured) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">{t('not_configured')}</p>
</CardContent>
</Card>
)
}
const isActive = connection?.status === 'active'
const showConnect = !connection || !isActive
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 pt-0">
<p className="text-sm text-muted-foreground">{t('description')}</p>
{connection && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-border p-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{connection.organization_name ||
connection.organization_uuid ||
t('unnamed_store')}
</span>
<Badge variant={STATUS_VARIANT[connection.status]}>
{t(`status_${connection.status}`)}
</Badge>
</div>
{connection.organization_uuid && connection.organization_name && (
<p className="mt-1 text-sm text-muted-foreground">
{connection.organization_uuid}
</p>
)}
{isActive && connection.connected_at && (
<p className="mt-1 text-sm text-muted-foreground">
{t('connected_since', { date: formatDateLong(connection.connected_at) })}
</p>
)}
{connection.error_message && (
<p className="mt-1 text-sm text-destructive">{connection.error_message}</p>
)}
</div>
</div>
{isActive &&
(confirmDisconnect ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleDisconnect}
disabled={disconnecting}
>
{t('disconnect_confirm')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDisconnect(false)}
disabled={disconnecting}
>
{t('cancel')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
{syncing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{syncing ? t('syncing') : t('sync_now')}
</Button>
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(true)}>
<Unlink className="mr-2 h-4 w-4" />
{t('disconnect')}
</Button>
</div>
))}
</div>
)}
{showConnect && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('connect_hint')}</p>
<Button onClick={handleConnect} disabled={connecting}>
{connecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Link2 className="mr-2 h-4 w-4" />
)}
{connecting ? t('connecting') : t('connect')}
</Button>
</div>
)}
{isActive && connection && (
<div className="flex flex-wrap items-start justify-between gap-4 rounded-lg border border-border p-4">
<div className="min-w-0 max-w-prose space-y-1">
<p className="text-sm font-medium">{t('transaction_sync_title')}</p>
<p className="text-sm text-muted-foreground">{t('transaction_sync_description')}</p>
{connection.transaction_sync_enabled ? (
<p className="text-xs text-muted-foreground">
{connection.last_order_synced_at
? t('transaction_sync_last_synced', {
date: formatDateLong(connection.last_order_synced_at),
})
: t('transaction_sync_never_synced')}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t('transaction_sync_backfill_note')}
</p>
)}
</div>
<Switch
checked={connection.transaction_sync_enabled}
onCheckedChange={handleToggleTransactionSync}
disabled={togglingTransactionSync}
aria-label={t('transaction_sync_title')}
/>
</div>
)}
</CardContent>
</Card>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { Extension } from '@/lib/extensions/types'
import { zettleApiRoutes } from './api-routes'
/**
* Zettle extension
*
* Connects a company's PayPal Zettle merchant account via partner-hosted
* OAuth (authorization code grant + rotating refresh token) and upserts
* paid purchases and refunds into webshop_orders (the Orders page), with
* per-rate VAT and a line-item snapshot as booking underlag. Feed-only
* (same doctrine as WooCommerce/Shopify): nothing is auto-booked. Finance
* API payouts/fees are out of scope for v1.
*
* Required environment variables:
* - ZETTLE_CLIENT_ID
* - ZETTLE_CLIENT_SECRET
* - ZETTLE_CREDENTIALS_ENCRYPTION_KEY
*/
export const zettleExtension: Extension = {
id: 'zettle',
name: 'Zettle',
version: '1.0.0',
sector: 'general',
settingsPanel: {
label: 'Zettle',
path: '/import?mode=zettle',
},
apiRoutes: zettleApiRoutes,
}
export default zettleExtension
+128
View File
@@ -0,0 +1,128 @@
/**
* Minimal Zettle Purchase API client for the paid-purchase feed.
*
* Host is fixed (purchase.izettle.com), not tenant input, so SSRF guarding
* via safeFetch is unnecessary; fetchWithTimeout covers hung connections.
* Auth is a short-lived Bearer access token obtained by refreshing the
* stored refresh token at the start of each run.
*/
import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
import { sleep } from '@/lib/utils'
import type { ZettlePurchase } from '../types'
const PURCHASE_BASE = 'https://purchase.izettle.com'
export const ZETTLE_PAGE_SIZE = 100
const REQUEST_TIMEOUT_MS = 30_000
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
const RETRY_DELAYS_MS = [1_000, 3_000]
export class ZettleApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string | null = null,
) {
super(message)
this.name = 'ZettleApiError'
}
}
/** Whether an API error means the credentials themselves are dead. */
export function isRevokedCredentialsError(error: unknown): boolean {
if (!(error instanceof ZettleApiError)) return false
return error.status === 401 || error.status === 403
}
export interface PurchasesPage {
purchases: ZettlePurchase[]
lastPurchaseHash: string | null
hasMore: boolean
}
export interface ListPurchasesOptions {
/** Inclusive UTC start (ISO date or datetime). */
startDate: string
/** Hash from the previous page's lastPurchaseHash, or null for page one. */
lastPurchaseHash: string | null
}
async function getJson(url: string, accessToken: string): Promise<Response> {
return fetchWithTimeout(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
{ timeoutMs: REQUEST_TIMEOUT_MS, description: 'Zettle Purchase API' },
)
}
/**
* One page of purchases on/after startDate, oldest first so the caller's
* cursor advances chronologically. Pagination uses lastPurchaseHash.
*/
export async function listPurchasesPage(
accessToken: string,
options: ListPurchasesOptions,
): Promise<PurchasesPage> {
const params = new URLSearchParams({
startDate: options.startDate,
limit: String(ZETTLE_PAGE_SIZE),
descending: 'false',
})
if (options.lastPurchaseHash) {
params.set('lastPurchaseHash', options.lastPurchaseHash)
}
const url = `${PURCHASE_BASE}/purchases/v2?${params.toString()}`
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
let response: Response
try {
response = await getJson(url, accessToken)
} catch (err) {
lastError = new ZettleApiError(
`Zettle request failed: ${err instanceof Error ? err.message : String(err)}`,
0,
)
if (attempt < RETRY_DELAYS_MS.length) {
await sleep(RETRY_DELAYS_MS[attempt])
continue
}
throw lastError
}
if (!response.ok) {
if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) {
lastError = new ZettleApiError(`Zettle API ${response.status}`, response.status)
await sleep(RETRY_DELAYS_MS[attempt])
continue
}
throw new ZettleApiError(`Zettle API ${response.status}`, response.status)
}
const body = (await response.json().catch(() => null)) as {
purchases?: ZettlePurchase[]
lastPurchaseHash?: string | null
} | null
const purchases = Array.isArray(body?.purchases) ? body!.purchases! : []
const lastPurchaseHash =
typeof body?.lastPurchaseHash === 'string' && body.lastPurchaseHash
? body.lastPurchaseHash
: null
return {
purchases,
lastPurchaseHash,
// Another page exists when this page was full and a hash was returned.
hasMore: purchases.length >= ZETTLE_PAGE_SIZE && lastPurchaseHash !== null,
}
}
throw lastError instanceof Error
? lastError
: new ZettleApiError('Zettle request failed', 0)
}
@@ -0,0 +1,58 @@
import crypto from 'crypto'
/**
* At-rest encryption for Zettle OAuth refresh tokens.
*
* AES-256-GCM with a dedicated env key, mirroring the Shopify/WooCommerce
* credential stores: 12-byte IV, 16-byte auth tag, layout iv|tag|ciphertext,
* base64url encoded. The key is deployment-wide; what makes rows useless
* off-server is that ZETTLE_CREDENTIALS_ENCRYPTION_KEY never leaves the
* environment.
*/
const ALGORITHM = 'aes-256-gcm'
/** Whether the integration is configured on this deployment. */
export function isZettleConfigured(): boolean {
return Boolean(
process.env.ZETTLE_CLIENT_ID &&
process.env.ZETTLE_CLIENT_SECRET &&
process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY,
)
}
function getEncryptionKey(): Buffer {
const key = process.env.ZETTLE_CREDENTIALS_ENCRYPTION_KEY
if (!key) throw new Error('ZETTLE_CREDENTIALS_ENCRYPTION_KEY is required')
return crypto.createHash('sha256').update(key).digest()
}
export function encryptCredential(plaintext: string): string {
const key = getEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
}
export function decryptCredential(ciphertext: string): string {
const key = getEncryptionKey()
const combined = Buffer.from(ciphertext, 'base64url')
const iv = combined.subarray(0, 12)
const tag = combined.subarray(12, 28)
const encrypted = combined.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}
/** Decrypted refresh token for an active connection. */
export function refreshTokenOf(connection: {
refresh_token_encrypted: string | null
}): string {
if (!connection.refresh_token_encrypted) {
throw new Error('Connection has no stored refresh token')
}
return decryptCredential(connection.refresh_token_encrypted)
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Zettle partner-hosted OAuth 2.0 (authorization code grant).
*
* Scopes: READ:PURCHASE (Purchase API) + READ:USERINFO (users/self for the
* organization UUID that becomes store_scope). Refresh tokens rotate: every
* refresh returns a new refresh token that MUST replace the previous one.
*/
import {
fetchWithTimeout,
OAUTH_TIMEOUT_MS,
OAUTH_REVOKE_TIMEOUT_MS,
} from '@/lib/http/fetch-with-timeout'
import { isZettleConfigured } from './credentials'
import type { ZettleTokenPair, ZettleUserSelf } from '../types'
const AUTH_ENDPOINT = 'https://oauth.zettle.com/authorize'
const TOKEN_ENDPOINT = 'https://oauth.zettle.com/token'
const USERS_SELF_ENDPOINT = 'https://oauth.zettle.com/users/self'
const DISCONNECT_ENDPOINT = 'https://oauth.zettle.com/application-connections/self'
/** Space-separated scopes requested at authorize time. */
export const ZETTLE_OAUTH_SCOPES = 'READ:PURCHASE READ:USERINFO'
export function getZettleClientCredentials(): { clientId: string; clientSecret: string } {
const clientId = process.env.ZETTLE_CLIENT_ID
const clientSecret = process.env.ZETTLE_CLIENT_SECRET
if (!clientId || !clientSecret) {
throw new Error('Zettle OAuth is not configured: set ZETTLE_CLIENT_ID and ZETTLE_CLIENT_SECRET')
}
return { clientId, clientSecret }
}
export function getZettleRedirectUri(): string {
const base = process.env.NEXT_PUBLIC_APP_URL
if (!base) {
throw new Error('NEXT_PUBLIC_APP_URL is required for Zettle OAuth')
}
return `${base.replace(/\/$/, '')}/api/extensions/zettle/callback`
}
export function buildAuthorizeUrl(state: string): string {
if (!isZettleConfigured()) {
throw new Error('Zettle is not configured')
}
const { clientId } = getZettleClientCredentials()
const params = new URLSearchParams({
response_type: 'code',
scope: ZETTLE_OAUTH_SCOPES,
client_id: clientId,
redirect_uri: getZettleRedirectUri(),
state,
})
return `${AUTH_ENDPOINT}?${params.toString()}`
}
async function postToken(body: URLSearchParams): Promise<ZettleTokenPair> {
const res = await fetchWithTimeout(
TOKEN_ENDPOINT,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: body.toString(),
// Node fetch can replay POST bodies across 307/308 redirects, including
// cross-origin. Refuse redirects so client_secret never leaves oauth.zettle.com.
redirect: 'error',
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Zettle token exchange' },
)
if (!res.ok) {
const errText = await res.text().catch(() => '')
throw new ZettleOAuthError(
`Zettle token exchange failed: ${res.status}${errText ? ` ${errText}` : ''}`,
res.status,
)
}
const json = (await res.json()) as Partial<ZettleTokenPair>
if (typeof json.access_token !== 'string' || !json.access_token) {
throw new ZettleOAuthError('Zettle token exchange returned no access token', 0)
}
if (typeof json.refresh_token !== 'string' || !json.refresh_token) {
throw new ZettleOAuthError('Zettle token exchange returned no refresh token', 0)
}
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
expires_in: typeof json.expires_in === 'number' ? json.expires_in : 7200,
}
}
export async function exchangeCodeForTokens(code: string): Promise<ZettleTokenPair> {
const { clientId, clientSecret } = getZettleClientCredentials()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: getZettleRedirectUri(),
})
return postToken(body)
}
/**
* Exchange a refresh token for a new access + refresh pair. Callers MUST
* persist the new refresh_token (Zettle rotates it on every refresh).
*/
export async function refreshAccessToken(refreshToken: string): Promise<ZettleTokenPair> {
const { clientId, clientSecret } = getZettleClientCredentials()
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
})
return postToken(body)
}
export async function fetchUserSelf(accessToken: string): Promise<ZettleUserSelf> {
const res = await fetchWithTimeout(
USERS_SELF_ENDPOINT,
{
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Zettle users/self' },
)
if (!res.ok) {
throw new ZettleOAuthError(`Zettle users/self failed: ${res.status}`, res.status)
}
const json = (await res.json()) as Partial<ZettleUserSelf>
if (typeof json.organizationUuid !== 'string' || !json.organizationUuid) {
throw new ZettleOAuthError('Zettle users/self returned no organizationUuid', 0)
}
return {
uuid: typeof json.uuid === 'string' ? json.uuid : '',
organizationUuid: json.organizationUuid,
}
}
/** Best-effort remote revoke; local revoke still proceeds if this fails. */
export async function disconnectApplication(accessToken: string): Promise<void> {
try {
await fetchWithTimeout(
DISCONNECT_ENDPOINT,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
},
{ timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Zettle disconnect' },
)
} catch {
// Remote revoke is best-effort.
}
}
export class ZettleOAuthError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
this.name = 'ZettleOAuthError'
}
}
export function isRevokedOAuthError(error: unknown): boolean {
if (!(error instanceof ZettleOAuthError)) return false
return error.status === 400 || error.status === 401 || error.status === 403
}
+629
View File
@@ -0,0 +1,629 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest'
import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types'
import { createLogger, type Logger } from '@/lib/logger'
import { roundOre as round } from '@/lib/money'
import type { WebshopOrderLineItem, WebshopVatBreakdownLine } from '@/types'
import { isRevokedCredentialsError, listPurchasesPage } from './api-client'
import { encryptCredential, refreshTokenOf } from './credentials'
import { isRevokedOAuthError, refreshAccessToken } from './oauth'
import type { ZettleConnection, ZettlePayment, ZettlePurchase } from '../types'
const defaultLog = createLogger('zettle/order-sync')
/**
* Zettle purchase sync: paid POS/online purchases and refunds as rich rows
* in public.webshop_orders (the Orders page). Feed-only doctrine: nothing
* here books anything.
*
* Qualification: positive-amount non-refund purchases with at least one
* non-invoice payment (or no payments array, treated as settled POS sale).
* Purchases whose only payment type is IZETTLE_INVOICE are skipped (unpaid
* invoice). Refunds (refund:true) land as separate negative rows.
*
* Underlag: per-rate VAT from groupedVatAmounts, line-item snapshot from
* products (+ serviceCharge). Customer fields stay null (POS rarely has
* orgnr/email). Currency amounts are minor units from the API.
*/
export const ZETTLE_IMPORT_SOURCE = 'zettle'
export const BACKFILL_DAYS = 90
const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000
const MAX_PURCHASES_PER_RUN = 10_000
const VAT_REMAINDER_TOLERANCE = 0.5
/**
* ⚠️ STORED-KEY FORMAT. Persisted to webshop_orders.external_id. Changing
* the template silently orphans every prior row. Locked by order-sync tests.
* Scope is organization_uuid, NOT connection id.
*/
export function zettleStoreScope(organizationUuid: string): string {
return organizationUuid
}
export function zettlePurchaseExternalId(storeScope: string, purchaseUuid: string): string {
return `zettle_${storeScope}_purchase_${purchaseUuid}`
}
export interface ZettleSyncSummary {
fetched: number
refundsFetched: number
inserted: number
updated: number
unchanged: number
frozenFlagged: number
crossMarked: number
errors: number
/** Sales imported unbookable (is_paid false) because the row model cannot express them yet. */
needsReview: number
/** Refunds of such sales, not imported at all. */
skippedUnsupported: number
deadlineReached?: boolean
revoked?: boolean
/** Another run holds the connection's sync claim; nothing was done. */
locked?: boolean
}
/** How long a sync claim lasts; longer than the cron's 300 s maxDuration. */
const SYNC_LOCK_MS = 6 * 60 * 1000
/** Minor units → major currency units. */
export function fromMinor(amount: number): number {
return round(amount / 100)
}
/** ISO date part from a Zettle timestamp (+0000 or Z). */
export function isoDateOf(timestamp: string): string {
const normalized = timestamp.replace(/([+-]\d{4})$/, (m) => `${m.slice(0, 3)}:${m.slice(3)}`)
const ms = Date.parse(normalized)
if (Number.isFinite(ms)) return new Date(ms).toISOString().split('T')[0]
return timestamp.split('T')[0]
}
export function purchaseTimestampIso(purchase: ZettlePurchase): string | null {
const raw = purchase.created || purchase.timestamp
if (!raw) return null
const normalized = raw.replace(/([+-]\d{4})$/, (m) => `${m.slice(0, 3)}:${m.slice(3)}`)
const ms = Date.parse(normalized)
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
}
const INVOICE_ONLY = 'IZETTLE_INVOICE'
/**
* Paid sale: not a refund, positive gross, and not invoice-only.
* Empty payments[] is treated as a settled POS sale (common for cash/card
* payloads that omit details in some historical rows).
*/
export function purchaseQualifiesAsPaidSale(purchase: ZettlePurchase): boolean {
if (purchase.refund === true) return false
if (!(purchase.amount > 0)) return false
const payments = purchase.payments ?? []
if (payments.length === 0) return true
return !payments.every((p) => p.type === INVOICE_ONLY)
}
export function purchaseQualifiesAsRefund(purchase: ZettlePurchase): boolean {
return purchase.refund === true && purchase.amount !== 0
}
const PAYMENT_TITLES: Record<string, string> = {
IZETTLE_CARD: 'Kort',
IZETTLE_CARD_ONLINE: 'Kort online',
IZETTLE_CASH: 'Kontant',
IZETTLE_INVOICE: 'Faktura',
SWISH: 'Swish',
VIPPS: 'Vipps',
MOBILE_PAY: 'MobilePay',
PAYPAL: 'PayPal',
GIFTCARD: 'Presentkort',
STORE_CREDIT: 'Tillgodohavande',
KLARNA: 'Klarna',
}
/**
* Purchases the v1 row model books wrong if treated as one paid sale to one
* payment account with revenue per VAT rate:
* - split_tender: several payment types; the whole gross would land on the
* first type's account (and a card + invoice split would count as paid).
* - voucher_tender: paid with gift card / store credit; Zettle never settles
* it, so 1686 would overstate the receivable.
* - giftcard_sale: a 0 %-rate voucher row is a liability (2421), not
* momsfri försäljning on 3004 / ruta 42.
* - gratuity: tips are never a momsfri sale; the amount semantics are
* unverified.
* Such sales are imported with is_paid = false (bookable only by hand);
* their refunds are not imported. Deterministic over guessing.
*/
export type ZettleUnsupportedReason =
| 'split_tender'
| 'voucher_tender'
| 'giftcard_sale'
| 'gratuity'
const VOUCHER_TENDERS = new Set(['GIFTCARD', 'STORE_CREDIT'])
export function unsupportedReason(purchase: ZettlePurchase): ZettleUnsupportedReason | null {
const payments = purchase.payments ?? []
const types = new Set(payments.map((p) => p.type).filter(Boolean))
if (types.size > 1) return 'split_tender'
if (payments.some((p) => VOUCHER_TENDERS.has(p.type))) return 'voucher_tender'
if ((purchase.products ?? []).some((p) => p.type === 'GIFTCARD')) return 'giftcard_sale'
if (payments.some((p) => typeof p.gratuityAmount === 'number' && p.gratuityAmount !== 0)) {
return 'gratuity'
}
return null
}
const NEEDS_REVIEW_TITLES: Record<ZettleUnsupportedReason, string> = {
split_tender: 'Delad betalning: bokför manuellt',
voucher_tender: 'Betalt med presentkort/tillgodohavande: bokför manuellt',
giftcard_sale: 'Presentkortsförsäljning: bokför manuellt',
gratuity: 'Dricks ingår: bokför manuellt',
}
export function paymentMethodOf(payments: ZettlePayment[] | undefined): {
method: string | null
title: string | null
} {
if (!payments || payments.length === 0) return { method: null, title: null }
const types = payments.map((p) => p.type).filter(Boolean)
if (types.length === 0) return { method: null, title: null }
return {
method: types[0],
title: types.map((t) => PAYMENT_TITLES[t] ?? t).join(', '),
}
}
/**
* Net per VAT rate summed from the product rows (Zettle's own öre rounding,
* rowTaxableAmount) plus the service charge. Null when any row lacks the
* fields, so the caller falls back to deriving net from the tax amount.
*/
function netByRateFromProducts(purchase: ZettlePurchase): Map<number, number> | null {
const netByRate = new Map<number, number>()
for (const product of purchase.products ?? []) {
const qty = Number.parseFloat(product.quantity)
if (!Number.isFinite(qty) || qty === 0) continue
if (typeof product.rowTaxableAmount !== 'number') return null
const rate = typeof product.vatPercentage === 'number' ? product.vatPercentage : 0
netByRate.set(rate, round((netByRate.get(rate) ?? 0) + fromMinor(product.rowTaxableAmount)))
}
const charge = purchase.serviceCharge
if (charge && typeof charge.amount === 'number') {
const rate = typeof charge.vatPercentage === 'number' ? charge.vatPercentage : 0
const gross = fromMinor(charge.amount)
const net = rate > 0 ? round(gross / (1 + rate / 100)) : gross
netByRate.set(rate, round((netByRate.get(rate) ?? 0) + net))
}
return netByRate.size > 0 ? netByRate : null
}
/**
* Per-rate VAT from groupedVatAmounts (tax in minor units). Net per rate is
* taken from the product rows when they carry it (matches what Zettle
* charged to the öre); otherwise derived as tax / rate. Remainder against
* gross becomes a 0% bucket.
*/
export function buildVatBreakdown(purchase: ZettlePurchase): WebshopVatBreakdownLine[] {
const total = fromMinor(Math.abs(purchase.amount))
if (total === 0) return []
const grouped = purchase.groupedVatAmounts
if (!grouped || typeof grouped !== 'object') return []
const netFromRows = netByRateFromProducts(purchase)
const buckets = new Map<number, { net: number; tax: number }>()
for (const [rateKey, taxMinor] of Object.entries(grouped)) {
if (typeof taxMinor !== 'number' || taxMinor === 0) continue
const rate = Number.parseFloat(rateKey)
if (!Number.isFinite(rate) || rate <= 0) return []
const tax = fromMinor(Math.abs(taxMinor))
const rowNet = netFromRows?.get(rate)
const net = rowNet !== undefined ? Math.abs(rowNet) : round(tax / (rate / 100))
const bucket = buckets.get(rate) ?? { net: 0, tax: 0 }
bucket.net = round(bucket.net + net)
bucket.tax = round(bucket.tax + tax)
buckets.set(rate, bucket)
}
const breakdown = Array.from(buckets.entries())
.map(([rate, { net, tax }]) => ({ rate, net, tax }))
.sort((a, b) => b.rate - a.rate)
const covered = round(breakdown.reduce((sum, b) => sum + b.net + b.tax, 0))
const remainder = round(total - covered)
if (remainder < -VAT_REMAINDER_TOLERANCE) return []
if (remainder > VAT_REMAINDER_TOLERANCE) {
breakdown.push({ rate: 0, net: remainder, tax: 0 })
}
return breakdown
}
/** Line snapshot: products + optional serviceCharge. Dropped if öre sum ≠ |total|. */
export function mapLineItems(purchase: ZettlePurchase): WebshopOrderLineItem[] {
const items: WebshopOrderLineItem[] = []
for (const product of purchase.products ?? []) {
const qty = Number.parseFloat(product.quantity)
if (!Number.isFinite(qty) || qty === 0) continue
const netMinor = product.rowTaxableAmount
if (typeof netMinor !== 'number') return []
// rowTaxableAmount is the row's net in minor units (signed with refunds).
const rowNet = fromMinor(netMinor)
const rate = typeof product.vatPercentage === 'number' ? product.vatPercentage : null
const tax =
rate !== null && rate > 0 ? round(rowNet * (rate / 100)) : 0
const nameParts = [product.name, product.variantName].filter(Boolean)
items.push({
name: nameParts.join(' / ') || product.type || 'Artikel',
quantity: qty,
total: rowNet,
total_tax: tax,
vat_rate: rate,
})
}
if (purchase.serviceCharge && typeof purchase.serviceCharge.amount === 'number') {
const gross = fromMinor(purchase.serviceCharge.amount)
const rate =
typeof purchase.serviceCharge.vatPercentage === 'number'
? purchase.serviceCharge.vatPercentage
: null
let net = gross
let tax = 0
if (rate !== null && rate > 0) {
net = round(gross / (1 + rate / 100))
tax = round(gross - net)
}
items.push({
name: purchase.serviceCharge.title || 'Serviceavgift',
quantity: Number.parseFloat(purchase.serviceCharge.quantity ?? '1') || 1,
total: net,
total_tax: tax,
vat_rate: rate,
})
}
// Each row's tax is re-derived from Zettle's rounded net, so net + tax can
// sit one öre off the row's charged gross (33.37 kr at 25%: net 26.70,
// tax 6.68, sum 33.38). Allow that per row; anything larger means the
// rows do not describe this purchase and the snapshot is dropped.
const total = fromMinor(Math.abs(purchase.amount))
const covered = round(
items.reduce((sum, i) => sum + Math.abs(i.total) + Math.abs(i.total_tax), 0),
)
const tolerance = 0.005 + 0.01 * items.length
if (Math.abs(covered - total) > tolerance) return []
return items
}
export function mapPurchaseToWebshopRows(
connection: Pick<ZettleConnection, 'id' | 'organization_name'>,
storeScope: string,
purchase: ZettlePurchase,
): WebshopOrderUpsert[] {
const uuid = purchase.purchaseUUID1
if (!uuid) return []
if (purchaseQualifiesAsPaidSale(purchase)) {
const total = fromMinor(purchase.amount)
if (total === 0) return []
const ts = purchaseTimestampIso(purchase)
const date = ts ? isoDateOf(ts) : isoDateOf(purchase.created || purchase.timestamp || '')
const payment = paymentMethodOf(purchase.payments)
const vat = buildVatBreakdown(purchase)
const totalTax =
typeof purchase.vatAmount === 'number'
? fromMinor(purchase.vatAmount)
: round(vat.reduce((s, b) => s + b.tax, 0))
const reason = unsupportedReason(purchase)
return [
{
platform: 'zettle',
store_scope: storeScope,
store_label: connection.organization_name,
connection_id: connection.id,
row_type: 'order',
parent_external_id: null,
external_id: zettlePurchaseExternalId(storeScope, uuid),
platform_order_id: uuid,
order_number: String(purchase.globalPurchaseNumber ?? purchase.purchaseNumber ?? uuid),
// is_paid = false keeps book-order / bulk-book from posting a row the
// model would book to the wrong accounts; the title says why.
status: reason ? 'needs_review' : purchase.refunded ? 'refunded' : 'paid',
is_paid: reason === null,
order_date: date,
paid_date: date,
currency: purchase.currency.toUpperCase(),
total,
total_tax: totalTax,
vat_breakdown: vat,
line_items: mapLineItems(purchase),
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: purchase.country ?? null,
payment_method: payment.method,
payment_method_title: reason ? NEEDS_REVIEW_TITLES[reason] : payment.title,
gateway_reference: purchase.payments?.[0]?.uuid ?? null,
refunded_total: 0,
},
]
}
if (purchaseQualifiesAsRefund(purchase)) {
// The booking guard only protects unpaid ORDER rows, so a refund of an
// unsupported sale is not imported at all rather than left bookable.
if (unsupportedReason(purchase) !== null) return []
const amount = fromMinor(purchase.amount) // already negative typically
const signedTotal = amount > 0 ? -amount : amount
if (signedTotal === 0) return []
const ts = purchaseTimestampIso(purchase)
const date = ts ? isoDateOf(ts) : isoDateOf(purchase.created || purchase.timestamp || '')
const payment = paymentMethodOf(purchase.payments)
const parentUuid = purchase.refundsPurchaseUUID1
const vat = buildVatBreakdown(purchase).map((b) => ({
rate: b.rate,
net: -Math.abs(b.net),
tax: -Math.abs(b.tax),
}))
const totalTax =
typeof purchase.vatAmount === 'number'
? -Math.abs(fromMinor(purchase.vatAmount))
: round(vat.reduce((s, b) => s + b.tax, 0))
return [
{
platform: 'zettle',
store_scope: storeScope,
store_label: connection.organization_name,
connection_id: connection.id,
row_type: 'refund',
parent_external_id: parentUuid
? zettlePurchaseExternalId(storeScope, parentUuid)
: null,
external_id: zettlePurchaseExternalId(storeScope, uuid),
platform_order_id: uuid,
order_number: String(purchase.globalPurchaseNumber ?? purchase.purchaseNumber ?? uuid),
status: 'refund',
is_paid: true,
order_date: date,
paid_date: date,
currency: purchase.currency.toUpperCase(),
total: signedTotal,
total_tax: totalTax,
vat_breakdown: vat,
line_items: mapLineItems(purchase),
customer_name: null,
customer_company: null,
customer_email: null,
customer_orgnr: null,
customer_country: purchase.country ?? null,
payment_method: payment.method,
payment_method_title: payment.title,
gateway_reference: purchase.payments?.[0]?.uuid ?? null,
refunded_total: 0,
},
]
}
return []
}
function resolveWindowStartIso(connection: ZettleConnection): string {
if (connection.last_order_synced_at) {
const cursorMs = Date.parse(connection.last_order_synced_at)
return new Date(Math.max(0, cursorMs - CURSOR_OVERLAP_MS)).toISOString()
}
return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString()
}
export async function syncZettlePurchases(
supabase: SupabaseClient,
connection: ZettleConnection,
log: Logger = defaultLog,
deadlineMs?: number,
): Promise<ZettleSyncSummary> {
const summary: ZettleSyncSummary = {
fetched: 0,
refundsFetched: 0,
inserted: 0,
updated: 0,
unchanged: 0,
frozenFlagged: 0,
crossMarked: 0,
errors: 0,
needsReview: 0,
skippedUnsupported: 0,
}
if (
connection.status !== 'active' ||
!connection.refresh_token_encrypted ||
!connection.organization_uuid
) {
return summary
}
// Claim the connection before touching the rotating refresh token. Two
// runs (the 03:30 cron and "Synka nu", or two tabs) refreshing at once
// would make the loser's token a reused one, which Zettle answers with
// 400 invalid_grant and this code would read as a revocation.
const nowIso = new Date().toISOString()
const { data: claimed, error: claimError } = await supabase
.from('zettle_connections')
.update({ sync_lock_until: new Date(Date.now() + SYNC_LOCK_MS).toISOString() })
.eq('id', connection.id)
.eq('status', 'active')
.lt('sync_lock_until', nowIso)
.select('id')
if (claimError) {
throw new Error(`Failed to claim Zettle connection for sync: ${claimError.message}`)
}
if (!claimed || claimed.length === 0) {
summary.locked = true
log.info('sync already running for this connection; skipped', {
connectionId: connection.id,
})
return summary
}
const storeScope = zettleStoreScope(connection.organization_uuid)
const runStartMs = Date.now()
const startDate = resolveWindowStartIso(connection)
let lastPurchaseHash: string | null = null
let prevCursorMs = connection.last_order_synced_at
? Date.parse(connection.last_order_synced_at)
: 0
let failureFloorMs = Number.POSITIVE_INFINITY
let windowExhausted = false
try {
const tokens = await refreshAccessToken(refreshTokenOf(connection))
// Rotate the refresh token immediately (Zettle invalidates the old one).
// The write must succeed before we continue: losing the new token leaves
// only a dead refresh token for the next run and forces a reconnect.
const encrypted = encryptCredential(tokens.refresh_token)
const { error: rotateError } = await supabase
.from('zettle_connections')
.update({ refresh_token_encrypted: encrypted, error_message: null })
.eq('id', connection.id)
if (rotateError) {
log.error('failed to persist rotated Zettle refresh token', rotateError, {
connectionId: connection.id,
message: rotateError.message,
code: rotateError.code,
})
throw new Error(
`Failed to persist rotated Zettle refresh token: ${rotateError.message}`,
)
}
connection.refresh_token_encrypted = encrypted
for (;;) {
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
summary.deadlineReached = true
log.info('time budget exhausted; stopping purchase sync', {
connectionId: connection.id,
processed: summary.inserted + summary.updated + summary.unchanged,
})
break
}
const page = await listPurchasesPage(tokens.access_token, {
startDate,
lastPurchaseHash,
})
if (page.purchases.length === 0) {
windowExhausted = true
break
}
summary.fetched += page.purchases.length
const rows: WebshopOrderUpsert[] = []
let pageMaxMs = 0
let pageMinMs = Number.POSITIVE_INFINITY
for (const purchase of page.purchases) {
const isRefund = purchaseQualifiesAsRefund(purchase)
if (isRefund) summary.refundsFetched += 1
if (unsupportedReason(purchase) !== null) {
if (isRefund) summary.skippedUnsupported += 1
else if (purchaseQualifiesAsPaidSale(purchase)) summary.needsReview += 1
}
const ts = purchaseTimestampIso(purchase)
if (ts) {
const ms = Date.parse(ts)
if (ms > pageMaxMs) pageMaxMs = ms
if (ms < pageMinMs) pageMinMs = ms
}
rows.push(...mapPurchaseToWebshopRows(connection, storeScope, purchase))
}
if (rows.length > 0) {
const result = await upsertWebshopOrders(
supabase,
connection.company_id,
connection.user_id,
rows,
)
summary.inserted += result.inserted
summary.updated += result.updated
summary.unchanged += result.unchanged
summary.frozenFlagged += result.frozenFlagged
summary.crossMarked += result.crossMarked
summary.errors += result.errors
if (result.errors > 0 && Number.isFinite(pageMinMs)) {
failureFloorMs = Math.min(failureFloorMs, pageMinMs - 1000)
}
}
if (pageMaxMs > 0) {
const candidateMs = Math.min(pageMaxMs, failureFloorMs)
if (candidateMs > prevCursorMs) {
const cursorIso = new Date(candidateMs).toISOString()
await supabase
.from('zettle_connections')
.update({ last_order_synced_at: cursorIso, error_message: null })
.eq('id', connection.id)
connection.last_order_synced_at = cursorIso
prevCursorMs = candidateMs
}
}
if (!page.hasMore) {
windowExhausted = true
break
}
lastPurchaseHash = page.lastPurchaseHash
if (summary.fetched >= MAX_PURCHASES_PER_RUN) {
log.warn('purchase cap reached; remaining purchases resume next run', {
connectionId: connection.id,
cap: MAX_PURCHASES_PER_RUN,
})
break
}
}
if (windowExhausted) {
const watermarkMs = Math.min(runStartMs, failureFloorMs)
if (watermarkMs > prevCursorMs) {
const cursorIso = new Date(watermarkMs).toISOString()
await supabase
.from('zettle_connections')
.update({ last_order_synced_at: cursorIso, error_message: null })
.eq('id', connection.id)
connection.last_order_synced_at = cursorIso
}
}
} catch (err) {
if (isRevokedCredentialsError(err) || isRevokedOAuthError(err)) {
summary.revoked = true
await supabase
.from('zettle_connections')
.update({
status: 'revoked',
error_message: 'Zettle avvisade anslutningen. Anslut kontot igen.',
refresh_token_encrypted: null,
oauth_state: null,
disconnected_at: new Date().toISOString(),
})
.eq('id', connection.id)
.eq('status', 'active')
log.warn('credentials revoked upstream; connection flipped to revoked', {
connectionId: connection.id,
})
return summary
}
throw err
} finally {
await supabase
.from('zettle_connections')
.update({ sync_lock_until: new Date(0).toISOString() })
.eq('id', connection.id)
}
log.info('zettle purchase sync done', {
connectionId: connection.id,
...summary,
})
return summary
}
@@ -0,0 +1,28 @@
import { resolveBrandByHost } from '@/lib/branding/resolve'
/**
* Re-validate the origin stored on a pending connection before redirecting
* to it. The connect route stores a validated value, but company members can
* UPDATE zettle_connections through RLS, so the column is not an authorization
* boundary: an edited row must never turn the OAuth callback into an open
* redirect. Accepted: the canonical app origin, or an https origin whose host
* resolves to a brand in the brands table. Anything else falls back to the
* app origin.
*/
export async function validateReturnOrigin(
stored: string | null | undefined,
appOrigin: string,
): Promise<string> {
if (!stored) return appOrigin
let url: URL
try {
url = new URL(stored)
} catch {
return appOrigin
}
if (url.origin === new URL(appOrigin).origin) return appOrigin
if (url.protocol !== 'https:' || url.port) return appOrigin
const brand = await resolveBrandByHost(url.hostname)
if (!brand || brand.domain.toLowerCase() !== url.hostname.toLowerCase()) return appOrigin
return `https://${url.hostname}`
}
@@ -0,0 +1,72 @@
/**
* The Zettle settings panel's server calls, each classified into exactly one
* outcome. Same doctrine as the Shopify/Stripe panels' settings-actions.
*/
import {
panelRequest,
type PanelRequestOptions,
type PanelRequestResult,
} from '@/lib/browser/panel-request'
export const ZETTLE_ACTION_TIMEOUT_MS = 15_000
export const ZETTLE_CONNECT_TIMEOUT_MS = 30_000
export const ZETTLE_SYNC_TIMEOUT_MS = 310_000
export type ZettleRequestResult<T> = PanelRequestResult<T>
export type ZettleRequestOptions = PanelRequestOptions
export { serverErrorMessage } from '@/lib/browser/panel-request'
export function zettleRequest<T>(options: ZettleRequestOptions): Promise<ZettleRequestResult<T>> {
return panelRequest<T>({ timeoutMs: ZETTLE_ACTION_TIMEOUT_MS, ...options })
}
export interface ZettleSyncPayload {
success?: boolean
transactions?: {
fetched?: number
refundsFetched?: number
inserted?: number
updated?: number
unchanged?: number
errors?: number
revoked?: boolean
deadlineReached?: boolean
needsReview?: number
} | null
}
type SyncCounts = {
fetched: number
imported: number
/** Sales imported unbookable (split tender, gift card, tip); see order-sync. */
needsReview: number
}
export type ZettleSyncOutcome =
| { reason: 'revoked' }
| { reason: 'empty' }
| { reason: 'partial'; values: SyncCounts & { errors: number } }
| { reason: 'errors'; values: SyncCounts & { errors: number } }
| { reason: 'feed'; values: SyncCounts }
| { reason: 'unknown' }
export function syncSummary(payload: ZettleSyncPayload | null): ZettleSyncOutcome {
const summary = payload?.transactions
if (!summary) return { reason: 'unknown' }
if (summary.revoked === true) return { reason: 'revoked' }
if (typeof summary.fetched !== 'number') return { reason: 'unknown' }
const fetched = summary.fetched
const imported = typeof summary.inserted === 'number' ? summary.inserted : 0
const errors = typeof summary.errors === 'number' ? summary.errors : 0
const needsReview = typeof summary.needsReview === 'number' ? summary.needsReview : 0
if (summary.deadlineReached === true) {
return { reason: 'partial', values: { fetched, imported, needsReview, errors } }
}
if (fetched === 0) return { reason: 'empty' }
if (errors > 0) return { reason: 'errors', values: { fetched, imported, needsReview, errors } }
return { reason: 'feed', values: { fetched, imported, needsReview } }
}
+23
View File
@@ -0,0 +1,23 @@
{
"id": "zettle",
"sector": "general",
"exportName": "zettleExtension",
"entryPoint": "@/extensions/general/zettle",
"workspace": null,
"requiredEnvVars": [
"ZETTLE_CLIENT_ID",
"ZETTLE_CLIENT_SECRET",
"ZETTLE_CREDENTIALS_ENCRYPTION_KEY"
],
"optionalEnvVars": [],
"npmDependencies": [],
"definition": {
"name": "Zettle",
"category": "import",
"icon": "CreditCard",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Hämta betalda köp och återbetalningar från Zettle till Ordersidan",
"longDescription": "Anslut ditt Zettle-konto så hämtas betalda köp och återbetalningar automatiskt varje natt till Ordersidan, med belopp, betalsätt, moms per sats och radunderlag. Inget bokförs automatiskt: du bokför varje köp själv från Ordersidan."
}
}
+116
View File
@@ -0,0 +1,116 @@
/** Row shape of public.zettle_connections. */
export interface ZettleConnection {
id: string
company_id: string
user_id: string
/**
* Merchant organization UUID from GET users/self. Frozen into store_scope /
* external_id so disconnect/reconnect of the same merchant stays deduped.
*/
organization_uuid: string | null
organization_name: string | null
/** AES-256-GCM encrypted OAuth refresh token. */
refresh_token_encrypted: string | null
oauth_state: string | null
/** Validated app or brand origin the connect flow started on; the callback returns there. */
return_origin: string | null
/** Sync claim held by a running cron/manual sync; a past timestamp (default epoch) = free. */
sync_lock_until: string
status: 'pending' | 'active' | 'revoked' | 'error'
currency: string | null
/** Opt-in: nightly purchase-feed cron (the manual sync button ignores it). */
transaction_sync_enabled: boolean
/** Purchase-polling cursor (max purchase timestamp processed). */
last_order_synced_at: string | null
error_message: string | null
connected_at: string | null
disconnected_at: string | null
created_at: string
updated_at: string
}
/** Status payload returned by GET /api/extensions/ext/zettle/status. */
export interface ZettleStatusResponse {
configured: boolean
connection: Pick<
ZettleConnection,
| 'id'
| 'status'
| 'organization_uuid'
| 'organization_name'
| 'currency'
| 'error_message'
| 'connected_at'
| 'transaction_sync_enabled'
| 'last_order_synced_at'
> | null
}
/** One product line on a Zettle purchase. */
export interface ZettleProduct {
quantity: string
type?: string
name?: string | null
variantName?: string | null
vatPercentage?: number | null
/** Net (excl. VAT) in minor currency units. */
rowTaxableAmount?: number | null
unitPrice?: number | null
unitName?: string | null
comment?: string | null
}
/** One payment on a Zettle purchase. */
export interface ZettlePayment {
uuid?: string
type: string
amount?: number
gratuityAmount?: number
}
/** Optional purchase-level service charge (e.g. shipping). */
export interface ZettleServiceCharge {
amount: number
title?: string | null
vatPercentage?: number | null
quantity?: string | null
}
/**
* Minimal Purchase API v2 shape consumed by the feed. Timestamps are ISO 8601
* (often with +0000 offset rather than Z).
*/
export interface ZettlePurchase {
purchaseUUID1: string
purchaseNumber?: number
globalPurchaseNumber?: number
/** Gross amount (incl. VAT) in minor units; negative on refunds. */
amount: number
/** VAT amount in minor units. */
vatAmount?: number
currency: string
country?: string
created?: string
timestamp?: string
refund?: boolean
refunded?: boolean
refundsPurchaseUUID1?: string | null
products?: ZettleProduct[]
payments?: ZettlePayment[]
/** Map of VAT rate percent string → tax amount in minor units. */
groupedVatAmounts?: Record<string, number> | null
serviceCharge?: ZettleServiceCharge | null
customAmountSale?: boolean
source?: string
}
export interface ZettleUserSelf {
uuid: string
organizationUuid: string
}
export interface ZettleTokenPair {
access_token: string
refresh_token: string
expires_in: number
}
+1 -1
View File
@@ -1779,7 +1779,7 @@ export const BookTransactionSchema = z
// ── Webshop orders (Orders page) ──────────────────────────────
export const WebshopPlatformSchema = z.enum(['woocommerce', 'shopify'])
export const WebshopPlatformSchema = z.enum(['woocommerce', 'shopify', 'zettle'])
export const WebshopOrdersListQuerySchema = z.object({
platform: WebshopPlatformSchema.optional(),
@@ -70,6 +70,7 @@ describe('getDashboardNavFlags', () => {
'shopify_connections',
'webshop_orders',
'woocommerce_connections',
'zettle_connections',
])
})
+6 -2
View File
@@ -70,15 +70,19 @@ export async function getDashboardNavFlagsViaProbes(
supabase: SupabaseClient,
companyId: string,
): Promise<Omit<DashboardNavFlags, 'hasExpenseClaims'>> {
const [woo, shopify, orders, trips] = await Promise.all([
const [woo, shopify, zettle, orders, trips] = await Promise.all([
supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
supabase.from('shopify_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
supabase.from('zettle_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1),
supabase.from('webshop_orders').select('id').eq('company_id', companyId).limit(1),
supabase.from('mileage_trips').select('id').eq('company_id', companyId).limit(1),
])
return {
hasWebshop:
(woo.data?.length ?? 0) > 0 || (shopify.data?.length ?? 0) > 0 || (orders.data?.length ?? 0) > 0,
(woo.data?.length ?? 0) > 0 ||
(shopify.data?.length ?? 0) > 0 ||
(zettle.data?.length ?? 0) > 0 ||
(orders.data?.length ?? 0) > 0,
hasMileageTrips: (trips.data?.length ?? 0) > 0,
}
}
+3
View File
@@ -34,6 +34,8 @@ export const CAPABILITY = {
woocommerce_sync: 'woocommerce_sync',
/** Shopify store sync: orders/refunds imported as a transaction feed. */
shopify_sync: 'shopify_sync',
/** Zettle purchase sync: paid purchases/refunds imported as a webshop_orders feed. */
zettle_sync: 'zettle_sync',
/**
* Multiple people working in one company. Without it only the OWNER can
* enter the company: every other membership goes dormant (never deleted)
@@ -80,6 +82,7 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
CAPABILITY.stripe_payments,
CAPABILITY.woocommerce_sync,
CAPABILITY.shopify_sync,
CAPABILITY.zettle_sync,
// Founder decision (2026-09-01): multiple users per company is paid.
// Trial-seeded and Stripe-synced like the rest; enforcement is the
// owner-only dormancy rule in lib/entitlements/multi-user.ts.
+3
View File
@@ -145,6 +145,9 @@ export type CoreEvent =
// Shopify store lifecycle: same audit doctrine as stripe.*/woocommerce.*.
| { type: 'shopify.connected'; payload: { connectionId: string; shopDomain: string; userId: string; companyId: string } }
| { type: 'shopify.disconnected'; payload: { connectionId: string; shopDomain: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
// Zettle organization lifecycle: same audit doctrine as shopify.*.
| { type: 'zettle.connected'; payload: { connectionId: string; organizationUuid: string; userId: string; companyId: string } }
| { type: 'zettle.disconnected'; payload: { connectionId: string; organizationUuid: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
// Periods
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
+3 -3
View File
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
expect(SECTORS.length).toBe(1)
})
it('should have 18 total extensions', () => {
expect(getAllExtensions().length).toBe(18)
it('should have 19 total extensions', () => {
expect(getAllExtensions().length).toBe(19)
})
it('should have unique slugs within each sector', () => {
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('general')
expect(extensions.length).toBe(18)
expect(extensions.length).toBe(19)
})
it('all extensions have required fields', () => {
+1
View File
@@ -15,5 +15,6 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'whatsapp-inbox',
'woocommerce',
'shopify',
'zettle',
'mail',
])
+2
View File
@@ -14,6 +14,7 @@ import { stripeExtension } from '@/extensions/general/stripe'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
import { woocommerceExtension } from '@/extensions/general/woocommerce'
import { shopifyExtension } from '@/extensions/general/shopify'
import { zettleExtension } from '@/extensions/general/zettle'
import { mailExtension } from '@/extensions/general/mail'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
@@ -31,5 +32,6 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
whatsappInboxExtension,
woocommerceExtension,
shopifyExtension,
zettleExtension,
mailExtension,
]
+11
View File
@@ -184,6 +184,17 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"longDescription": "Anslut din Shopify-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till Ordersidan, med belopp, betalsätt och moms per sats. Inget bokförs automatiskt: du bokför varje order själv från Ordersidan.",
"hasOwnData": true
},
{
"slug": "zettle",
"name": "Zettle",
"sector": "general",
"category": "import",
"icon": "CreditCard",
"dataPattern": "manual",
"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.",
"hasOwnData": true
},
{
"slug": "mail",
"name": "Brevlådor",
@@ -25,6 +25,9 @@ const SETTINGS_PANELS: Record<string, ComponentType> = {
shopify: dynamic(
() => import('@/extensions/general/shopify/components/ShopifySettingsPanel')
),
zettle: dynamic(
() => import('@/extensions/general/zettle/components/ZettleSettingsPanel')
),
}
/**
+1
View File
@@ -1252,6 +1252,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
webhooks: 'automation config with signing secrets',
woocommerce_connections: 'WooCommerce connection state (encrypted API secrets)',
shopify_connections: 'Shopify connection state (encrypted API secrets)',
zettle_connections: 'Zettle connection state (encrypted OAuth refresh token)',
}
/** Max parent ids per `IN (...)` chunk: keeps the PostgREST URL well under limits. */
@@ -129,7 +129,7 @@ describe('applySubscriptionState', () => {
const grantUpsert = calls.find((c) => c.table === 'capability_grants')
expect(grantUpsert?.op).toBe('upsert')
const rows = grantUpsert?.payload as Array<{ capability_key: string; source: string }>
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'multi_user', 'shopify_sync', 'skatteverket', 'stripe_payments', 'woocommerce_sync'])
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'multi_user', 'shopify_sync', 'skatteverket', 'stripe_payments', 'woocommerce_sync', 'zettle_sync'])
expect(rows.every((r) => r.source === 'stripe')).toBe(true)
})
+1
View File
@@ -58,6 +58,7 @@ export interface OrderUnderlagModel {
const PLATFORM_LABELS: Record<string, string> = {
woocommerce: 'WooCommerce',
shopify: 'Shopify',
zettle: 'Zettle',
}
function vatRateLabel(rate: number | null): string {
+48
View File
@@ -536,6 +536,50 @@
"transaction_sync_disabled_toast": "Order sync disabled.",
"transaction_sync_toggle_failed": "Could not save the setting. Please try again."
},
"zettle": {
"title": "Zettle",
"description": "Connect your Zettle account to fetch paid purchases and refunds to the Orders page every night, with amounts, payment method, per-rate VAT and line underlag. You book them from there.",
"not_configured": "The Zettle integration is not configured on this installation. Contact an administrator.",
"load_failed": "Could not read Zettle status. Check your connection and try again.",
"action_timeout": "The action took too long. Reload the page to see if it went through.",
"action_network": "No contact with the server. Check your connection and try again.",
"connect": "Connect Zettle",
"connecting": "Connecting…",
"connect_hint": "You will be sent to Zettle to approve read access to purchases. You can disconnect here at any time.",
"disconnect": "Disconnect",
"disconnect_confirm": "Yes, disconnect",
"cancel": "Cancel",
"status_active": "Connected",
"status_pending": "Pending",
"status_revoked": "Disconnected",
"status_error": "Error",
"connected_since": "Connected {date}",
"unnamed_store": "Zettle account",
"connected_toast_title": "Zettle connected",
"connected_toast_description": "Paid purchases and refunds are now fetched every night.",
"disconnected_toast_title": "Zettle disconnected",
"disconnected_toast_description": "The Zettle connection has been removed.",
"connect_failed_title": "Connection failed",
"disconnect_failed_title": "Disconnect failed",
"sync_now": "Sync now",
"syncing": "Syncing…",
"sync_done_title": "Sync complete",
"sync_done_feed": "{fetched} purchase(s) fetched: {imported} new on the Orders page.{needsReview, plural, =0 {} other { # row(s) need manual booking (split payment, gift card or tip).}}",
"sync_done_empty": "Zettle returned no purchases for the period. Check that the right account is connected if you expected purchases.",
"sync_done_feed_errors": "{fetched} purchase(s) fetched: {imported} new on the Orders page.{needsReview, plural, =0 {} other { # row(s) need manual booking.}} {errors} row(s) could not be imported: sync again.",
"sync_partial_title": "Sync paused",
"sync_partial": "{fetched} purchase(s) fetched so far: {imported} new on the Orders page.{needsReview, plural, =0 {} other { # row(s) need manual booking.}}{errors, plural, =0 {} other { # row(s) could not be imported.}} Not all purchases were fetched: sync again to continue where it stopped.",
"sync_failed_title": "Sync failed",
"sync_revoked": "Zettle rejected the connection, so no purchases could be fetched. Connect the account again.",
"transaction_sync_title": "Purchases from Zettle",
"transaction_sync_description": "Fetch paid purchases and refunds to the Orders page every night. You book them from there.",
"transaction_sync_backfill_note": "The first sync fetches up to 90 days of history.",
"transaction_sync_last_synced": "Last synced {date}",
"transaction_sync_never_synced": "Not synced yet",
"transaction_sync_enabled_toast": "Purchase sync enabled. History is fetched on the next sync.",
"transaction_sync_disabled_toast": "Purchase sync disabled.",
"transaction_sync_toggle_failed": "Could not save the setting. Try again."
},
"settings_modal": {
"title": "Settings",
"description": "Manage your company and account"
@@ -7806,6 +7850,10 @@
"shopify_description": "Connect your Shopify store to fetch paid orders and refunds to the Orders page.",
"shopify_not_enabled_title": "The Shopify extension is not enabled",
"shopify_not_enabled_description": "Enable the Shopify extension to connect your store and fetch orders automatically.",
"zettle_title": "Zettle",
"zettle_description": "Connect your Zettle account to fetch paid purchases and refunds to the Orders page.",
"zettle_not_enabled_title": "The Zettle extension is not enabled",
"zettle_not_enabled_description": "Enable the Zettle extension to connect your account and fetch purchases automatically.",
"migration_title": "Import from another system",
"migration_description": "Nothing changes in your existing system.",
"bankfile_title": "Bank file",
+48
View File
@@ -536,6 +536,50 @@
"transaction_sync_disabled_toast": "Ordersynk avaktiverad.",
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
},
"zettle": {
"title": "Zettle",
"description": "Koppla ditt Zettle-konto så hämtas betalda köp och återbetalningar till Ordersidan varje natt, med belopp, betalsätt, moms per sats och radunderlag. Du bokför köpen därifrån.",
"not_configured": "Zettle-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.",
"load_failed": "Kunde inte läsa Zettle-statusen. Kontrollera din uppkoppling och försök igen.",
"action_timeout": "Åtgärden tog för lång tid. Ladda om sidan för att se om den gick igenom.",
"action_network": "Ingen kontakt med servern. Kontrollera din uppkoppling och försök igen.",
"connect": "Anslut Zettle",
"connecting": "Ansluter…",
"connect_hint": "Du skickas till Zettle för att godkänna läsbehörighet till köp. Anslutningen kan när som helst kopplas från här.",
"disconnect": "Koppla från",
"disconnect_confirm": "Ja, koppla från",
"cancel": "Avbryt",
"status_active": "Ansluten",
"status_pending": "Väntar",
"status_revoked": "Frånkopplad",
"status_error": "Fel",
"connected_since": "Ansluten {date}",
"unnamed_store": "Zettle-konto",
"connected_toast_title": "Zettle anslutet",
"connected_toast_description": "Betalda köp och återbetalningar hämtas nu varje natt.",
"disconnected_toast_title": "Zettle frånkopplat",
"disconnected_toast_description": "Anslutningen till Zettle är borttagen.",
"connect_failed_title": "Anslutningen misslyckades",
"disconnect_failed_title": "Frånkopplingen misslyckades",
"sync_now": "Synka nu",
"syncing": "Synkar…",
"sync_done_title": "Synkronisering klar",
"sync_done_feed": "{fetched} köp hämtade: {imported} nya på Ordersidan.{needsReview, plural, =0 {} other { # rad(er) kräver manuell bokföring (delad betalning, presentkort eller dricks).}}",
"sync_done_empty": "Zettle returnerade inga köp för perioden. Kontrollera att rätt konto är anslutet om du väntade dig köp.",
"sync_done_feed_errors": "{fetched} köp hämtade: {imported} nya på Ordersidan.{needsReview, plural, =0 {} other { # rad(er) kräver manuell bokföring.}} {errors} rad(er) kunde inte importeras: synka igen.",
"sync_partial_title": "Synkroniseringen pausades",
"sync_partial": "{fetched} köp hämtade hittills: {imported} nya på Ordersidan.{needsReview, plural, =0 {} other { # rad(er) kräver manuell bokföring.}}{errors, plural, =0 {} other { # rad(er) kunde inte importeras.}} Alla köp hann inte hämtas: synka igen för att fortsätta där det stannade.",
"sync_failed_title": "Synkroniseringen misslyckades",
"sync_revoked": "Zettle avvisade anslutningen, så inga köp kunde hämtas. Anslut kontot igen.",
"transaction_sync_title": "Köp från Zettle",
"transaction_sync_description": "Hämta betalda köp och återbetalningar till Ordersidan varje natt. Du bokför köpen därifrån.",
"transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik.",
"transaction_sync_last_synced": "Senast synkad {date}",
"transaction_sync_never_synced": "Inte synkad ännu",
"transaction_sync_enabled_toast": "Köpsynk aktiverad. Historiken hämtas vid nästa synkning.",
"transaction_sync_disabled_toast": "Köpsynk avaktiverad.",
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen."
},
"settings_modal": {
"title": "Inställningar",
"description": "Hantera ditt företag och konto"
@@ -7806,6 +7850,10 @@
"shopify_description": "Koppla din Shopify-butik så hämtas betalda ordrar och återbetalningar till Ordersidan.",
"shopify_not_enabled_title": "Shopify-tillägget är inte aktiverat",
"shopify_not_enabled_description": "Aktivera tillägget Shopify för att koppla din butik och hämta ordrar automatiskt.",
"zettle_title": "Zettle",
"zettle_description": "Koppla ditt Zettle-konto så hämtas betalda köp och återbetalningar till Ordersidan.",
"zettle_not_enabled_title": "Zettle-tillägget är inte aktiverat",
"zettle_not_enabled_description": "Aktivera tillägget Zettle för att koppla ditt konto och hämta köp automatiskt.",
"migration_title": "Hämta från annat system",
"migration_description": "Inget ändras i ditt befintliga system.",
"bankfile_title": "Bankfil",
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 32" role="img" aria-label="Zettle">
<rect width="120" height="32" rx="4" fill="#000"/>
<text x="60" y="21" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="14" font-weight="700" fill="#fff">Zettle</text>
</svg>

After

Width:  |  Height:  |  Size: 298 B

@@ -0,0 +1,80 @@
-- Zettle merchant connections: per-company OAuth refresh tokens for the
-- paid-purchase feed (extensions/general/zettle).
--
-- Partner-hosted authorization code grant: the deployment holds
-- ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET; each merchant authorises READ:PURCHASE
-- + READ:USERINFO. Only the rotating refresh token is stored, AES-256-GCM
-- encrypted with ZETTLE_CREDENTIALS_ENCRYPTION_KEY (same layout as Shopify /
-- WooCommerce credential stores). Access tokens are ephemeral (~2h) and
-- never persisted. organization_uuid from users/self is the store identity
-- frozen into webshop_orders.external_id.
--
-- Modeled on shopify_connections: same status lifecycle, same member-scoped
-- RLS, no DELETE policy (connections are revoked, never deleted). No
-- write_audit_log trigger: connection state carrying encrypted credentials
-- must not flood audit_log.
create table public.zettle_connections (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references public.companies(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
-- Merchant organization UUID; null while pending OAuth.
organization_uuid text,
organization_name text,
-- AES-256-GCM encrypted OAuth refresh token.
refresh_token_encrypted text,
oauth_state text,
-- Validated origin the connect flow started on (app origin or a brand
-- domain from the brands table); the callback returns the browser there.
return_origin text,
-- Sync claim: set by the run that holds the connection (cron or manual
-- sync), so two runs never refresh the rotating token concurrently (a
-- reused refresh token comes back 400 and would flip the row to revoked).
sync_lock_until timestamptz not null default 'epoch',
status text not null default 'pending'
check (status in ('pending', 'active', 'revoked', 'error')),
currency text,
transaction_sync_enabled boolean not null default false,
last_order_synced_at timestamptz,
error_message text,
connected_at timestamptz,
disconnected_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index zettle_connections_one_active_per_company
on public.zettle_connections (company_id) where (status = 'active');
create unique index zettle_connections_org_active_uniq
on public.zettle_connections (organization_uuid) where (status = 'active');
create index idx_zettle_connections_company_id
on public.zettle_connections (company_id);
alter table public.zettle_connections enable row level security;
create policy "members read zettle_connections"
on public.zettle_connections for select
using (company_id in (select public.user_company_ids()));
create policy "members insert zettle_connections"
on public.zettle_connections for insert
with check (
company_id in (select public.user_company_ids())
and user_id = auth.uid()
);
create policy "members update zettle_connections"
on public.zettle_connections for update
using (company_id in (select public.user_company_ids()))
with check (company_id in (select public.user_company_ids()));
create trigger set_updated_at_zettle_connections
before update on public.zettle_connections
for each row execute function public.update_updated_at_column();
comment on table public.zettle_connections is
'Zettle merchant connections per company. Refresh token stored AES-256-GCM encrypted; decryption requires ZETTLE_CREDENTIALS_ENCRYPTION_KEY.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,22 @@
-- Backfill capability_grants for the new 'zettle_sync' capability.
--
-- zettle_sync joins PAID_CAPABILITIES; existing companies' grants were written
-- before this key existed. Mirror each existing bank_sync grant (same sibling
-- used by stripe_payments / woocommerce_sync / shopify_sync backfills).
insert into public.capability_grants
(company_id, team_id, capability_key, source, granted_at, expires_at, metadata)
select
g.company_id,
g.team_id,
'zettle_sync',
g.source,
g.granted_at,
g.expires_at,
jsonb_build_object(
'backfilled_from', 'bank_sync',
'backfill_migration', '20260909100100'
)
from public.capability_grants g
where g.capability_key = 'bank_sync'
on conflict (company_id, team_id, capability_key, source) do nothing;
@@ -0,0 +1,40 @@
-- Include active zettle_connections in get_dashboard_nav_flags.has_webshop
-- so the Orders nav row appears after a Zettle connect (parity with
-- WooCommerce / Shopify probes in 20260826120000).
CREATE OR REPLACE FUNCTION public.get_dashboard_nav_flags(p_company_id uuid)
RETURNS TABLE(has_webshop boolean, has_mileage_trips boolean)
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
SELECT
(
EXISTS (
SELECT 1 FROM public.woocommerce_connections w
WHERE w.company_id = p_company_id AND w.status = 'active'
)
OR EXISTS (
SELECT 1 FROM public.shopify_connections s
WHERE s.company_id = p_company_id AND s.status = 'active'
)
OR EXISTS (
SELECT 1 FROM public.zettle_connections z
WHERE z.company_id = p_company_id AND z.status = 'active'
)
OR EXISTS (
SELECT 1 FROM public.webshop_orders o
WHERE o.company_id = p_company_id
)
) AS has_webshop,
EXISTS (
SELECT 1 FROM public.mileage_trips m
WHERE m.company_id = p_company_id
) AS has_mileage_trips;
$$;
COMMENT ON FUNCTION public.get_dashboard_nav_flags(uuid) IS
'Dashboard nav visibility flags (webshop, mileage) for one company in one round trip. SECURITY INVOKER: RLS applies.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,39 @@
-- REPLACE seed_trial_capability_grants to add zettle_sync while keeping the
-- byrå suppression from 20260826130300 / 20260901081417.
CREATE OR REPLACE FUNCTION public.seed_trial_capability_grants()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Byrå-team companies are covered by the team's agreement (WL-10):
-- no company-scoped trial, so no trial-expiry noise toward byrå clients.
IF NEW.team_id IS NOT NULL AND EXISTS (
SELECT 1 FROM public.teams t
WHERE t.id = NEW.team_id
AND t.kind = 'byra'
) THEN
RETURN NEW;
END IF;
-- Full PAID set as of 20260909100300; keep this VALUES list in step with
-- lib/entitlements/keys.ts PAID_CAPABILITIES whenever a key is added.
INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
SELECT NEW.id, k.key, 'trial', NEW.created_at + interval '30 days'
FROM (VALUES
('ai'),
('bank_sync'),
('skatteverket'),
('email_send'),
('stripe_payments'),
('woocommerce_sync'),
('shopify_sync'),
('zettle_sync'),
('multi_user')
) AS k(key)
ON CONFLICT (company_id, team_id, capability_key, source) DO NOTHING;
RETURN NEW;
END;
$$;
@@ -0,0 +1,162 @@
-- Zettle parity for every site that enumerates the webshop platforms or the
-- integration connection tables. The zettle extension (20260909100000) adds
-- zettle_connections and writes platform = 'zettle' into webshop_orders, but:
--
-- 1. webshop_orders.platform and webshop_store_settings.platform still
-- CHECK (platform in ('woocommerce', 'shopify')), so every Zettle upsert
-- would have been refused at the database (the unit tests mock Supabase
-- and never saw it).
-- 2. The writer-role gate trigger (20260902093000) was attached to the
-- shopify/woocommerce connection tables by name; zettle_connections had
-- no aa_enforce_company_writer_role, so a viewer could connect a POS.
-- 3. The migration-reset snapshot counts pending/active integrations as a
-- blocker and the reset locks those tables FOR UPDATE (20260818084050);
-- both enumerate stripe/woocommerce/shopify and missed zettle.
--
-- 3 follows the wrapper pattern of 20260826150000 (rename, wrap, revoke)
-- instead of re-issuing the 400-line reset body for one PERFORM line.
-- 1. Platform CHECK constraints -------------------------------------------
ALTER TABLE public.webshop_orders
DROP CONSTRAINT IF EXISTS webshop_orders_platform_check;
ALTER TABLE public.webshop_orders
ADD CONSTRAINT webshop_orders_platform_check
CHECK (platform IN ('woocommerce', 'shopify', 'zettle'));
ALTER TABLE public.webshop_store_settings
DROP CONSTRAINT IF EXISTS webshop_store_settings_platform_check;
ALTER TABLE public.webshop_store_settings
ADD CONSTRAINT webshop_store_settings_platform_check
CHECK (platform IN ('woocommerce', 'shopify', 'zettle'));
-- 2. Writer-role gate ------------------------------------------------------
DROP TRIGGER IF EXISTS aa_enforce_company_writer_role ON public.zettle_connections;
CREATE TRIGGER aa_enforce_company_writer_role
BEFORE INSERT OR UPDATE OR DELETE ON public.zettle_connections
FOR EACH ROW EXECUTE FUNCTION public.enforce_company_writer_role();
-- 3a. Reset snapshot: a pending/active Zettle connection blocks the reset ---
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
RENAME TO company_migration_reset_snapshot_before_20260909100400;
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_snapshot jsonb;
v_blockers jsonb;
v_zettle integer;
BEGIN
v_snapshot := public.company_migration_reset_snapshot_before_20260909100400(
p_company_id
);
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
RETURN v_snapshot;
END IF;
SELECT count(*) INTO v_zettle
FROM public.zettle_connections
WHERE company_id = p_company_id AND status IN ('pending', 'active');
IF v_zettle = 0 THEN
RETURN v_snapshot;
END IF;
-- Fold into the existing active_integrations_or_schedules blocker when the
-- inner snapshot already raised one; otherwise append it.
SELECT COALESCE(jsonb_agg(
CASE
WHEN existing.blocker ->> 'code' = 'active_integrations_or_schedules'
THEN existing.blocker || jsonb_build_object(
'count', COALESCE((existing.blocker ->> 'count')::integer, 0) + v_zettle
)
ELSE existing.blocker
END
ORDER BY existing.position
), '[]'::jsonb)
INTO v_blockers
FROM jsonb_array_elements(v_snapshot -> 'blockers')
WITH ORDINALITY AS existing(blocker, position);
IF NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(v_blockers) AS b
WHERE b ->> 'code' = 'active_integrations_or_schedules'
) THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'active_integrations_or_schedules',
'count', v_zettle
));
END IF;
RETURN v_snapshot || jsonb_build_object(
'eligible', false,
'blockers', v_blockers
);
END;
$$;
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
'Internal fail-closed reset snapshot. Journal entries, voucher sequences, and invoices are retained data, not blockers; lock, filing, sync, import, integration (incl. Zettle), and worker state still block.';
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260909100400(uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
FROM PUBLIC, anon, authenticated;
-- 3b. Reset execution: lock zettle_connections with the other integrations --
ALTER FUNCTION public.reset_company_for_migration(uuid, text, text, boolean, boolean)
RENAME TO reset_company_for_migration_before_20260909100400;
CREATE OR REPLACE FUNCTION public.reset_company_for_migration(
p_company_id uuid,
p_confirmed_name text,
p_reason text,
p_confirm_no_filed_declarations boolean,
p_confirm_retained_archive boolean
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Only an owner reaches the lock: outsiders and non-owner members fall
-- through to the inner function's own fast-fail so they cannot hold row
-- locks on a foreign company while it rejects them.
IF EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid()
AND cm.role = 'owner'
) THEN
PERFORM 1 FROM public.zettle_connections
WHERE company_id = p_company_id FOR UPDATE;
END IF;
RETURN public.reset_company_for_migration_before_20260909100400(
p_company_id,
p_confirmed_name,
p_reason,
p_confirm_no_filed_declarations,
p_confirm_retained_archive
);
END;
$$;
REVOKE ALL ON FUNCTION public.reset_company_for_migration_before_20260909100400(uuid, text, text, boolean, boolean)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.reset_company_for_migration(uuid, text, text, boolean, boolean)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.reset_company_for_migration(uuid, text, text, boolean, boolean)
TO authenticated;
NOTIFY pgrst, 'reload schema';
+29 -1
View File
@@ -73,6 +73,8 @@ describe('company migration reset RPCs (pg)', () => {
legacy_snapshot_224000_authenticated: boolean
legacy_snapshot_231500_authenticated: boolean
legacy_snapshot_20260826150000_authenticated: boolean
legacy_snapshot_20260909100400_authenticated: boolean
legacy_reset_20260909100400_authenticated: boolean
}>(`
SELECT
has_function_privilege(
@@ -124,7 +126,17 @@ describe('company migration reset RPCs (pg)', () => {
'authenticated',
'public.company_migration_reset_snapshot_before_20260826150000(uuid)',
'EXECUTE'
) AS legacy_snapshot_20260826150000_authenticated
) AS legacy_snapshot_20260826150000_authenticated,
has_function_privilege(
'authenticated',
'public.company_migration_reset_snapshot_before_20260909100400(uuid)',
'EXECUTE'
) AS legacy_snapshot_20260909100400_authenticated,
has_function_privilege(
'authenticated',
'public.reset_company_for_migration_before_20260909100400(uuid,text,text,boolean,boolean)',
'EXECUTE'
) AS legacy_reset_20260909100400_authenticated
`)
expect(rows[0]).toEqual({
@@ -138,6 +150,8 @@ describe('company migration reset RPCs (pg)', () => {
legacy_snapshot_224000_authenticated: false,
legacy_snapshot_231500_authenticated: false,
legacy_snapshot_20260826150000_authenticated: false,
legacy_snapshot_20260909100400_authenticated: false,
legacy_reset_20260909100400_authenticated: false,
})
})
@@ -318,6 +332,20 @@ describe('company migration reset RPCs (pg)', () => {
count: 1,
})
// A pending Zettle connection is an integration too (20260909100400 wraps
// the snapshot instead of re-issuing its body).
const pos = await seedCompany()
await getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, status)
VALUES ($1, $2, 'pending')`,
[pos.companyId, pos.userId],
)
const posPreview = await preview(pos.userId, pos.companyId)
expect(posPreview.eligibility?.blockers).toContainEqual({
code: 'active_integrations_or_schedules',
count: 1,
})
const busy = await seedCompany()
await getPool().query(
`INSERT INTO public.operations (company_id, user_id, operation_type, status)
+12 -2
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { insertAuthUser, insertCompany, insertCompanyMember, seedCompany } from './fixtures'
// Validates migration 20260826120000_get_dashboard_nav_flags:
// 1. has_webshop flips on an ACTIVE WooCommerce or Shopify connection
// Validates migration 20260826120000_get_dashboard_nav_flags (plus zettle):
// 1. has_webshop flips on an ACTIVE WooCommerce, Shopify, or Zettle connection
// (a pending/revoked one does not count) and has_mileage_trips on any
// mileage_trips row.
// 2. SECURITY INVOKER: a member of ANOTHER company sees (false, false)
@@ -64,6 +64,16 @@ describe('get_dashboard_nav_flags()', () => {
expect((await flagsAs(userId, companyId)).has_webshop).toBe(true)
})
it('flips has_webshop on an active Zettle connection', async () => {
const { userId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, userId, `nav-flags-zettle-${companyId}`],
)
expect((await flagsAs(userId, companyId)).has_webshop).toBe(true)
})
it('flips has_mileage_trips on any mileage trip', async () => {
const { userId, companyId } = await seedCompany()
await getPool().query(
+4 -2
View File
@@ -61,10 +61,11 @@ async function trialGrantKeys(companyId: string): Promise<string[]> {
}
describe('trial suppression for byrå-team companies', () => {
it('the trial seed covers the full eight-key PAID set', () => {
it('the trial seed covers the full nine-key PAID set', () => {
// Guard against the seed list drifting from lib/entitlements/keys.ts:
// if PAID_CAPABILITIES grows, the migration VALUES list (and this test)
// must grow with it. multi_user joined at 20260901081417.
// must grow with it. multi_user joined at 20260901081417; zettle_sync
// at 20260909100300.
expect(TRIAL_KEYS).toEqual(
[
'ai',
@@ -75,6 +76,7 @@ describe('trial suppression for byrå-team companies', () => {
'skatteverket',
'stripe_payments',
'woocommerce_sync',
'zettle_sync',
],
)
})
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { randomUUID } from 'crypto'
import { insertAuthUser, insertCompanyMember, seedCompany } from './fixtures'
const uniqueOrg = (label: string) => label + '-' + randomUUID()
/**
* Covers migration 20260909100000_zettle_connections:
* 1. RLS: members insert and read their own company's connection.
* 2. One ACTIVE connection per company.
* 3. One organization actively connected to at most one company.
* 4. No DELETE policy.
*/
describe('zettle_connections RLS', () => {
it('a member can insert and read their company connection', async () => {
const { userId, companyId } = await seedCompany()
const org = uniqueOrg('member')
await withUserContext(userId, async (client) => {
const inserted = await client.query(
`INSERT INTO public.zettle_connections
(company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active')
RETURNING id`,
[companyId, userId, org],
)
expect(inserted.rows).toHaveLength(1)
const read = await client.query(
`SELECT status, organization_uuid FROM public.zettle_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toEqual([{ status: 'active', organization_uuid: org }])
})
})
it('a non-member sees nothing and cannot insert for a foreign company', async () => {
const { userId: ownerId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, ownerId, uniqueOrg('foreign')],
)
const { userId: outsiderId } = await seedCompany()
await withUserContext(outsiderId, async (client) => {
const read = await client.query(
`SELECT id FROM public.zettle_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toHaveLength(0)
await expect(
client.query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'pending')`,
[companyId, outsiderId, uniqueOrg('intruder')],
),
).rejects.toThrow(/row-level security/i)
})
})
it('only one ACTIVE connection per company is allowed', async () => {
const { userId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, userId, uniqueOrg('org-one')],
)
await expect(
getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active')`,
[companyId, userId, uniqueOrg('org-two')],
),
).rejects.toMatchObject({ code: '23505' })
})
it('an organization may be actively connected to at most one company', async () => {
const { userId: userA, companyId: companyA } = await seedCompany()
const { userId: userB, companyId: companyB } = await seedCompany()
const sharedOrg = uniqueOrg('shared')
await getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active')`,
[companyA, userA, sharedOrg],
)
await expect(
getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active')`,
[companyB, userB, sharedOrg],
),
).rejects.toMatchObject({ code: '23505' })
})
it('members cannot DELETE a connection (revoke-only)', async () => {
const { userId, companyId } = await seedCompany()
const inserted = await getPool().query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'active') RETURNING id`,
[companyId, userId, uniqueOrg('nodelete')],
)
const id = inserted.rows[0].id as string
await withUserContext(userId, async (client) => {
const deleted = await client.query(
`DELETE FROM public.zettle_connections WHERE id = $1`,
[id],
)
expect(deleted.rowCount).toBe(0)
})
const still = await getPool().query(
`SELECT status FROM public.zettle_connections WHERE id = $1`,
[id],
)
expect(still.rows[0].status).toBe('active')
})
})
/**
* Covers migration 20260909100400_zettle_platform_parity:
* 1. webshop_orders / webshop_store_settings accept platform = 'zettle'.
* 2. The writer-role gate (20260902093000) is attached: a viewer cannot
* connect a Zettle account even though RLS membership would let them.
*/
describe('zettle platform parity', () => {
it('lets webshop rows carry platform = zettle', async () => {
const { rows } = await getPool().query<{ conname: string; def: string }>(
`SELECT conname, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE conname IN ('webshop_orders_platform_check', 'webshop_store_settings_platform_check')
ORDER BY conname`,
)
expect(rows.map((r) => r.conname)).toEqual([
'webshop_orders_platform_check',
'webshop_store_settings_platform_check',
])
for (const row of rows) {
expect(row.def).toContain("'zettle'")
expect(row.def).toContain("'shopify'")
expect(row.def).toContain("'woocommerce'")
}
})
it('refuses a viewer who tries to connect', async () => {
const { companyId } = await seedCompany()
const viewerId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
await withUserContext(viewerId, async (client) => {
await expect(
client.query(
`INSERT INTO public.zettle_connections (company_id, user_id, organization_uuid, status)
VALUES ($1, $2, $3, 'pending')`,
[companyId, viewerId, uniqueOrg('viewer')],
),
).rejects.toThrow(/no write access to company/i)
})
})
})
+2 -2
View File
@@ -3840,9 +3840,9 @@ export interface IngestResult {
shadow_date_drift_candidates?: number
}
// ── Webshop orders (Orders page; synced by the woocommerce/shopify extensions) ──
// ── Webshop orders (Orders page; synced by the woocommerce/shopify/zettle extensions) ──
export type WebshopPlatform = 'woocommerce' | 'shopify'
export type WebshopPlatform = 'woocommerce' | 'shopify' | 'zettle'
export type WebshopOrderRowType = 'order' | 'refund'
/** One VAT rate bucket of an order, in the order's currency. */
+4
View File
@@ -34,6 +34,10 @@
"path": "/api/extensions/shopify/orders/cron",
"schedule": "15 3 * * *"
},
{
"path": "/api/extensions/zettle/orders/cron",
"schedule": "30 3 * * *"
},
{
"path": "/api/documents/verify/cron",
"schedule": "0 3 * * *"