From 6ea92f315281371b059affb451651f67f46a6eb3 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:19:39 +0200 Subject: [PATCH] feat(zettle): sync paid purchases into webshop_orders (#2445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB --- .env.example | 5 + DECISIONS.md | 9 + app/(dashboard)/import/page.tsx | 34 +- .../[id]/delete/__tests__/route.test.ts | 12 + app/api/company/[id]/delete/route.ts | 14 + .../zettle/callback/__tests__/route.test.ts | 238 +++++++ app/api/extensions/zettle/callback/route.ts | 245 +++++++ .../orders/cron/__tests__/route.test.ts | 117 ++++ .../extensions/zettle/orders/cron/route.ts | 156 +++++ docker/crontab.hosted | 1 + docker/crontab.self-hosted | 1 + extensions.config.json | 2 +- extensions.schema.json | 3 +- .../zettle/__tests__/api-client.test.ts | 11 + .../zettle/__tests__/api-routes.test.ts | 188 ++++++ .../zettle/__tests__/credentials.test.ts | 30 + .../general/zettle/__tests__/oauth.test.ts | 29 + .../zettle/__tests__/order-sync-claim.test.ts | 60 ++ .../zettle/__tests__/order-sync.test.ts | 206 ++++++ .../zettle/__tests__/settings-actions.test.ts | 27 + extensions/general/zettle/api-routes.ts | 351 ++++++++++ .../zettle/components/ZettleSettingsPanel.tsx | 386 +++++++++++ extensions/general/zettle/index.ts | 33 + extensions/general/zettle/lib/api-client.ts | 128 ++++ extensions/general/zettle/lib/credentials.ts | 58 ++ extensions/general/zettle/lib/oauth.ts | 169 +++++ extensions/general/zettle/lib/order-sync.ts | 629 ++++++++++++++++++ .../general/zettle/lib/return-origin.ts | 28 + .../general/zettle/lib/settings-actions.ts | 72 ++ extensions/general/zettle/manifest.json | 23 + extensions/general/zettle/types.ts | 116 ++++ lib/api/schemas.ts | 2 +- lib/dashboard/__tests__/nav-flags.test.ts | 1 + lib/dashboard/nav-flags.ts | 8 +- lib/entitlements/keys.ts | 3 + lib/events/types.ts | 3 + lib/extensions/__tests__/sectors.test.ts | 6 +- .../_generated/enabled-extensions.ts | 1 + lib/extensions/_generated/extension-list.ts | 2 + .../_generated/sector-definitions.ts | 11 + lib/extensions/settings-panel-registry.tsx | 3 + lib/reports/full-archive-export.ts | 1 + .../__tests__/subscription-sync.test.ts | 2 +- lib/webshop-orders/order-underlag.tsx | 1 + messages/en.json | 48 ++ messages/sv.json | 48 ++ public/logos/zettle.svg | 4 + .../20260909100000_zettle_connections.sql | 80 +++ ...100100_zettle_sync_capability_backfill.sql | 22 + ...9100200_get_dashboard_nav_flags_zettle.sql | 40 ++ ...00_seed_trial_capability_grants_zettle.sql | 39 ++ .../20260909100400_zettle_platform_parity.sql | 162 +++++ tests/pg/company-migration-reset.pg.test.ts | 30 +- tests/pg/dashboard-nav-flags-rpc.pg.test.ts | 14 +- tests/pg/trial-suppression-byra.pg.test.ts | 6 +- tests/pg/zettle-connections.pg.test.ts | 161 +++++ types/index.ts | 4 +- vercel.json | 4 + 58 files changed, 4069 insertions(+), 18 deletions(-) create mode 100644 app/api/extensions/zettle/callback/__tests__/route.test.ts create mode 100644 app/api/extensions/zettle/callback/route.ts create mode 100644 app/api/extensions/zettle/orders/cron/__tests__/route.test.ts create mode 100644 app/api/extensions/zettle/orders/cron/route.ts create mode 100644 extensions/general/zettle/__tests__/api-client.test.ts create mode 100644 extensions/general/zettle/__tests__/api-routes.test.ts create mode 100644 extensions/general/zettle/__tests__/credentials.test.ts create mode 100644 extensions/general/zettle/__tests__/oauth.test.ts create mode 100644 extensions/general/zettle/__tests__/order-sync-claim.test.ts create mode 100644 extensions/general/zettle/__tests__/order-sync.test.ts create mode 100644 extensions/general/zettle/__tests__/settings-actions.test.ts create mode 100644 extensions/general/zettle/api-routes.ts create mode 100644 extensions/general/zettle/components/ZettleSettingsPanel.tsx create mode 100644 extensions/general/zettle/index.ts create mode 100644 extensions/general/zettle/lib/api-client.ts create mode 100644 extensions/general/zettle/lib/credentials.ts create mode 100644 extensions/general/zettle/lib/oauth.ts create mode 100644 extensions/general/zettle/lib/order-sync.ts create mode 100644 extensions/general/zettle/lib/return-origin.ts create mode 100644 extensions/general/zettle/lib/settings-actions.ts create mode 100644 extensions/general/zettle/manifest.json create mode 100644 extensions/general/zettle/types.ts create mode 100644 public/logos/zettle.svg create mode 100644 supabase/migrations/20260909100000_zettle_connections.sql create mode 100644 supabase/migrations/20260909100100_zettle_sync_capability_backfill.sql create mode 100644 supabase/migrations/20260909100200_get_dashboard_nav_flags_zettle.sql create mode 100644 supabase/migrations/20260909100300_seed_trial_capability_grants_zettle.sql create mode 100644 supabase/migrations/20260909100400_zettle_platform_parity.sql create mode 100644 tests/pg/zettle-connections.pg.test.ts diff --git a/.env.example b/.env.example index e94a1bbd..568bcdb6 100644 --- a/.env.example +++ b/.env.example @@ -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 /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 diff --git a/DECISIONS.md b/DECISIONS.md index 7ccc4172..43bb0d10 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1665,6 +1665,9 @@ One line per decision: `[YYYY-MM-DD] : `. 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] : `. 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. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index ebab7aee..2cb0e385 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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 (
@@ -2517,6 +2522,16 @@ export default function ImportPage() { onClick={() => setMode('shopify')} /> )} + {hasZettleExtension && ( + } + chips={} + disabled={zettleDisabled} + onClick={() => setMode('zettle')} + /> + )} {hasMigrationExtension && ( ) )} + {mode === 'zettle' && ( + hasZettleExtension && ZettlePanel ? ( + + ) : ( + + + +

{t('zettle_not_enabled_title')}

+

+ {t('zettle_not_enabled_description')} +

+
+
+ ) + )} {mode === 'bank' && } {mode === 'skattekonto' && } {/* The CSV/Excel wizard opens on "Ingående balanser" by default, which is diff --git a/app/api/company/[id]/delete/__tests__/route.test.ts b/app/api/company/[id]/delete/__tests__/route.test.ts index 5c43a2de..48792834 100644 --- a/app/api/company/[id]/delete/__tests__/route.test.ts +++ b/app/api/company/[id]/delete/__tests__/route.test.ts @@ -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' }), + ) }) }) diff --git a/app/api/company/[id]/delete/route.ts b/app/api/company/[id]/delete/route.ts index 103e83e1..58aa78f2 100644 --- a/app/api/company/[id]/delete/route.ts +++ b/app/api/company/[id]/delete/route.ts @@ -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 diff --git a/app/api/extensions/zettle/callback/__tests__/route.test.ts b/app/api/extensions/zettle/callback/__tests__/route.test.ts new file mode 100644 index 00000000..a38f0c18 --- /dev/null +++ b/app/api/extensions/zettle/callback/__tests__/route.test.ts @@ -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) { + 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 = {} + 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).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).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() + }) +}) diff --git a/app/api/extensions/zettle/callback/route.ts b/app/api/extensions/zettle/callback/route.ts new file mode 100644 index 00000000..d89938e6 --- /dev/null +++ b/app/api/extensions/zettle/callback/route.ts @@ -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')}`, + ) + } +} diff --git a/app/api/extensions/zettle/orders/cron/__tests__/route.test.ts b/app/api/extensions/zettle/orders/cron/__tests__/route.test.ts new file mode 100644 index 00000000..fb4a9b00 --- /dev/null +++ b/app/api/extensions/zettle/orders/cron/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/extensions/zettle/orders/cron/route.ts b/app/api/extensions/zettle/orders/cron/route.ts new file mode 100644 index 00000000..2062066e --- /dev/null +++ b/app/api/extensions/zettle/orders/cron/route.ts @@ -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 }) +}) diff --git a/docker/crontab.hosted b/docker/crontab.hosted index 869cc897..a7b4a5a0 100644 --- a/docker/crontab.hosted +++ b/docker/crontab.hosted @@ -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 diff --git a/docker/crontab.self-hosted b/docker/crontab.self-hosted index cf1156ad..3ad98735 100644 --- a/docker/crontab.self-hosted +++ b/docker/crontab.self-hosted @@ -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 diff --git a/extensions.config.json b/extensions.config.json index 1b46c56a..dd910440 100644 --- a/extensions.config.json +++ b/extensions.config.json @@ -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"]} \ No newline at end of file +{"$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"]} \ No newline at end of file diff --git a/extensions.schema.json b/extensions.schema.json index ceab643c..ad0122c8 100644 --- a/extensions.schema.json +++ b/extensions.schema.json @@ -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." diff --git a/extensions/general/zettle/__tests__/api-client.test.ts b/extensions/general/zettle/__tests__/api-client.test.ts new file mode 100644 index 00000000..981e618f --- /dev/null +++ b/extensions/general/zettle/__tests__/api-client.test.ts @@ -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) + }) +}) diff --git a/extensions/general/zettle/__tests__/api-routes.test.ts b/extensions/general/zettle/__tests__/api-routes.test.ts new file mode 100644 index 00000000..63ae0cb6 --- /dev/null +++ b/extensions/general/zettle/__tests__/api-routes.test.ts @@ -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() + return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) } +}) + +vi.mock('../lib/oauth', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + refreshAccessToken: vi.fn(), + disconnectApplication: vi.fn().mockResolvedValue(undefined), + } +}) + +vi.mock('../lib/order-sync', async (importOriginal) => { + const actual = await importOriginal() + 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() + }) +}) diff --git a/extensions/general/zettle/__tests__/credentials.test.ts b/extensions/general/zettle/__tests__/credentials.test.ts new file mode 100644 index 00000000..76dc4207 --- /dev/null +++ b/extensions/general/zettle/__tests__/credentials.test.ts @@ -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 + }) +}) diff --git a/extensions/general/zettle/__tests__/oauth.test.ts b/extensions/general/zettle/__tests__/oauth.test.ts new file mode 100644 index 00000000..350be5f1 --- /dev/null +++ b/extensions/general/zettle/__tests__/oauth.test.ts @@ -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', + ) + }) +}) diff --git a/extensions/general/zettle/__tests__/order-sync-claim.test.ts b/extensions/general/zettle/__tests__/order-sync-claim.test.ts new file mode 100644 index 00000000..6caf8d25 --- /dev/null +++ b/extensions/general/zettle/__tests__/order-sync-claim.test.ts @@ -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() + return { ...actual, refreshAccessToken: (...args: unknown[]) => refreshAccessToken(...args) } +}) +vi.mock('../lib/credentials', async (importOriginal) => { + const actual = await importOriginal() + 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') + }) +}) diff --git a/extensions/general/zettle/__tests__/order-sync.test.ts b/extensions/general/zettle/__tests__/order-sync.test.ts new file mode 100644 index 00000000..978006a7 --- /dev/null +++ b/extensions/general/zettle/__tests__/order-sync.test.ts @@ -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 { + 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) + }) +}) diff --git a/extensions/general/zettle/__tests__/settings-actions.test.ts b/extensions/general/zettle/__tests__/settings-actions.test.ts new file mode 100644 index 00000000..62857e28 --- /dev/null +++ b/extensions/general/zettle/__tests__/settings-actions.test.ts @@ -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 }, + }) + }) +}) diff --git a/extensions/general/zettle/api-routes.ts b/extensions/general/zettle/api-routes.ts new file mode 100644 index 00000000..e6d88de3 --- /dev/null +++ b/extensions/general/zettle/api-routes.ts @@ -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 { + 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 { + 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 }) + }, + }, +] diff --git a/extensions/general/zettle/components/ZettleSettingsPanel.tsx b/extensions/general/zettle/components/ZettleSettingsPanel.tsx new file mode 100644 index 00000000..50283d71 --- /dev/null +++ b/extensions/general/zettle/components/ZettleSettingsPanel.tsx @@ -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 + +const STATUS_VARIANT: Record = { + 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(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({ + 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({ + 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 ( + + + + + + + + ) + } + + if (loadFailed) { + return ( + + + {t('title')} + + +

{t('load_failed')}

+ +
+
+ ) + } + + if (!configured) { + return ( + + + {t('title')} + + +

{t('not_configured')}

+
+
+ ) + } + + const isActive = connection?.status === 'active' + const showConnect = !connection || !isActive + + return ( + + + {t('title')} + + +

{t('description')}

+ + {connection && ( +
+
+ +
+
+ + {connection.organization_name || + connection.organization_uuid || + t('unnamed_store')} + + + {t(`status_${connection.status}`)} + +
+ {connection.organization_uuid && connection.organization_name && ( +

+ {connection.organization_uuid} +

+ )} + {isActive && connection.connected_at && ( +

+ {t('connected_since', { date: formatDateLong(connection.connected_at) })} +

+ )} + {connection.error_message && ( +

{connection.error_message}

+ )} +
+
+ {isActive && + (confirmDisconnect ? ( +
+ + +
+ ) : ( +
+ + +
+ ))} +
+ )} + + {showConnect && ( +
+

{t('connect_hint')}

+ +
+ )} + + {isActive && connection && ( +
+
+

{t('transaction_sync_title')}

+

{t('transaction_sync_description')}

+ {connection.transaction_sync_enabled ? ( +

+ {connection.last_order_synced_at + ? t('transaction_sync_last_synced', { + date: formatDateLong(connection.last_order_synced_at), + }) + : t('transaction_sync_never_synced')} +

+ ) : ( +

+ {t('transaction_sync_backfill_note')} +

+ )} +
+ +
+ )} +
+
+ ) +} diff --git a/extensions/general/zettle/index.ts b/extensions/general/zettle/index.ts new file mode 100644 index 00000000..705bc0cf --- /dev/null +++ b/extensions/general/zettle/index.ts @@ -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 diff --git a/extensions/general/zettle/lib/api-client.ts b/extensions/general/zettle/lib/api-client.ts new file mode 100644 index 00000000..17622b01 --- /dev/null +++ b/extensions/general/zettle/lib/api-client.ts @@ -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 { + 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 { + 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) +} diff --git a/extensions/general/zettle/lib/credentials.ts b/extensions/general/zettle/lib/credentials.ts new file mode 100644 index 00000000..8ca758f6 --- /dev/null +++ b/extensions/general/zettle/lib/credentials.ts @@ -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) +} diff --git a/extensions/general/zettle/lib/oauth.ts b/extensions/general/zettle/lib/oauth.ts new file mode 100644 index 00000000..1543e880 --- /dev/null +++ b/extensions/general/zettle/lib/oauth.ts @@ -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 { + 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 + 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 { + 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 { + 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 { + 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 + 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 { + 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 +} diff --git a/extensions/general/zettle/lib/order-sync.ts b/extensions/general/zettle/lib/order-sync.ts new file mode 100644 index 00000000..2b6b3b8b --- /dev/null +++ b/extensions/general/zettle/lib/order-sync.ts @@ -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 = { + 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 = { + 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 | null { + const netByRate = new Map() + 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() + 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, + 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 { + 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 +} diff --git a/extensions/general/zettle/lib/return-origin.ts b/extensions/general/zettle/lib/return-origin.ts new file mode 100644 index 00000000..6b4d537a --- /dev/null +++ b/extensions/general/zettle/lib/return-origin.ts @@ -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 { + 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}` +} diff --git a/extensions/general/zettle/lib/settings-actions.ts b/extensions/general/zettle/lib/settings-actions.ts new file mode 100644 index 00000000..b23492b7 --- /dev/null +++ b/extensions/general/zettle/lib/settings-actions.ts @@ -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 = PanelRequestResult +export type ZettleRequestOptions = PanelRequestOptions + +export { serverErrorMessage } from '@/lib/browser/panel-request' + +export function zettleRequest(options: ZettleRequestOptions): Promise> { + return panelRequest({ 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 } } +} diff --git a/extensions/general/zettle/manifest.json b/extensions/general/zettle/manifest.json new file mode 100644 index 00000000..2bd431b1 --- /dev/null +++ b/extensions/general/zettle/manifest.json @@ -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." + } +} diff --git a/extensions/general/zettle/types.ts b/extensions/general/zettle/types.ts new file mode 100644 index 00000000..9c137f62 --- /dev/null +++ b/extensions/general/zettle/types.ts @@ -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 | 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 +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 0fb9cba8..29d02f7d 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/dashboard/__tests__/nav-flags.test.ts b/lib/dashboard/__tests__/nav-flags.test.ts index e55c87a7..b8ec2ae3 100644 --- a/lib/dashboard/__tests__/nav-flags.test.ts +++ b/lib/dashboard/__tests__/nav-flags.test.ts @@ -70,6 +70,7 @@ describe('getDashboardNavFlags', () => { 'shopify_connections', 'webshop_orders', 'woocommerce_connections', + 'zettle_connections', ]) }) diff --git a/lib/dashboard/nav-flags.ts b/lib/dashboard/nav-flags.ts index 1d1da82e..3819c430 100644 --- a/lib/dashboard/nav-flags.ts +++ b/lib/dashboard/nav-flags.ts @@ -70,15 +70,19 @@ export async function getDashboardNavFlagsViaProbes( supabase: SupabaseClient, companyId: string, ): Promise> { - 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, } } diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index 392e1cd6..06b0dcef 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -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. diff --git a/lib/events/types.ts b/lib/events/types.ts index 7629bf86..e2348347 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -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 } } diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index 5072dd1a..bc557d82 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -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', () => { diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts index 4b552a69..e11c6a26 100644 --- a/lib/extensions/_generated/enabled-extensions.ts +++ b/lib/extensions/_generated/enabled-extensions.ts @@ -15,5 +15,6 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([ 'whatsapp-inbox', 'woocommerce', 'shopify', + 'zettle', 'mail', ]) diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts index 991569ac..90f22970 100644 --- a/lib/extensions/_generated/extension-list.ts +++ b/lib/extensions/_generated/extension-list.ts @@ -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, ] diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 4c32e5e6..ee5e1856 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -184,6 +184,17 @@ export const EXTENSION_DEFINITIONS: Record = { "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", diff --git a/lib/extensions/settings-panel-registry.tsx b/lib/extensions/settings-panel-registry.tsx index 4efbdf4e..b02fb252 100644 --- a/lib/extensions/settings-panel-registry.tsx +++ b/lib/extensions/settings-panel-registry.tsx @@ -25,6 +25,9 @@ const SETTINGS_PANELS: Record = { shopify: dynamic( () => import('@/extensions/general/shopify/components/ShopifySettingsPanel') ), + zettle: dynamic( + () => import('@/extensions/general/zettle/components/ZettleSettingsPanel') + ), } /** diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index e3aabd89..f096d1f3 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -1252,6 +1252,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = { 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. */ diff --git a/lib/stripe/__tests__/subscription-sync.test.ts b/lib/stripe/__tests__/subscription-sync.test.ts index cdfaa2b8..179f1b41 100644 --- a/lib/stripe/__tests__/subscription-sync.test.ts +++ b/lib/stripe/__tests__/subscription-sync.test.ts @@ -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) }) diff --git a/lib/webshop-orders/order-underlag.tsx b/lib/webshop-orders/order-underlag.tsx index f1bc1967..cb20984c 100644 --- a/lib/webshop-orders/order-underlag.tsx +++ b/lib/webshop-orders/order-underlag.tsx @@ -58,6 +58,7 @@ export interface OrderUnderlagModel { const PLATFORM_LABELS: Record = { woocommerce: 'WooCommerce', shopify: 'Shopify', + zettle: 'Zettle', } function vatRateLabel(rate: number | null): string { diff --git a/messages/en.json b/messages/en.json index c81010cb..a31d31af 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 28683cbd..df8036b6 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/public/logos/zettle.svg b/public/logos/zettle.svg new file mode 100644 index 00000000..eb7a768f --- /dev/null +++ b/public/logos/zettle.svg @@ -0,0 +1,4 @@ + + + Zettle + diff --git a/supabase/migrations/20260909100000_zettle_connections.sql b/supabase/migrations/20260909100000_zettle_connections.sql new file mode 100644 index 00000000..638d964d --- /dev/null +++ b/supabase/migrations/20260909100000_zettle_connections.sql @@ -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'; diff --git a/supabase/migrations/20260909100100_zettle_sync_capability_backfill.sql b/supabase/migrations/20260909100100_zettle_sync_capability_backfill.sql new file mode 100644 index 00000000..ad7cc4bd --- /dev/null +++ b/supabase/migrations/20260909100100_zettle_sync_capability_backfill.sql @@ -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; diff --git a/supabase/migrations/20260909100200_get_dashboard_nav_flags_zettle.sql b/supabase/migrations/20260909100200_get_dashboard_nav_flags_zettle.sql new file mode 100644 index 00000000..8c920b35 --- /dev/null +++ b/supabase/migrations/20260909100200_get_dashboard_nav_flags_zettle.sql @@ -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'; diff --git a/supabase/migrations/20260909100300_seed_trial_capability_grants_zettle.sql b/supabase/migrations/20260909100300_seed_trial_capability_grants_zettle.sql new file mode 100644 index 00000000..9cb80a60 --- /dev/null +++ b/supabase/migrations/20260909100300_seed_trial_capability_grants_zettle.sql @@ -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; +$$; diff --git a/supabase/migrations/20260909100400_zettle_platform_parity.sql b/supabase/migrations/20260909100400_zettle_platform_parity.sql new file mode 100644 index 00000000..f7774b4e --- /dev/null +++ b/supabase/migrations/20260909100400_zettle_platform_parity.sql @@ -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'; diff --git a/tests/pg/company-migration-reset.pg.test.ts b/tests/pg/company-migration-reset.pg.test.ts index 2b2c1cfb..de1a2ad0 100644 --- a/tests/pg/company-migration-reset.pg.test.ts +++ b/tests/pg/company-migration-reset.pg.test.ts @@ -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) diff --git a/tests/pg/dashboard-nav-flags-rpc.pg.test.ts b/tests/pg/dashboard-nav-flags-rpc.pg.test.ts index f9400f1d..612b990f 100644 --- a/tests/pg/dashboard-nav-flags-rpc.pg.test.ts +++ b/tests/pg/dashboard-nav-flags-rpc.pg.test.ts @@ -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( diff --git a/tests/pg/trial-suppression-byra.pg.test.ts b/tests/pg/trial-suppression-byra.pg.test.ts index af90046f..e8cc33ba 100644 --- a/tests/pg/trial-suppression-byra.pg.test.ts +++ b/tests/pg/trial-suppression-byra.pg.test.ts @@ -61,10 +61,11 @@ async function trialGrantKeys(companyId: string): Promise { } 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', ], ) }) diff --git a/tests/pg/zettle-connections.pg.test.ts b/tests/pg/zettle-connections.pg.test.ts new file mode 100644 index 00000000..c34d0950 --- /dev/null +++ b/tests/pg/zettle-connections.pg.test.ts @@ -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) + }) + }) +}) diff --git a/types/index.ts b/types/index.ts index 7035c881..35765182 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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. */ diff --git a/vercel.json b/vercel.json index 8b6380e8..fdc3cf9b 100644 --- a/vercel.json +++ b/vercel.json @@ -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 * * *"