From 707d597b2ef8f8ac4a451b613d32dc59a7f0d9c4 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:30:00 +0200 Subject: [PATCH] feat(woocommerce): store order/refund feed extension (#1442) * feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 5 + app/(dashboard)/import/page.tsx | 37 +- .../woocommerce/__tests__/callback.test.ts | 164 +++++ .../extensions/woocommerce/callback/route.ts | 184 ++++++ .../woocommerce/orders/cron/route.ts | 138 ++++ .../extensions/woocommerce/return/route.ts | 62 ++ docker/crontab.hosted | 1 + docker/crontab.self-hosted | 1 + extensions.config.json | 2 +- extensions.schema.json | 3 +- .../woocommerce/__tests__/api-client.test.ts | 73 +++ .../woocommerce/__tests__/api-routes.test.ts | 355 +++++++++++ .../woocommerce/__tests__/credentials.test.ts | 69 ++ .../woocommerce/__tests__/order-sync.test.ts | 450 +++++++++++++ .../__tests__/settings-actions.test.ts | 46 ++ extensions/general/woocommerce/api-routes.ts | 527 ++++++++++++++++ .../components/WooCommerceSettingsPanel.tsx | 506 +++++++++++++++ extensions/general/woocommerce/index.ts | 31 + .../general/woocommerce/lib/api-client.ts | 319 ++++++++++ extensions/general/woocommerce/lib/connect.ts | 52 ++ .../general/woocommerce/lib/credentials.ts | 44 ++ .../general/woocommerce/lib/order-sync.ts | 593 ++++++++++++++++++ .../woocommerce/lib/settings-actions.ts | 160 +++++ extensions/general/woocommerce/manifest.json | 19 + extensions/general/woocommerce/types.ts | 89 +++ lib/entitlements/keys.ts | 3 + lib/events/types.ts | 4 + 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 +- messages/en.json | 57 ++ messages/sv.json | 57 ++ public/logos/woocommerce.svg | 4 + ...20260806170000_woocommerce_connections.sql | 99 +++ ...0_woocommerce_sync_capability_backfill.sql | 29 + tests/pg/woocommerce-connections.pg.test.ts | 131 ++++ vercel.json | 4 + 41 files changed, 4335 insertions(+), 9 deletions(-) create mode 100644 app/api/extensions/woocommerce/__tests__/callback.test.ts create mode 100644 app/api/extensions/woocommerce/callback/route.ts create mode 100644 app/api/extensions/woocommerce/orders/cron/route.ts create mode 100644 app/api/extensions/woocommerce/return/route.ts create mode 100644 extensions/general/woocommerce/__tests__/api-client.test.ts create mode 100644 extensions/general/woocommerce/__tests__/api-routes.test.ts create mode 100644 extensions/general/woocommerce/__tests__/credentials.test.ts create mode 100644 extensions/general/woocommerce/__tests__/order-sync.test.ts create mode 100644 extensions/general/woocommerce/__tests__/settings-actions.test.ts create mode 100644 extensions/general/woocommerce/api-routes.ts create mode 100644 extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx create mode 100644 extensions/general/woocommerce/index.ts create mode 100644 extensions/general/woocommerce/lib/api-client.ts create mode 100644 extensions/general/woocommerce/lib/connect.ts create mode 100644 extensions/general/woocommerce/lib/credentials.ts create mode 100644 extensions/general/woocommerce/lib/order-sync.ts create mode 100644 extensions/general/woocommerce/lib/settings-actions.ts create mode 100644 extensions/general/woocommerce/manifest.json create mode 100644 extensions/general/woocommerce/types.ts create mode 100644 public/logos/woocommerce.svg create mode 100644 supabase/migrations/20260806170000_woocommerce_connections.sql create mode 100644 supabase/migrations/20260806170100_woocommerce_sync_capability_backfill.sql create mode 100644 tests/pg/woocommerce-connections.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 71dae89b..949855b5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -804,6 +804,11 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed. [2026-08-06] Supplier credit notes under kontantmetoden now reverse when the ORIGINAL was already booked (paid), not only under faktureringsmetoden: skipping left the expense and the 2641 ingaende moms deduction overstated with no accounting trace. Mirrors the customer-side creditNoteNeedsJournalEntry(). The v1 route's GDPR-minimised projection had to re-add registration_journal_entry_id/payment_journal_entry_id/paid_at/paid_amount: status alone misses a part-paid-but-booked original. [2026-08-06] Kontantmetoden year-end cut-off (BFL 5 kap 2 §) books moms to the VILANDE accounts (2618/2628/2638 ut, 2648 in), never 2611/2641: vilande accounts are deliberately absent from ACCOUNT_RUTA/ACCOUNT_TO_BOX, so the moms stays out of the momsdeklaration until payment, which is what bokslutsmetoden requires. 2647 was considered and rejected: it is domestic omvand betalningsskyldighet, unrelated. Cut-off posts as two AGGREGATE verifikat reversed on day 1 of the next period, and deliberately does NOT set invoices.journal_entry_id: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed, booking the settlement twice. +[2026-08-06] WooCommerce integration (extensions/general/woocommerce) is feed-only, orders + refunds: one positive inbox row per paid order (date_paid, gross total), one negative row per refund, nothing auto-booked, matching the Stripe feed doctrine of 2026-07-24. Gateway fees and payouts are deliberately out of scope: core wc/v3 exposes neither (they belong to the gateway; WooPayments has its own API and is a possible follow-up), so promising fee-level accuracy from the Woo API alone would be a lie. order.transaction_id is stored as the row reference for later gateway-side reconciliation. +[2026-08-06] WooCommerce feed clearing account is 1680 Andra kortfristiga fordringar, not 1686: 1686 (Fordringar for kontokort och kuponger) is the economically right BAS account for gateway receivables but the Stripe feed owns it and cash_accounts enforces UNIQUE(company_id, ledger_account); 1680 is the closest correct free account in the 168x group. Consequence accepted: a store using the Stripe gateway plus a connected Stripe feed shows the same money in both feeds on different accounts, and the dedup account guard correctly does not merge them; panel copy positions Woo as the order feed and Stripe as the money feed. +[2026-08-06] WooCommerce v1 has no webhooks: delivery runs on WP-Cron (fires only on site traffic), auto-disables after 5 consecutive failures, and has no refund topic, so it cannot be the backbone of an accounting feed. Nightly modified_after polling with a 24h cursor overlap + manual sync instead; webhooks can be added later as a poke-then-refetch accelerator. +[2026-08-06] wc-auth callback authenticity: WooCommerce signs nothing on the handshake POST, so possession of the single-use oauth_state is the CSRF defense and activation additionally requires the received keys to pass a live probe against the STORED store_url. Per-tenant consumer key/secret are AES-256-GCM encrypted (WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY, skatteverket token-store pattern). external_id scope is the store host, not the connection id, so disconnect/reconnect of the same store never re-imports history. +[2026-08-06] WooCommerce sync redesigned after skeptic review refuted the first version: pagination is cursor-based (modified_after advances per page; offset pages only within a same-second date_modified tie, since modified_after is strictly exclusive), termination is an empty page (hosts may cap per_page), the persisted cursor never advances past failed refund fetches, ingest errors, or deadline-skipped work (failure floor = earliest affected order's date_modified - 1s), the deadline is checked between refund fetches inside a page, and rows dated on/before bookkeeping_locked_through are dropped at map time on EVERY run because modified_after selects on the wrong date dimension (a refund bumps date_modified long after date_paid). Cash-account currency falls back to the first order's real currency and a creation failure writes error_message on the connection so the panel cannot show a healthy store that never syncs. normalizeStoreUrl refuses localhost/private-IP/.local/.internal hosts (hostname-level SSRF guard). [2026-08-06] Recurring invoice cadence (user request) is a generic interval_months SMALLINT 1-12 (default 1) rather than a cadence enum: the UI offers only the four presets (1/3/6/12), while API/MCP accept any 1-12 (e.g. every 2 months), and existing rows stay monthly via the default. Missed-run roll-forward and edits/reactivation of interval>1 schedules anchor on the schedule's own next_run_date month grid (rollNextRunDateForward), so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 -> Apr 15, never Feb 15; monthly (interval 1) keeps its pinned today-anchored recompute semantics unchanged. Changing interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier (a 3->1 change therefore waits out the current gap; visible via Nasta korning). [2026-08-06] Prompt clarifications render from a structured summary (lib/agent-context/chat-clarifications.ts), never off the raw channel_context blob. A WhatsApp "nej" stores representation with participants:[] and purpose:null and denied:true, so branching on `!purpose` reads a settled denial as a half answer: the shipped renderer emitted "syfte SAKNAS: fråga bara efter syftet" about a meal the user had just said was not representation. `denied` and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) are now separate states. Also: the photo caption no longer reaches the prompt, for the reason already written down in channel-context-notes.ts (nobody was asked for it, nobody reviewed it), and free text passes through flattenMemoryContent because promptTemplate output is seeded as a user message and wrapToolResult only wraps tool results. [2026-08-06] Unmatched underlag are PROPOSED to the assistant, never auto-linked. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id nor transactions.document_id, so a chat-captured receipt is invisible to every lookup and #1425's backfill-by-document_id has nothing to backfill. Scoring unmatched items at read time (lib/agent-context/underlag-candidates.ts, reusing core-receipt-matcher) closes that with no migration and no link written by a machine, preserving the human confirm step; setting matched_transaction_id at intake above a confidence bar remains the open alternative and is a founder call. An uncomparable cross-currency amount disqualifies a candidate outright, because the matcher drops the amount signal there and date + merchant alone score 1.0. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 1ec4fe65..cbc4bf7d 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -20,7 +20,7 @@ import { } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle } from 'lucide-react' +import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle, ShoppingCart } from 'lucide-react' import { cn, formatDate } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { useCompany, useCapability } from '@/contexts/CompanyContext' @@ -1955,11 +1955,15 @@ const BankingPanel = getSettingsPanel('enable-banking') // PSD2 bank connection above. const StripePanel = getSettingsPanel('stripe') +// And for the WooCommerce order feed: the store's paid orders and refunds are +// an import source in the same category as the Stripe feed above. +const WooCommercePanel = getSettingsPanel('woocommerce') + // ============================================================ // Import Page with Selection Cards // ============================================================ -type ImportMode = null | 'psd2' | 'stripe' | 'bank' | 'sie' | 'csv_data' | 'migration' +type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'bank' | 'sie' | 'csv_data' | 'migration' export default function ImportPage() { const { isSandbox } = useCompany() @@ -1991,7 +1995,7 @@ export default function ImportPage() { // Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable. const allowedModes = isSandbox ? ['bank', 'sie', 'csv_data'] - : ['psd2', 'stripe', 'bank', 'sie', 'csv_data', 'migration'] + : ['psd2', 'stripe', 'woocommerce', 'bank', 'sie', 'csv_data', 'migration'] if (!isSandbox && searchParams.get('migration')) { setMode('migration') } else { @@ -2037,6 +2041,9 @@ export default function ImportPage() { const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe') // Stripe is enabled everywhere (hosted + self-hosted); only the sandbox blocks it. const stripeDisabled = isSandbox + const hasWooCommerceExtension = ENABLED_EXTENSION_IDS.has('woocommerce') + // Same doctrine as Stripe: external credentials never leave the sandbox. + const woocommerceDisabled = isSandbox return (
@@ -2108,6 +2115,15 @@ export default function ImportPage() { onClick={() => setMode('stripe')} /> )} + {hasWooCommerceExtension && ( + } + disabled={woocommerceDisabled} + onClick={() => setMode('woocommerce')} + /> + )} {hasMigrationExtension && ( ) )} + {mode === 'woocommerce' && ( + hasWooCommerceExtension && WooCommercePanel ? ( + + ) : ( + + + +

{t('woocommerce_not_enabled_title')}

+

+ {t('woocommerce_not_enabled_description')} +

+
+
+ ) + )} {mode === 'bank' && } {mode === 'sie' && } {mode === 'csv_data' && } diff --git a/app/api/extensions/woocommerce/__tests__/callback.test.ts b/app/api/extensions/woocommerce/__tests__/callback.test.ts new file mode 100644 index 00000000..2803ac78 --- /dev/null +++ b/app/api/extensions/woocommerce/__tests__/callback.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: vi.fn(), +})) +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() } })) +vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() })) +vi.mock('@/lib/extensions/registry', () => ({ extensionRegistry: { get: vi.fn() } })) +vi.mock('@/extensions/general/woocommerce/lib/api-client', async (importOriginal) => { + const actual = + await importOriginal() + return { ...actual, testConnectionAndFetchStoreInfo: vi.fn() } +}) + +import { POST } from '../callback/route' +import { createServiceClient } from '@/lib/supabase/server' +import { eventBus } from '@/lib/events/bus' +import { extensionRegistry } from '@/lib/extensions/registry' +import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client' +import { decryptCredential } from '@/extensions/general/woocommerce/lib/credentials' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const STATE = '123e4567-e89b-12d3-a456-426614174000' + +function makeCallbackRequest(body: unknown): Request { + return new Request('https://test.local/api/extensions/woocommerce/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }) +} + +const VALID_BODY = { + key_id: 1, + user_id: STATE, + consumer_key: 'ck_new', + consumer_secret: 'cs_new', + key_permissions: 'read', +} + +describe('POST /api/extensions/woocommerce/callback', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY', 'test-key') + vi.mocked(extensionRegistry.get).mockReturnValue( + { id: 'woocommerce' } as ReturnType, + ) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('refuses with 503 when the extension is disabled', async () => { + vi.mocked(extensionRegistry.get).mockReturnValue(undefined) + const res = await POST(makeCallbackRequest(VALID_BODY)) + expect(res.status).toBe(503) + const body = await res.json() + expect(body.code).toBe('EXTENSION_DISABLED') + }) + + it('rejects a non-JSON body with 400', async () => { + const res = await POST(makeCallbackRequest('not json')) + expect(res.status).toBe(400) + }) + + it('rejects a missing or non-UUID state with 400', async () => { + const res = await POST( + makeCallbackRequest({ ...VALID_BODY, user_id: 'not-a-uuid' }), + ) + expect(res.status).toBe(400) + + const res2 = await POST(makeCallbackRequest({ user_id: STATE })) + expect(res2.status).toBe(400) + }) + + it('returns 404 for an unknown or already-consumed state', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + vi.mocked(createServiceClient).mockResolvedValue( + supabase as unknown as Awaited>, + ) + enqueue({ data: null, error: { message: 'no rows', code: 'PGRST116' } }) + const res = await POST(makeCallbackRequest(VALID_BODY)) + expect(res.status).toBe(404) + }) + + it('marks the row error and returns 502 when the credential probe fails', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + vi.mocked(createServiceClient).mockResolvedValue( + supabase as unknown as Awaited>, + ) + enqueue({ + data: { + id: 'conn-1', + company_id: 'company-1', + user_id: 'user-1', + store_url: 'https://shop.example.se', + }, + }) + enqueue({ data: null }) // markError update + vi.mocked(testConnectionAndFetchStoreInfo).mockRejectedValue(new Error('403')) + + const res = await POST(makeCallbackRequest(VALID_BODY)) + expect(res.status).toBe(502) + const errorUpdate = findCall('woocommerce_connections', 'update')?.[0] as Record< + string, + unknown + > + expect(errorUpdate.status).toBe('error') + // The probe ran against the STORED store_url, not anything the caller sent. + expect(vi.mocked(testConnectionAndFetchStoreInfo).mock.calls[0][0]).toMatchObject({ + storeUrl: 'https://shop.example.se', + consumerKey: 'ck_new', + }) + }) + + it('encrypts the keys, activates the row and emits the audit event', async () => { + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + vi.mocked(createServiceClient).mockResolvedValue( + supabase as unknown as Awaited>, + ) + enqueue({ + data: { + id: 'conn-1', + company_id: 'company-1', + user_id: 'user-1', + store_url: 'https://shop.example.se', + }, + }) + enqueue({ + data: { + id: 'conn-1', + company_id: 'company-1', + user_id: 'user-1', + store_url: 'https://shop.example.se', + }, + }) // activation update + vi.mocked(testConnectionAndFetchStoreInfo).mockResolvedValue({ + name: 'Testbutiken', + currency: 'SEK', + prices_include_tax: true, + wc_version: '9.9.5', + }) + + const res = await POST(makeCallbackRequest(VALID_BODY)) + expect(res.status).toBe(200) + + const updates = findCalls('woocommerce_connections', 'update') + const activation = updates[0][0] as Record + expect(activation.status).toBe('active') + expect(activation.transaction_sync_enabled).toBe(true) + expect(activation.oauth_state).toBeNull() + expect(activation.store_name).toBe('Testbutiken') + // Secrets never stored in plaintext, and they decrypt back. + expect(String(activation.consumer_key_encrypted)).not.toContain('ck_new') + expect(decryptCredential(String(activation.consumer_key_encrypted))).toBe('ck_new') + expect(decryptCredential(String(activation.consumer_secret_encrypted))).toBe('cs_new') + + expect(eventBus.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'woocommerce.connected' }), + ) + }) +}) diff --git a/app/api/extensions/woocommerce/callback/route.ts b/app/api/extensions/woocommerce/callback/route.ts new file mode 100644 index 00000000..ec4f4065 --- /dev/null +++ b/app/api/extensions/woocommerce/callback/route.ts @@ -0,0 +1,184 @@ +import { NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events/bus' +import { loadExtensions } from '@/lib/extensions/loader' +import { extensionRegistry } from '@/lib/extensions/registry' +import { createLogger } from '@/lib/logger' +import { + encryptCredential, + isWooCommerceConfigured, +} from '@/extensions/general/woocommerce/lib/credentials' +import { testConnectionAndFetchStoreInfo } from '@/extensions/general/woocommerce/lib/api-client' + +// This route emits woocommerce.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() + +const log = createLogger('woocommerce/callback') + +// The credential probe talks to an arbitrary (often slow) WooCommerce host. +export const maxDuration = 60 + +/** + * POST /api/extensions/woocommerce/callback + * + * Server-to-server delivery of the wc-auth handshake result: WooCommerce + * POSTs { key_id, user_id, consumer_key, consumer_secret, key_permissions } + * here after the merchant approves. Must be a real Next.js route (not an + * extension dispatcher handler) because the store calls it directly, + * unauthenticated: the single-use oauth_state riding in user_id locates the + * pending row, and the received keys are verified against that row's stored + * store_url before anything is persisted. + */ +export async function POST(request: Request) { + loadExtensions() + if (!extensionRegistry.get('woocommerce')) { + return NextResponse.json( + { error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' }, + { status: 503 }, + ) + } + // The registry does not check manifest requiredEnvVars, so this route can be + // live without the encryption key; without this guard encryptCredential() + // would throw AFTER the probe, escaping the markError path entirely. + if (!isWooCommerceConfigured()) { + return NextResponse.json( + { error: 'WooCommerce integration is not configured', code: 'NOT_CONFIGURED' }, + { status: 503 }, + ) + } + + let body: { + user_id?: unknown + consumer_key?: unknown + consumer_secret?: unknown + key_permissions?: unknown + } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const state = typeof body.user_id === 'string' ? body.user_id : null + const consumerKey = typeof body.consumer_key === 'string' ? body.consumer_key : null + const consumerSecret = typeof body.consumer_secret === 'string' ? body.consumer_secret : null + const keyPermissions = + typeof body.key_permissions === 'string' ? body.key_permissions : null + // The state is a UUID we generated; reject anything else before it reaches + // the DB (the column is typed uuid and would error opaquely). + const isUuid = + state !== null && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(state) + if (!isUuid || !consumerKey || !consumerSecret) { + return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }) + } + + const supabase = await createServiceClient() + + const { data: pending, error: findError } = await supabase + .from('woocommerce_connections') + .select('id, company_id, user_id, store_url') + .eq('oauth_state', state) + .eq('status', 'pending') + .single() + + if (findError || !pending) { + log.warn('no pending connection for handshake state', { + hasRow: Boolean(pending), + code: findError?.code, + }) + return NextResponse.json({ error: 'Unknown or expired state' }, { status: 404 }) + } + + const markError = (message: string) => + supabase + .from('woocommerce_connections') + .update({ status: 'error', error_message: message, oauth_state: null }) + .eq('id', pending.id) + .eq('status', 'pending') + + // Authenticity check: the keys must actually work against the store URL the + // user asked to connect. A forged callback with someone else's (or made-up) + // keys fails here and never gets stored. + let storeInfo + try { + storeInfo = await testConnectionAndFetchStoreInfo({ + storeUrl: pending.store_url, + consumerKey, + consumerSecret, + }) + } catch (probeError) { + log.error('credential probe failed during handshake', { + connectionId: pending.id, + message: probeError instanceof Error ? probeError.message : String(probeError), + }) + await markError('Nycklarna kunde inte verifieras mot butiken.') + return NextResponse.json({ error: 'Credential verification failed' }, { status: 502 }) + } + + const { data: activated, error: updateError } = await supabase + .from('woocommerce_connections') + .update({ + consumer_key_encrypted: encryptCredential(consumerKey), + consumer_secret_encrypted: encryptCredential(consumerSecret), + key_permissions: keyPermissions, + store_name: storeInfo.name, + currency: storeInfo.currency, + prices_include_tax: storeInfo.prices_include_tax, + wc_version: storeInfo.wc_version, + status: 'active', + connected_at: new Date().toISOString(), + error_message: null, + oauth_state: null, // Clear to prevent replay + // Feed-only product: connecting the store means fetching its orders, so + // the nightly feed starts on by default; the panel toggle is the opt-out. + transaction_sync_enabled: true, + }) + .eq('id', pending.id) + .eq('status', 'pending') + .select('id, company_id, user_id, store_url') + .single() + + if (updateError || !activated) { + // 23505 = a partial unique index: the store is already actively connected + // (to this or another company), or the company connected in a parallel tab. + const isConflict = updateError?.code === '23505' + log.error('failed to activate connection', { + connectionId: pending.id, + code: updateError?.code, + message: updateError?.message, + }) + await markError( + isConflict + ? 'Butiken är redan ansluten till ett företag.' + : 'Anslutningen kunde inte slutföras.', + ) + return NextResponse.json( + { error: isConflict ? 'Store already connected' : 'Activation failed' }, + { status: isConflict ? 409 : 500 }, + ) + } + + try { + await eventBus.emit({ + type: 'woocommerce.connected', + payload: { + connectionId: activated.id, + storeUrl: activated.store_url, + userId: activated.user_id, + companyId: activated.company_id, + }, + }) + } catch (emitError) { + // Non-fatal: the DB state (source of truth) is already committed. + log.error('failed to emit woocommerce.connected', { + connectionId: activated.id, + message: emitError instanceof Error ? emitError.message : String(emitError), + }) + } + + return NextResponse.json({ success: true }) +} diff --git a/app/api/extensions/woocommerce/orders/cron/route.ts b/app/api/extensions/woocommerce/orders/cron/route.ts new file mode 100644 index 00000000..5f5ccd0d --- /dev/null +++ b/app/api/extensions/woocommerce/orders/cron/route.ts @@ -0,0 +1,138 @@ +import { createClient } from '@supabase/supabase-js' +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 { isWooCommerceConfigured } from '@/extensions/general/woocommerce/lib/credentials' +import { syncWooCommerceOrders } from '@/extensions/general/woocommerce/lib/order-sync' +import type { WooCommerceConnection } from '@/extensions/general/woocommerce/types' + +export const maxDuration = 300 + +/** + * GET /api/extensions/woocommerce/orders/cron + * Nightly order sync for connections that opted in (transaction_sync_enabled): + * imports each connected store's paid orders and refunds into the + * transactions inbox as a bank-style feed on the 1680 cash account. + * + * Read-only against the stores, and it never posts to the journal: rows land + * unbooked; booking stays a human decision. Idempotent via the + * (company_id, external_id) unique index, so overlapping windows and re-runs + * are no-ops. Emits no events, so no ensureInitialized() is needed. + */ +export const GET = withCronContext('cron.woocommerce_order_sync', async (_request, ctx) => { + // Physical routes under app/api/extensions// compile into EVERY build, + // including the core-with-zero-extensions one: the registry (generated from + // extensions.config.json) is what actually switches an extension on. A + // scheduled-but-disabled cron must fail visibly (503) instead of quietly + // doing the work anyway. + loadExtensions() + if (!extensionRegistry.get('woocommerce')) { + ctx.log.warn('woocommerce extension is not enabled; cron refused') + return NextResponse.json( + { error: 'WooCommerce 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 (!isWooCommerceConfigured()) { + return NextResponse.json({ message: 'WooCommerce not configured', processed: 0 }) + } + + const supabase = createClient(supabaseUrl, supabaseServiceKey) + + const { data: connections, error: connError } = await supabase + .from('woocommerce_connections') + .select('*') + .eq('status', 'active') + .eq('transaction_sync_enabled', true) + .order('last_order_synced_at', { ascending: true, nullsFirst: true }) + .limit(50) + + if (connError) { + ctx.log.error('failed to fetch woocommerce connections', connError, { + message: connError.message, + code: connError.code, + }) + return errorResponse(connError, ctx.log, { requestId: ctx.requestId }) + } + + if (!connections || connections.length === 0) { + return NextResponse.json({ + message: 'No connections with transaction sync enabled', + processed: 0, + }) + } + + const startTime = Date.now() + const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration + // Shared with syncWooCommerceOrders: it stops between pages and persists + // its cursor, so a truncated connection resumes next night. + const deadlineMs = startTime + TIME_BUDGET_MS + + const results: Array<{ + connectionId: string + imported: number + duplicates: number + status: 'synced' | 'revoked' | 'error' + }> = [] + + for (const connection of connections as WooCommerceConnection[]) { + if (Date.now() >= deadlineMs) { + ctx.log.info('time budget reached', { processedSoFar: results.length }) + break + } + + if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.woocommerce_sync))) { + ctx.log.info('skip: capability not entitled', { companyId: connection.company_id }) + continue + } + + try { + const summary = await syncWooCommerceOrders(supabase, connection, ctx.log, deadlineMs) + 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, + imported: summary.imported, + duplicates: summary.duplicates, + status: summary.revoked ? 'revoked' : 'synced', + }) + } catch (error) { + ctx.log.error('woocommerce order sync failed for connection', error as Error, { + connectionId: connection.id, + companyId: connection.company_id, + }) + results.push({ + connectionId: connection.id, + imported: 0, + duplicates: 0, + status: 'error', + }) + } + } + + const totalImported = results.reduce((acc, r) => acc + r.imported, 0) + ctx.log.info('woocommerce order sync summary', { + processed: results.length, + totalImported, + failed: results.filter((r) => r.status === 'error').length, + }) + + return NextResponse.json({ processed: results.length, imported: totalImported, results }) +}) diff --git a/app/api/extensions/woocommerce/return/route.ts b/app/api/extensions/woocommerce/return/route.ts new file mode 100644 index 00000000..644721ad --- /dev/null +++ b/app/api/extensions/woocommerce/return/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase/server' +import { loadExtensions } from '@/lib/extensions/loader' +import { extensionRegistry } from '@/lib/extensions/registry' +import { createLogger } from '@/lib/logger' + +const log = createLogger('woocommerce/return') + +/** + * GET /api/extensions/woocommerce/return + * + * Browser leg of the wc-auth handshake: WooCommerce redirects the merchant + * here with ?success=1|0&user_id=. The credentials arrive on + * the separate server-to-server callback (usually before this redirect, but + * ordering is not guaranteed), so on success this route only sends the user + * back to the import page; the panel polls /status until the row is active. + */ +export async function GET(request: Request) { + loadExtensions() + if (!extensionRegistry.get('woocommerce')) { + return NextResponse.json( + { error: 'WooCommerce extension is not enabled', code: 'EXTENSION_DISABLED' }, + { status: 503 }, + ) + } + + const { searchParams } = new URL(request.url) + const success = searchParams.get('success') + const state = searchParams.get('user_id') + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + // The WooCommerce surface lives on the import page; the base already has a + // query, so appended params below must use '&'. + const returnUrl = `${baseUrl}/import?mode=woocommerce` + + if (success === '1') { + return NextResponse.redirect(`${returnUrl}&woocommerce_connected=true`) + } + + // Denied (or malformed): close out the pending row so its state can never + // complete a late callback, then surface the denial to the panel. + if (state) { + try { + const supabase = await createServiceClient() + await supabase + .from('woocommerce_connections') + .update({ + status: 'error', + error_message: 'Anslutningen nekades i butiken.', + oauth_state: null, + }) + .eq('oauth_state', state) + .eq('status', 'pending') + } catch (cleanupError) { + log.error('failed to clean up denied connection', { + message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }) + } + } + + return NextResponse.redirect(`${returnUrl}&woocommerce_error=denied`) +} diff --git a/docker/crontab.hosted b/docker/crontab.hosted index 8dfcc472..70043a4f 100644 --- a/docker/crontab.hosted +++ b/docker/crontab.hosted @@ -29,6 +29,7 @@ 0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron 0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron 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 0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron 0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron 0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron diff --git a/docker/crontab.self-hosted b/docker/crontab.self-hosted index c62e52a5..8599f2e5 100644 --- a/docker/crontab.self-hosted +++ b/docker/crontab.self-hosted @@ -29,6 +29,7 @@ 0 0 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron 0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron 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 0 3 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron 0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron 0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron diff --git a/extensions.config.json b/extensions.config.json index cb7e7d03..0b3852af 100644 --- a/extensions.config.json +++ b/extensions.config.json @@ -1 +1 @@ -{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox"]} +{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce"]} diff --git a/extensions.schema.json b/extensions.schema.json index 685ec04d..353b8384 100644 --- a/extensions.schema.json +++ b/extensions.schema.json @@ -31,7 +31,8 @@ "skatteverket", "cloud-backup", "document-extraction", - "whatsapp-inbox" + "whatsapp-inbox", + "woocommerce" ] }, "description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory." diff --git a/extensions/general/woocommerce/__tests__/api-client.test.ts b/extensions/general/woocommerce/__tests__/api-client.test.ts new file mode 100644 index 00000000..629d788f --- /dev/null +++ b/extensions/general/woocommerce/__tests__/api-client.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { listOrderRefunds, type WooCredentials } from '../lib/api-client' + +const CREDS: WooCredentials = { + storeUrl: 'https://shop.example.se', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function makeRefunds(startId: number, count: number) { + return Array.from({ length: count }, (_, i) => ({ + id: startId + i, + amount: '10.00', + reason: '', + date_created_gmt: '2026-08-01T10:00:00', + })) +} + +const fetchMock = vi.fn() + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('listOrderRefunds', () => { + it('terminates on an empty page, not a short one (hosts may cap per_page)', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(makeRefunds(1, 50))) // short but non-empty + .mockResolvedValueOnce(jsonResponse(makeRefunds(51, 50))) + .mockResolvedValueOnce(jsonResponse([])) + + const refunds = await listOrderRefunds(CREDS, 42) + expect(refunds).toHaveLength(100) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('stops when a host ignoring `page` repeats the same rows', async () => { + // Fresh Response per call: a Response body is single-use. + fetchMock.mockImplementation(async () => jsonResponse(makeRefunds(1, 100))) + + const refunds = await listOrderRefunds(CREDS, 42) + expect(refunds).toHaveLength(100) + // Page 1 full of fresh rows, page 2 identical → zero fresh → stop. + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('throws instead of returning a silently partial list when the page cap is exhausted', async () => { + // Ten full pages of genuinely fresh rows: the cap trips with data still + // flowing, and a partial return would let the sync cursor pass unseen + // refunds. The thrown error routes into the caller's held-cursor retry. + fetchMock.mockImplementation(async (url: string | URL) => { + const page = Number(new URL(String(url)).searchParams.get('page')) + return jsonResponse(makeRefunds(page * 1000, 100)) + }) + + await expect(listOrderRefunds(CREDS, 42)).rejects.toThrow( + /Refund pagination cap exceeded/, + ) + expect(fetchMock).toHaveBeenCalledTimes(10) + }) +}) diff --git a/extensions/general/woocommerce/__tests__/api-routes.test.ts b/extensions/general/woocommerce/__tests__/api-routes.test.ts new file mode 100644 index 00000000..1401738c --- /dev/null +++ b/extensions/general/woocommerce/__tests__/api-routes.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Force the capability gate to run but stub requireCapability so entitlement +// is controlled per test. Mirrors the stripe/enable-banking suites. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, requireCapability: vi.fn().mockResolvedValue(null) } +}) + +// Never let a unit test reach a real WooCommerce host: the credential probe +// is mocked, the pure helpers (normalizeStoreUrl) stay real. +vi.mock('../lib/api-client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, testConnectionAndFetchStoreInfo: vi.fn() } +}) + +// The sync engine has its own suite; here it only needs to be callable. +vi.mock('../lib/order-sync', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, syncWooCommerceOrders: vi.fn() } +}) + +vi.mock('@/lib/auth/api-keys', () => ({ + createServiceClientNoCookies: vi.fn(() => ({ service: true })), +})) + +import { woocommerceExtension } from '../index' +import { requireCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { testConnectionAndFetchStoreInfo } from '../lib/api-client' +import { syncWooCommerceOrders } from '../lib/order-sync' +import { decryptCredential } from '../lib/credentials' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { ExtensionContext } from '@/lib/extensions/types' + +function findRoute(method: string, path: string) { + const route = woocommerceExtension.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/woocommerce/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: 'woocommerce', + 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('woocommerce extension routes', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireCapability).mockResolvedValue(null) + vi.stubEnv('WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY', 'test-key') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + describe('GET /status', () => { + it('returns 401 without a user', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null }) + const res = await findRoute('GET', '/status').handler( + makeRequest('GET'), + makeContext(supabase), + ) + expect(res.status).toBe(401) + }) + + it('prefers the active connection and reports configured', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ + data: [ + { id: 'c2', status: 'revoked' }, + { id: 'c1', status: 'active', store_url: 'https://shop.example.se' }, + ], + }) + 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') + }) + }) + + describe('POST /connect', () => { + it('returns 401 without a user', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null }) + const res = await findRoute('POST', '/connect').handler( + makeRequest('POST', {}), + makeContext(supabase), + ) + expect(res.status).toBe(401) + }) + + it('blocks anonymous (sandbox) users before any external call', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1', is_anonymous: true } }, + error: null, + }) + const res = await findRoute('POST', '/connect').handler( + makeRequest('POST', {}), + makeContext(supabase), + ) + expect(res.status).toBe(403) + const body = await res.json() + expect(body.sandbox_blocked).toBe(true) + }) + + it('returns 403 capability_blocked when not entitled', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: { is_sandbox: false } }) + vi.mocked(requireCapability).mockResolvedValue( + capabilityBlockedResponse(CAPABILITY.woocommerce_sync), + ) + const res = await findRoute('POST', '/connect').handler( + makeRequest('POST', { store_url: 'https://shop.example.se' }), + makeContext(supabase), + ) + expect(res.status).toBe(403) + }) + + it('rejects an invalid or http store URL with 400', async () => { + 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', { store_url: 'http://insecure.se' }), + makeContext(supabase), + ) + expect(res.status).toBe(400) + }) + + it('stages a pending row and returns the wc-auth authorize URL', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: { is_sandbox: false } }) // guardSandbox + enqueue({ data: [] }) // no existing active/pending + enqueue({ data: { id: 'conn-1' } }) // insert pending + const res = await findRoute('POST', '/connect').handler( + makeRequest('POST', { store_url: 'Shop.Example.se/' }), + makeContext(supabase), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.url).toMatch(/^https:\/\/shop\.example\.se\/wc-auth\/v1\/authorize\?/) + expect(body.url).toContain('scope=read') + expect(body.url).toContain( + encodeURIComponent('http://localhost:3000/api/extensions/woocommerce/callback'), + ) + const inserted = findCall('woocommerce_connections', 'insert')?.[0] as Record< + string, + unknown + > + expect(inserted.store_url).toBe('https://shop.example.se') + expect(inserted.status).toBe('pending') + expect(inserted.oauth_state).toBeTruthy() + }) + }) + + describe('POST /manual-connect', () => { + it('rejects missing keys with 400', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: { is_sandbox: false } }) + const res = await findRoute('POST', '/manual-connect').handler( + makeRequest('POST', { store_url: 'https://shop.example.se', consumer_key: 'ck_x' }), + makeContext(supabase), + ) + expect(res.status).toBe(400) + }) + + it('rejects with 400 when the credential probe fails', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: [] }) // no existing connection + vi.mocked(testConnectionAndFetchStoreInfo).mockRejectedValue(new Error('401')) + const res = await findRoute('POST', '/manual-connect').handler( + makeRequest('POST', { + store_url: 'https://shop.example.se', + consumer_key: 'ck_x', + consumer_secret: 'cs_y', + }), + makeContext(supabase), + ) + expect(res.status).toBe(400) + }) + + it('verifies, encrypts and activates on the happy path', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: [] }) // no existing connection + enqueue({ data: { id: 'conn-1', store_url: 'https://shop.example.se' } }) // insert + vi.mocked(testConnectionAndFetchStoreInfo).mockResolvedValue({ + name: 'Testbutiken', + currency: 'SEK', + prices_include_tax: true, + wc_version: '9.9.5', + }) + const ctx = makeContext(supabase) + const res = await findRoute('POST', '/manual-connect').handler( + makeRequest('POST', { + store_url: 'https://shop.example.se', + consumer_key: 'ck_x', + consumer_secret: 'cs_y', + }), + ctx, + ) + expect(res.status).toBe(200) + const inserted = findCall('woocommerce_connections', 'insert')?.[0] as Record< + string, + string + > + expect(inserted.status).toBe('active') + expect(inserted.store_name).toBe('Testbutiken') + // Secrets never stored in plaintext, and they decrypt back. + expect(inserted.consumer_key_encrypted).not.toContain('ck_x') + expect(decryptCredential(inserted.consumer_key_encrypted)).toBe('ck_x') + expect(decryptCredential(inserted.consumer_secret_encrypted)).toBe('cs_y') + expect(ctx.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'woocommerce.connected' }), + ) + }) + }) + + describe('POST /sync', () => { + it('returns 404 without an active connection', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: null }) + const res = await findRoute('POST', '/sync').handler( + makeRequest('POST'), + makeContext(supabase), + ) + expect(res.status).toBe(404) + }) + + it('runs the sync on the service client and returns the summary', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: { id: 'conn-1', status: 'active' } }) + vi.mocked(syncWooCommerceOrders).mockResolvedValue({ + fetched: 3, + refundsFetched: 1, + imported: 4, + duplicates: 0, + errors: 0, + }) + const res = await findRoute('POST', '/sync').handler( + makeRequest('POST'), + makeContext(supabase), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.transactions.imported).toBe(4) + expect(vi.mocked(syncWooCommerceOrders).mock.calls[0][0]).toEqual({ service: true }) + }) + }) + + describe('POST /transaction-sync', () => { + it('rejects a non-boolean enabled with 400', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + const res = await findRoute('POST', '/transaction-sync').handler( + makeRequest('POST', { enabled: 'yes' }), + makeContext(supabase), + ) + expect(res.status).toBe(400) + }) + + it('persists the toggle for the active connection', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: [{ id: 'conn-1' }] }) + const res = await findRoute('POST', '/transaction-sync').handler( + makeRequest('POST', { enabled: false }), + makeContext(supabase), + ) + expect(res.status).toBe(200) + expect(findCall('woocommerce_connections', 'update')?.[0]).toEqual({ + transaction_sync_enabled: false, + }) + }) + }) + + describe('DELETE /disconnect', () => { + it('returns 404 when no connection exists', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: [] }) + const res = await findRoute('DELETE', '/disconnect').handler( + makeRequest('DELETE', {}), + makeContext(supabase), + ) + expect(res.status).toBe(404) + }) + + it('revokes (never deletes) and emits the audit event', async () => { + const { supabase, enqueue, findCall } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ + data: [{ id: 'conn-1', status: 'active', store_url: 'https://shop.example.se' }], + }) + enqueue({ data: null }) // update + const ctx = makeContext(supabase) + const res = await findRoute('DELETE', '/disconnect').handler( + makeRequest('DELETE', {}), + ctx, + ) + expect(res.status).toBe(200) + const updated = findCall('woocommerce_connections', 'update')?.[0] as Record< + string, + unknown + > + expect(updated.status).toBe('revoked') + expect(ctx.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'woocommerce.disconnected' }), + ) + }) + }) +}) diff --git a/extensions/general/woocommerce/__tests__/credentials.test.ts b/extensions/general/woocommerce/__tests__/credentials.test.ts new file mode 100644 index 00000000..b850b039 --- /dev/null +++ b/extensions/general/woocommerce/__tests__/credentials.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + encryptCredential, + decryptCredential, + isWooCommerceConfigured, +} from '../lib/credentials' +import { normalizeStoreUrl } from '../lib/api-client' + +describe('credential codec', () => { + beforeEach(() => { + process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key' + }) + + it('round-trips a consumer key', () => { + const ciphertext = encryptCredential('ck_1234567890abcdef') + expect(ciphertext).not.toContain('ck_1234567890abcdef') + expect(decryptCredential(ciphertext)).toBe('ck_1234567890abcdef') + }) + + it('produces a fresh IV per encryption (no ciphertext reuse)', () => { + expect(encryptCredential('cs_secret')).not.toBe(encryptCredential('cs_secret')) + }) + + it('rejects tampered ciphertext (GCM auth tag)', () => { + const ciphertext = encryptCredential('cs_secret') + const tampered = ciphertext.slice(0, -2) + (ciphertext.endsWith('AA') ? 'BB' : 'AA') + expect(() => decryptCredential(tampered)).toThrow() + }) + + it('requires the env key', () => { + delete process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY + expect(isWooCommerceConfigured()).toBe(false) + expect(() => encryptCredential('x')).toThrow(/WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY/) + }) +}) + +describe('normalizeStoreUrl', () => { + it('normalizes bare domains, case, and trailing slashes', () => { + expect(normalizeStoreUrl('MinButik.se')).toBe('https://minbutik.se') + expect(normalizeStoreUrl('https://Shop.Example.se/')).toBe('https://shop.example.se') + expect(normalizeStoreUrl(' https://shop.example.se ')).toBe('https://shop.example.se') + }) + + it('keeps subdirectory installs', () => { + expect(normalizeStoreUrl('https://example.se/butik/')).toBe('https://example.se/butik') + }) + + it('refuses private and internal hosts (SSRF guard)', () => { + expect(normalizeStoreUrl('https://localhost')).toBeNull() + expect(normalizeStoreUrl('https://foo.localhost')).toBeNull() + expect(normalizeStoreUrl('https://intranet.local')).toBeNull() + expect(normalizeStoreUrl('https://db.internal')).toBeNull() + expect(normalizeStoreUrl('https://127.0.0.1')).toBeNull() + expect(normalizeStoreUrl('https://10.0.0.5')).toBeNull() + expect(normalizeStoreUrl('https://172.20.1.1')).toBeNull() + expect(normalizeStoreUrl('https://192.168.1.10')).toBeNull() + expect(normalizeStoreUrl('https://169.254.169.254')).toBeNull() + expect(normalizeStoreUrl('https://[::1]')).toBeNull() + }) + + it('refuses http, credentials, queries and garbage', () => { + expect(normalizeStoreUrl('http://insecure.se')).toBeNull() + expect(normalizeStoreUrl('https://user:pass@shop.se')).toBeNull() + expect(normalizeStoreUrl('https://shop.se/?a=1')).toBeNull() + expect(normalizeStoreUrl('https://shop.se/#frag')).toBeNull() + expect(normalizeStoreUrl('not a url at all')).toBeNull() + expect(normalizeStoreUrl('')).toBeNull() + }) +}) diff --git a/extensions/general/woocommerce/__tests__/order-sync.test.ts b/extensions/general/woocommerce/__tests__/order-sync.test.ts new file mode 100644 index 00000000..5a8437d8 --- /dev/null +++ b/extensions/general/woocommerce/__tests__/order-sync.test.ts @@ -0,0 +1,450 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' + +const listOrdersPage = vi.fn() +const listOrderRefunds = vi.fn() + +vi.mock('../lib/api-client', () => ({ + listOrdersPage: (...args: unknown[]) => listOrdersPage(...args), + listOrderRefunds: (...args: unknown[]) => listOrderRefunds(...args), + isRevokedCredentialsError: (error: unknown) => + error instanceof Error && error.message === 'REVOKED', + WC_PAGE_SIZE: 100, +})) + +vi.mock('@/lib/transactions/ingest', () => ({ + ingestTransactions: vi.fn(), +})) + +vi.mock('@/lib/cash-accounts/service', () => ({ + ensureManualCashAccount: vi.fn().mockResolvedValue('cash-account-1'), +})) + +vi.mock('@/lib/import/account-sync', () => ({ + syncMappedAccounts: vi.fn().mockResolvedValue({ error: null }), +})) + +import { ingestTransactions } from '@/lib/transactions/ingest' +import { ensureManualCashAccount } from '@/lib/cash-accounts/service' +import { encryptCredential } from '../lib/credentials' +import { + WOOCOMMERCE_IMPORT_SOURCE, + WOOCOMMERCE_LEDGER_ACCOUNT, + mapOrder, + mapRefund, + orderQualifies, + rowBehindLock, + syncWooCommerceOrders, + wooOrderExternalId, + wooRefundExternalId, + wooStoreScope, +} from '../lib/order-sync' +import type { WooCommerceConnection, WooOrder, WooRefund } from '../types' + +process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key' + +function makeConnection(overrides: Partial = {}): WooCommerceConnection { + return { + id: 'conn-1', + company_id: 'company-1', + user_id: 'user-1', + store_url: 'https://shop.example.se', + store_name: 'Testbutiken', + consumer_key_encrypted: encryptCredential('ck_test'), + consumer_secret_encrypted: encryptCredential('cs_test'), + key_permissions: 'read', + status: 'active', + oauth_state: null, + currency: 'SEK', + prices_include_tax: true, + wc_version: '9.9.5', + transaction_sync_enabled: true, + last_order_synced_at: null, + error_message: null, + connected_at: '2026-07-01T00:00:00.000Z', + disconnected_at: null, + created_at: '2026-07-01T00:00:00.000Z', + updated_at: '2026-07-01T00:00:00.000Z', + ...overrides, + } +} + +function makeOrder(overrides: Partial = {}): WooOrder { + return { + id: 1042, + number: '1042', + status: 'processing', + currency: 'sek', + total: '1250.00', + total_tax: '250.00', + prices_include_tax: true, + date_created_gmt: '2026-08-01T09:00:00', + date_modified_gmt: '2026-08-01T09:05:00', + date_paid_gmt: '2026-08-01T09:04:30', + payment_method: 'stripe', + payment_method_title: 'Kortbetalning', + transaction_id: 'pi_abc123', + refunds: [], + ...overrides, + } +} + +/** Minimal chainable supabase mock covering the sync's query patterns. */ +function makeSupabaseMock(options: { lockThrough?: string | null } = {}) { + const updates: Array<{ table: string; values: Record }> = [] + const client = { + from(table: string) { + const builder = { + select: () => builder, + eq: () => builder, + maybeSingle: async () => ({ + data: + table === 'company_settings' + ? { bookkeeping_locked_through: options.lockThrough ?? null } + : null, + error: null, + }), + update: (values: Record) => { + updates.push({ table, values }) + return builder + }, + } + return builder + }, + } + return { client: client as unknown as SupabaseClient, updates } +} + +function cursorUpdates(updates: Array<{ table: string; values: Record }>) { + return updates.filter( + (u) => u.table === 'woocommerce_connections' && 'last_order_synced_at' in u.values, + ) +} + +beforeEach(() => { + vi.clearAllMocks() + // Termination is an empty page; every test starts from a quiet store and + // enqueues its pages with mockResolvedValueOnce. + listOrdersPage.mockResolvedValue([]) + listOrderRefunds.mockResolvedValue([]) + vi.mocked(ingestTransactions).mockResolvedValue({ + imported: 0, + duplicates: 0, + errors: 0, + } as Awaited>) +}) + +describe('frozen external_id formats', () => { + // ⚠️ These assert the exact persisted formats. If this test fails, you are + // about to orphan every previously imported WooCommerce row: do not update + // the expectation without a coordinated backfill (see order-sync.ts). + it('order id format is frozen', () => { + expect(wooOrderExternalId('shop.example.se', 1042)).toBe( + 'woo_shop.example.se_order_1042', + ) + }) + + it('refund id format is frozen', () => { + expect(wooRefundExternalId('shop.example.se', 77)).toBe( + 'woo_shop.example.se_refund_77', + ) + }) + + it('store scope strips exactly the https prefix and keeps host + path', () => { + expect(wooStoreScope('https://shop.example.se')).toBe('shop.example.se') + expect(wooStoreScope('https://example.se/butik')).toBe('example.se/butik') + }) + + it('import source and ledger account are frozen', () => { + expect(WOOCOMMERCE_IMPORT_SOURCE).toBe('woocommerce') + expect(WOOCOMMERCE_LEDGER_ACCOUNT).toBe('1680') + }) +}) + +describe('orderQualifies', () => { + it('requires date_paid and excludes trashed orders', () => { + expect(orderQualifies(makeOrder())).toBe(true) + expect(orderQualifies(makeOrder({ status: 'refunded' }))).toBe(true) + expect(orderQualifies(makeOrder({ date_paid_gmt: null }))).toBe(false) + expect(orderQualifies(makeOrder({ status: 'trash' }))).toBe(false) + }) +}) + +describe('mapOrder', () => { + it('maps a paid order to one gross row dated by date_paid', () => { + const rows = mapOrder('shop.example.se', makeOrder()) + expect(rows).toEqual([ + { + date: '2026-08-01', + description: 'WooCommerce-order #1042', + amount: 1250, + currency: 'SEK', + external_id: 'woo_shop.example.se_order_1042', + import_source: 'woocommerce', + reference: 'pi_abc123', + }, + ]) + }) + + it('rounds string money to two decimals', () => { + const rows = mapOrder('s', makeOrder({ total: '99.995' })) + expect(rows[0].amount).toBe(100) + }) + + it('skips unpaid, trashed, zero-total and unparseable orders', () => { + expect(mapOrder('s', makeOrder({ date_paid_gmt: null }))).toEqual([]) + expect(mapOrder('s', makeOrder({ status: 'trash' }))).toEqual([]) + expect(mapOrder('s', makeOrder({ total: '0.00' }))).toEqual([]) + expect(mapOrder('s', makeOrder({ total: 'not-a-number' }))).toEqual([]) + }) +}) + +describe('mapRefund', () => { + const refund: WooRefund = { + id: 77, + amount: '250.00', + reason: 'Retur', + date_created_gmt: '2026-08-03T10:00:00', + } + + it('maps a refund to one negative row dated by the refund date', () => { + const rows = mapRefund('shop.example.se', makeOrder(), refund) + expect(rows).toEqual([ + { + date: '2026-08-03', + description: 'WooCommerce-återbetalning order #1042', + amount: -250, + currency: 'SEK', + external_id: 'woo_shop.example.se_refund_77', + import_source: 'woocommerce', + reference: null, + }, + ]) + }) + + it('skips zero-amount refunds', () => { + expect(mapRefund('s', makeOrder(), { ...refund, amount: '0' })).toEqual([]) + }) +}) + +describe('rowBehindLock', () => { + it('drops dates on/before the lock and keeps later ones', () => { + expect(rowBehindLock('2026-06-30', '2026-06-30')).toBe(true) + expect(rowBehindLock('2026-06-15', '2026-06-30')).toBe(true) + expect(rowBehindLock('2026-07-01', '2026-06-30')).toBe(false) + expect(rowBehindLock('2026-06-15', null)).toBe(false) + }) +}) + +describe('syncWooCommerceOrders', () => { + it('ingests order and refund rows against the 1680 cash account and advances the cursor', async () => { + const { client, updates } = makeSupabaseMock() + const order = makeOrder({ + refunds: [{ id: 77, reason: 'Retur', total: '-250.00' }], + }) + listOrdersPage.mockResolvedValueOnce([order]) + listOrderRefunds.mockResolvedValueOnce([ + { id: 77, amount: '250.00', reason: 'Retur', date_created_gmt: '2026-08-03T10:00:00' }, + ]) + vi.mocked(ingestTransactions).mockResolvedValueOnce({ + imported: 2, + duplicates: 0, + errors: 0, + } as Awaited>) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary).toMatchObject({ fetched: 1, refundsFetched: 1, imported: 2, duplicates: 0 }) + expect(ensureManualCashAccount).toHaveBeenCalledWith( + client, + 'company-1', + '1680', + 'SEK', + 'WooCommerce-saldo', + ) + expect(ingestTransactions).toHaveBeenCalledTimes(1) + const [, companyId, userId, rows, ingestOptions] = + vi.mocked(ingestTransactions).mock.calls[0] + expect(companyId).toBe('company-1') + expect(userId).toBe('user-1') + expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([ + 'woo_shop.example.se_order_1042', + 'woo_shop.example.se_refund_77', + ]) + expect(ingestOptions).toEqual({ settlementAccount: '1680', skipAutoCategorization: true }) + + // Cursor persisted from the page's max date_modified_gmt, branded UTC, + // and any stale error_message is cleared on progress. + const cursors = cursorUpdates(updates) + expect(cursors).toHaveLength(1) + expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z') + expect(cursors[0].values.error_message).toBeNull() + + // Second list call proves cursor pagination: modified_after advanced to + // the last row's timestamp, page reset to 1, terminated by the empty page. + expect(listOrdersPage).toHaveBeenCalledTimes(2) + expect(listOrdersPage.mock.calls[1][1]).toEqual({ + modifiedAfter: '2026-08-01T09:05:00.000Z', + page: 1, + }) + }) + + it('drops rows dated on/before the bookkeeping lock on every run', async () => { + const { client, updates } = makeSupabaseMock({ lockThrough: '2026-08-02' }) + // Order paid 2026-08-01 (behind lock), refund created 2026-08-03 (after). + const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] }) + listOrdersPage.mockResolvedValueOnce([order]) + listOrderRefunds.mockResolvedValueOnce([ + { id: 77, amount: '250.00', reason: '', date_created_gmt: '2026-08-03T10:00:00' }, + ]) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary.skippedLocked).toBe(1) + const [, , , rows] = vi.mocked(ingestTransactions).mock.calls[0] + expect((rows as Array<{ external_id: string }>).map((r) => r.external_id)).toEqual([ + 'woo_shop.example.se_refund_77', + ]) + // The cursor still advances: the drop is by design, not a failure. + expect(cursorUpdates(updates)).toHaveLength(1) + }) + + it('holds the cursor below an order whose refund fetch failed', async () => { + const { client, updates } = makeSupabaseMock() + const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] }) + listOrdersPage.mockResolvedValueOnce([order]) + listOrderRefunds.mockRejectedValueOnce(new Error('502 from host')) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary.errors).toBe(1) + // date_modified 09:05:00 minus 1s: the next run re-lists this order. + const cursors = cursorUpdates(updates) + expect(cursors).toHaveLength(1) + expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z') + }) + + it('pages through a full same-timestamp tie by offset, then resumes cursor pagination', async () => { + const { client } = makeSupabaseMock() + const tie = Array.from({ length: 100 }, (_, i) => + makeOrder({ id: i + 1, number: String(i + 1) }), + ) + const later = makeOrder({ + id: 500, + number: '500', + date_modified_gmt: '2026-08-01T10:00:00', + }) + listOrdersPage.mockResolvedValueOnce(tie).mockResolvedValueOnce([later]) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary.fetched).toBe(101) + expect(listOrdersPage).toHaveBeenCalledTimes(3) + const [firstArgs, secondArgs, thirdArgs] = listOrdersPage.mock.calls.map((c) => c[1]) + // Full page, all one timestamp: same cursor, next offset page. + expect(secondArgs).toEqual({ modifiedAfter: firstArgs.modifiedAfter, page: 2 }) + // Progress within the tie page: cursor moves, offset resets. + expect(thirdArgs).toEqual({ modifiedAfter: '2026-08-01T10:00:00.000Z', page: 1 }) + }) + + it('falls back to the first order currency when store settings were unreadable', async () => { + const { client } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([makeOrder({ currency: 'eur' })]) + + await syncWooCommerceOrders(client, makeConnection({ currency: null })) + + expect(ensureManualCashAccount).toHaveBeenCalledWith( + client, + 'company-1', + '1680', + 'EUR', + 'WooCommerce-saldo', + ) + }) + + it('surfaces a cash-account failure on the connection instead of failing silently', async () => { + const { client, updates } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([makeOrder()]) + vi.mocked(ensureManualCashAccount).mockRejectedValueOnce( + new Error('cash account 1680 exists with currency EUR'), + ) + + await expect(syncWooCommerceOrders(client, makeConnection())).rejects.toThrow( + /currency EUR/, + ) + const errorUpdate = updates.find( + (u) => u.table === 'woocommerce_connections' && 'error_message' in u.values, + ) + expect(errorUpdate?.values.error_message).toMatch(/1680/) + }) + + it('counts an unparseable order total as an error without stalling the cursor', async () => { + const { client, updates } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([makeOrder({ total: 'not-a-number' })]) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary.errors).toBe(1) + expect(ingestTransactions).not.toHaveBeenCalled() + // Deliberate: a permanently corrupt total must not stall the feed. + expect(cursorUpdates(updates)).toHaveLength(1) + }) + + it('does nothing for a connection without credentials or not active', async () => { + const { client } = makeSupabaseMock() + const summary = await syncWooCommerceOrders( + client, + makeConnection({ consumer_key_encrypted: null }), + ) + expect(summary.fetched).toBe(0) + expect(listOrdersPage).not.toHaveBeenCalled() + + const revokedSummary = await syncWooCommerceOrders( + client, + makeConnection({ status: 'revoked' }), + ) + expect(revokedSummary.fetched).toBe(0) + }) + + it('flips the connection to revoked when the store rejects the credentials', async () => { + const { client, updates } = makeSupabaseMock() + listOrdersPage.mockRejectedValueOnce(new Error('REVOKED')) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary.revoked).toBe(true) + const revokeUpdate = updates.find((u) => u.table === 'woocommerce_connections') + expect(revokeUpdate?.values.status).toBe('revoked') + }) + + it('stops before fetching when the deadline is already reached', async () => { + const { client } = makeSupabaseMock() + const summary = await syncWooCommerceOrders( + client, + makeConnection(), + undefined, + Date.now() - 1, + ) + expect(summary.deadlineReached).toBe(true) + expect(listOrdersPage).not.toHaveBeenCalled() + }) + + it('skips remaining refund fetches on deadline and holds the cursor for them', async () => { + const { client, updates } = makeSupabaseMock() + const refunded = makeOrder({ refunds: [{ id: 77, reason: '', total: '-1.00' }] }) + // The list call itself consumes the whole budget, so the deadline is + // comfortably alive at the loop check and expired by the refund loop. + const deadlineMs = Date.now() + 200 + listOrdersPage.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)) + return [refunded] + }) + const summary = await syncWooCommerceOrders(client, makeConnection(), undefined, deadlineMs) + + expect(summary.deadlineReached).toBe(true) + expect(listOrderRefunds).not.toHaveBeenCalled() + const cursors = cursorUpdates(updates) + expect(cursors).toHaveLength(1) + expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z') + }) +}) diff --git a/extensions/general/woocommerce/__tests__/settings-actions.test.ts b/extensions/general/woocommerce/__tests__/settings-actions.test.ts new file mode 100644 index 00000000..82d46a02 --- /dev/null +++ b/extensions/general/woocommerce/__tests__/settings-actions.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { syncSummary } from '../lib/settings-actions' + +describe('syncSummary', () => { + it('maps an unreadable body to unknown', () => { + expect(syncSummary(null)).toEqual({ reason: 'unknown' }) + expect(syncSummary({})).toEqual({ reason: 'unknown' }) + expect(syncSummary({ transactions: {} })).toEqual({ reason: 'unknown' }) + }) + + it('reports revoked before anything else', () => { + expect(syncSummary({ transactions: { revoked: true, fetched: 5 } })).toEqual({ + reason: 'revoked', + }) + }) + + it('reports a deadline-truncated run as partial, never as complete', () => { + expect( + syncSummary({ transactions: { deadlineReached: true, fetched: 120, imported: 80 } }), + ).toEqual({ reason: 'partial', values: { fetched: 120, imported: 80, errors: 0 } }) + // Even a zero-fetch truncated run is partial, not "empty": the window was + // not exhausted, so claiming the store had nothing would be false. + expect( + syncSummary({ transactions: { deadlineReached: true, fetched: 0, imported: 0 } }), + ).toEqual({ reason: 'partial', values: { fetched: 0, imported: 0, errors: 0 } }) + }) + + it('a truncated run with row errors keeps both facts', () => { + expect( + syncSummary({ + transactions: { deadlineReached: true, fetched: 50, imported: 40, errors: 3 }, + }), + ).toEqual({ reason: 'partial', values: { fetched: 50, imported: 40, errors: 3 } }) + }) + + it('distinguishes empty, errors and feed outcomes', () => { + expect(syncSummary({ transactions: { fetched: 0 } })).toEqual({ reason: 'empty' }) + expect( + syncSummary({ transactions: { fetched: 3, imported: 2, errors: 1 } }), + ).toEqual({ reason: 'errors', values: { fetched: 3, imported: 2, errors: 1 } }) + expect(syncSummary({ transactions: { fetched: 3, imported: 3 } })).toEqual({ + reason: 'feed', + values: { fetched: 3, imported: 3 }, + }) + }) +}) diff --git a/extensions/general/woocommerce/api-routes.ts b/extensions/general/woocommerce/api-routes.ts new file mode 100644 index 00000000..ecfad298 --- /dev/null +++ b/extensions/general/woocommerce/api-routes.ts @@ -0,0 +1,527 @@ +import { NextResponse } from 'next/server' +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 { isWooCommerceConfigured, encryptCredential } from './lib/credentials' +import { normalizeStoreUrl, testConnectionAndFetchStoreInfo } from './lib/api-client' +import { buildAuthorizeUrl } from './lib/connect' +import { syncWooCommerceOrders } from './lib/order-sync' +import type { WooCommerceConnection, WooCommerceStatusResponse } from './types' + +// Per-user limits: connect/disconnect start outward-facing handshakes, sync +// hits the merchant's WooCommerce host. +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 } + +// A pending row younger than this blocks a second connect attempt so a +// double-click cannot start two handshake round-trips (only one state would +// survive, stranding the other at the callback). +const PENDING_FRESH_MS = 60_000 + +const NOT_CONFIGURED_MESSAGE = + 'WooCommerce-integrationen är inte konfigurerad på den här installationen.' + +/** Columns safe to hand to the browser: never the encrypted credentials. */ +const STATUS_COLUMNS = + 'id, status, store_url, store_name, currency, error_message, connected_at, transaction_sync_enabled, last_order_synced_at' + +type AuthedContext = { + supabase: ExtensionContext['supabase'] + userId: string + isAnonymous: boolean + companyId: string +} + +/** Shared auth preamble: cookie user + company context, or an error response. */ +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, + } +} + +/** + * Guards shared by both connect paths: sandbox users never reach external + * stores (same doctrine as Stripe connect), and the feed is a paid + * capability (woocommerce_sync). + */ +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.woocommerce_sync) +} + +/** + * Existing-connection preflight for both connect paths: 409 on an active + * connection or a fresh pending handshake, and supersede stale pendings so + * their oauth_state can never complete a late callback. + */ +async function blockOrSupersedeExisting(auth: AuthedContext): Promise { + const { data: existing } = await auth.supabase + .from('woocommerce_connections') + .select('id, status, created_at') + .eq('company_id', auth.companyId) + .in('status', ['active', 'pending']) + .order('created_at', { ascending: false }) + + if (existing?.some((c) => c.status === 'active')) { + return NextResponse.json( + { error: 'Företaget har redan en ansluten WooCommerce-butik. Koppla från den först.' }, + { status: 409 }, + ) + } + const pending = existing?.filter((c) => c.status === 'pending') ?? [] + const freshPending = pending.find( + (c) => Date.now() - new Date(c.created_at).getTime() < PENDING_FRESH_MS, + ) + if (freshPending) { + return NextResponse.json( + { error: 'En anslutning pågår redan. Vänta och försök igen.' }, + { status: 409 }, + ) + } + if (pending.length > 0) { + await auth.supabase + .from('woocommerce_connections') + .update({ + status: 'error', + error_message: 'Superseded by new connection attempt', + oauth_state: null, + }) + .eq('company_id', auth.companyId) + .eq('status', 'pending') + } + return null +} + +export const woocommerceApiRoutes: ApiRouteDefinition[] = [ + { + method: 'GET', + path: '/status', + handler: async (_request: Request, ctx?: ExtensionContext) => { + const auth = await requireUserAndCompany(ctx) + if (auth instanceof NextResponse) return auth + + // Prefer the active connection; otherwise surface the most recent row + // so the panel can show pending/error/revoked states. + const { data: rows } = await auth.supabase + .from('woocommerce_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: WooCommerceStatusResponse = { + configured: isWooCommerceConfigured(), + 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: 'woocommerce:connect', + identifier: auth.userId, + ...RATE_LIMIT_CONNECT, + }) + if (!rl.ok) return rl.response! + + if (!isWooCommerceConfigured()) { + return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 }) + } + + const body = (await request.json().catch(() => ({}))) as { store_url?: unknown } + const storeUrl = + typeof body.store_url === 'string' ? normalizeStoreUrl(body.store_url) : null + if (!storeUrl) { + return NextResponse.json( + { error: 'Ange butikens adress som en giltig https-URL.' }, + { status: 400 }, + ) + } + + const conflict = await blockOrSupersedeExisting(auth) + if (conflict) return conflict + + // Persist the CSRF state BEFORE handing the user to the store: the + // callback locates the row by oauth_state alone, so the row must exist + // before the store can ever POST back with that state. + const oauthState = crypto.randomUUID() + const { data: created, error: insertError } = await auth.supabase + .from('woocommerce_connections') + .insert({ + company_id: auth.companyId, + user_id: auth.userId, + store_url: storeUrl, + status: 'pending', + oauth_state: oauthState, + }) + .select('id') + .single() + + if (insertError || !created) { + log.error('[woocommerce] Failed to stage 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 }, + ) + } + + log.info('[woocommerce] Starting wc-auth handshake', { + connection_id: created.id, + company_id: auth.companyId, + }) + return NextResponse.json({ url: buildAuthorizeUrl(storeUrl, oauthState) }) + }, + }, + { + method: 'POST', + path: '/manual-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: 'woocommerce:connect', + identifier: auth.userId, + ...RATE_LIMIT_CONNECT, + }) + if (!rl.ok) return rl.response! + + if (!isWooCommerceConfigured()) { + return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 }) + } + + const body = (await request.json().catch(() => ({}))) as { + store_url?: unknown + consumer_key?: unknown + consumer_secret?: unknown + } + const storeUrl = + typeof body.store_url === 'string' ? normalizeStoreUrl(body.store_url) : null + const consumerKey = + typeof body.consumer_key === 'string' ? body.consumer_key.trim() : '' + const consumerSecret = + typeof body.consumer_secret === 'string' ? body.consumer_secret.trim() : '' + if (!storeUrl) { + return NextResponse.json( + { error: 'Ange butikens adress som en giltig https-URL.' }, + { status: 400 }, + ) + } + if (!consumerKey || !consumerSecret) { + return NextResponse.json( + { error: 'Ange både konsumentnyckel och konsumenthemlighet.' }, + { status: 400 }, + ) + } + + const conflict = await blockOrSupersedeExisting(auth) + if (conflict) return conflict + + // Verify before storing: a typo'd key must fail here, not at 03:45. + let storeInfo + try { + storeInfo = await testConnectionAndFetchStoreInfo({ + storeUrl, + consumerKey, + consumerSecret, + }) + } catch (probeError) { + log.warn('[woocommerce] Manual credential probe failed', { + companyId: auth.companyId, + message: probeError instanceof Error ? probeError.message : String(probeError), + }) + return NextResponse.json( + { + error: + 'Kunde inte ansluta till butiken med de angivna nycklarna. Kontrollera adressen och att nyckeln har läsbehörighet.', + }, + { status: 400 }, + ) + } + + const { data: created, error: insertError } = await auth.supabase + .from('woocommerce_connections') + .insert({ + company_id: auth.companyId, + user_id: auth.userId, + store_url: storeUrl, + store_name: storeInfo.name, + currency: storeInfo.currency, + prices_include_tax: storeInfo.prices_include_tax, + wc_version: storeInfo.wc_version, + consumer_key_encrypted: encryptCredential(consumerKey), + consumer_secret_encrypted: encryptCredential(consumerSecret), + status: 'active', + connected_at: new Date().toISOString(), + transaction_sync_enabled: true, + }) + .select('id, store_url') + .single() + + if (insertError || !created) { + const isConflict = insertError?.code === '23505' + log.error('[woocommerce] Failed to create manual connection', { + message: insertError?.message, + code: insertError?.code, + companyId: auth.companyId, + }) + return NextResponse.json( + { + error: isConflict + ? 'Butiken är redan ansluten till ett företag.' + : 'Kunde inte spara anslutningen. Försök igen.', + }, + { status: isConflict ? 409 : 500 }, + ) + } + + if (ctx?.emit) { + try { + await ctx.emit({ + type: 'woocommerce.connected', + payload: { + connectionId: created.id, + storeUrl: created.store_url, + userId: auth.userId, + companyId: auth.companyId, + }, + }) + } catch { + // Audit event failure must not block the connect itself. + } + } + + return NextResponse.json({ success: true, connection_id: created.id }) + }, + }, + { + 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.woocommerce_sync, + ) + if (capabilityBlocked) return capabilityBlocked + + const rl = await checkRateLimit({ + prefix: 'woocommerce:sync', + identifier: auth.userId, + ...RATE_LIMIT_SYNC, + }) + if (!rl.ok) return rl.response! + + // Membership-scoped lookup via the user client; the sync itself runs on + // the service client (cursor updates and ingest are service paths). The + // manual button ignores transaction_sync_enabled (that flag gates the + // nightly cron): pressing it IS the opt-in. + const { data: connection } = await auth.supabase + .from('woocommerce_connections') + .select('*') + .eq('company_id', auth.companyId) + .eq('status', 'active') + .maybeSingle() + + if (!connection) { + return NextResponse.json( + { error: 'Ingen ansluten WooCommerce-butik.' }, + { status: 404 }, + ) + } + + try { + const serviceClient = createServiceClientNoCookies() + // Bounded like the cron: without a deadline a huge first sync against + // a slow host would be killed at the dispatcher's maxDuration with no + // cursor persisted; with one it stops cleanly, reports a partial sync + // and resumes where it stopped on the next press. + const summary = await syncWooCommerceOrders( + serviceClient, + connection as WooCommerceConnection, + undefined, + Date.now() + 240_000, + ) + return NextResponse.json({ success: true, transactions: summary }) + } catch (error) { + log.error('[woocommerce] 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.woocommerce_sync, + ) + if (capabilityBlocked) return capabilityBlocked + + const rl = await checkRateLimit({ + prefix: 'woocommerce: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('woocommerce_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: 'Ingen ansluten WooCommerce-butik.' }, + { 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: 'woocommerce: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('woocommerce_connections') + .select('id, status, store_url') + .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 }) + } + + // There is no remote revoke API: the consumer key lives in the store's + // wp-admin and only the merchant can delete it there. We drop our copy + // of the credentials outright (nothing reads them after revoke, and a + // reconnect inserts a fresh row); the audit row keeps store_url and the + // connect/disconnect timestamps. The panel tells the user to remove the + // key in WooCommerce as well. + const { error: updateError } = await auth.supabase + .from('woocommerce_connections') + .update({ + status: 'revoked', + oauth_state: null, + consumer_key_encrypted: null, + consumer_secret_encrypted: null, + disconnected_at: new Date().toISOString(), + }) + .eq('id', connection.id) + .eq('company_id', auth.companyId) + + if (updateError) { + log.error('[woocommerce] 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: 'woocommerce.disconnected', + payload: { + connectionId: connection.id, + storeUrl: connection.store_url ?? null, + reason: 'user', + userId: auth.userId, + companyId: auth.companyId, + }, + }) + } catch { + // Audit event failure must not block the disconnect itself. + } + } + + return NextResponse.json({ success: true }) + }, + }, +] diff --git a/extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx b/extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx new file mode 100644 index 00000000..6e5a9bb2 --- /dev/null +++ b/extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx @@ -0,0 +1,506 @@ +'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 { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +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 { KeyRound, Link2, Loader2, RefreshCw, ShoppingCart, Unlink } from 'lucide-react' +import { + wooRequest, + syncSummary, + WOO_CONNECT_TIMEOUT_MS, + WOO_SYNC_TIMEOUT_MS, + type WooSyncPayload, +} from '../lib/settings-actions' +import type { WooCommerceStatusResponse } from '../types' + +type ConnectionInfo = NonNullable + +const STATUS_VARIANT: Record = { + active: 'success', + pending: 'secondary', + revoked: 'warning', + error: 'destructive', +} + +export default function WooCommerceSettingsPanel() { + const t = useTranslations('woocommerce') + 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 [storeUrl, setStoreUrl] = useState('') + const [manualMode, setManualMode] = useState(false) + const [consumerKey, setConsumerKey] = useState('') + const [consumerSecret, setConsumerSecret] = useState('') + 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 () => { + // A failed status read must never render as "not configured" (see the + // Stripe panel: that copy sends the user to an administrator for nothing). + const result = await wooRequest({ + url: '/api/extensions/ext/woocommerce/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]) + + function retryLoadStatus() { + setLoading(true) + void loadStatus() + } + + // Consume the one-shot handshake bounce-back params off the render path, + // mirroring the Stripe/banking sections. + useEffect(() => { + const connected = searchParams.get('woocommerce_connected') + const error = searchParams.get('woocommerce_error') + if (!connected && !error) return + + let cancelled = false + queueMicrotask(() => { + if (cancelled) return + if (connected) { + toast({ title: t('connected_toast_title'), description: t('connected_toast_description') }) + } else if (error) { + const message = error === 'denied' ? t('error_denied') : t('error_generic') + toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' }) + } + router.replace('/import?mode=woocommerce') + }) + return () => { cancelled = true } + }, [searchParams, router, toast, t]) + + async function handleConnect() { + if (connecting) return + setConnecting(true) + try { + const result = await wooRequest<{ url?: string }>({ + url: '/api/extensions/ext/woocommerce/connect', + body: { store_url: storeUrl }, + locale, + timeoutMs: WOO_CONNECT_TIMEOUT_MS, + }) + if (!result.ok || !result.data?.url) { + toast({ + title: t('connect_failed_title'), + description: result.ok ? t('error_generic') : failureDescription(result, failureCopy), + variant: 'destructive', + }) + return + } + window.location.href = result.data.url + } finally { + setConnecting(false) + } + } + + async function handleManualConnect() { + if (connecting) return + setConnecting(true) + try { + const result = await wooRequest({ + url: '/api/extensions/ext/woocommerce/manual-connect', + body: { + store_url: storeUrl, + consumer_key: consumerKey, + consumer_secret: consumerSecret, + }, + locale, + timeoutMs: WOO_CONNECT_TIMEOUT_MS, + }) + if (!result.ok) { + toast({ + title: t('connect_failed_title'), + description: failureDescription(result, failureCopy), + variant: 'destructive', + }) + return + } + toast({ title: t('connected_toast_title'), description: t('connected_toast_description') }) + setConsumerKey('') + setConsumerSecret('') + setManualMode(false) + await loadStatus() + } finally { + setConnecting(false) + } + } + + async function handleSyncNow() { + if (syncing) return + setSyncing(true) + try { + const result = await wooRequest({ + url: '/api/extensions/ext/woocommerce/sync', + locale, + timeoutMs: WOO_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 wooRequest({ + url: '/api/extensions/ext/woocommerce/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 wooRequest({ + url: '/api/extensions/ext/woocommerce/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 + } + // The key still exists in the store's wp-admin; only the merchant can + // delete it there, so the toast says so. + 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 showConnectForm = !connection || !isActive + + return ( + + + {t('title')} + + +

{t('description')}

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

{connection.store_url}

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

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

+ )} + {connection.status === 'pending' && ( +

{t('pending_note')}

+ )} + {connection.error_message && ( + // Shown for active connections too: a sync that cannot run + // (e.g. cash-account currency conflict) must not hide + // behind a healthy-looking "Ansluten" badge. +

{connection.error_message}

+ )} +
+
+ {isActive && ( + confirmDisconnect ? ( +
+ + +
+ ) : ( +
+ + +
+ ) + )} +
+ )} + + {showConnectForm && ( +
+
+ + setStoreUrl(e.target.value)} + disabled={connecting} + /> +
+ + {manualMode ? ( +
+

{t('manual_hint')}

+
+ + setConsumerKey(e.target.value)} + disabled={connecting} + /> +
+
+ + setConsumerSecret(e.target.value)} + disabled={connecting} + /> +
+
+ + +
+
+ ) : ( +
+
+ + +
+

{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/woocommerce/index.ts b/extensions/general/woocommerce/index.ts new file mode 100644 index 00000000..38992d1b --- /dev/null +++ b/extensions/general/woocommerce/index.ts @@ -0,0 +1,31 @@ +import type { Extension } from '@/lib/extensions/types' +import { woocommerceApiRoutes } from './api-routes' + +/** + * WooCommerce extension + * + * Connects a company's WooCommerce store via the wc-auth key handshake (or + * manual key entry) and imports the store's paid orders and refunds into the + * transactions inbox as a bank-style feed on the 1680 cash account. Feed-only + * (same doctrine as the Stripe feed, decision 2026-08-06): nothing is + * auto-booked, and payment-gateway fees/payouts are out of scope: core wc/v3 + * does not expose them. + * + * Required environment variables: + * - WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY (at-rest key for consumer key/secret) + */ +export const woocommerceExtension: Extension = { + id: 'woocommerce', + name: 'WooCommerce', + version: '1.0.0', + sector: 'general', + + settingsPanel: { + label: 'WooCommerce', + path: '/import?mode=woocommerce', + }, + + apiRoutes: woocommerceApiRoutes, +} + +export default woocommerceExtension diff --git a/extensions/general/woocommerce/lib/api-client.ts b/extensions/general/woocommerce/lib/api-client.ts new file mode 100644 index 00000000..73b2fa96 --- /dev/null +++ b/extensions/general/woocommerce/lib/api-client.ts @@ -0,0 +1,319 @@ +import type { WooOrder, WooRefund, WooStoreInfo } from '../types' + +/** + * Minimal WooCommerce REST API (wc/v3) client for the order feed. + * + * Auth is HTTP Basic (consumer key as username, secret as password) over + * HTTPS only. Some hosts (Apache CGI, security plugins) strip the + * Authorization header, so a 401 is retried once with the documented + * query-string credential fallback; that fallback is why plain-http stores + * are refused outright (keys in a cleartext URL are a credentials leak). + * + * Typical WooCommerce hosts are slow shared PHP boxes: requests run + * sequentially, pages are capped at 100 rows, and 429/5xx responses get a + * short exponential backoff before the error is surfaced. + */ + +const REQUEST_TIMEOUT_MS = 30_000 +const RETRYABLE_STATUS = new Set([429, 502, 503, 504]) +const RETRY_DELAYS_MS = [1_000, 3_000] +/** wc/v3 hard maximum for per_page. */ +export const WC_PAGE_SIZE = 100 + +export interface WooCredentials { + storeUrl: string + consumerKey: string + consumerSecret: string +} + +export class WooCommerceApiError extends Error { + constructor( + message: string, + /** HTTP status, or 0 for network-level failures. */ + readonly status: number, + /** WooCommerce error code (e.g. woocommerce_rest_cannot_view), if any. */ + readonly wooCode: string | null = null, + ) { + super(message) + this.name = 'WooCommerceApiError' + } +} + +/** + * Whether an API error means the credentials themselves are dead (key deleted + * or demoted in wp-admin), as opposed to a transient failure. Used to flip a + * connection to status 'revoked' so the UI offers a reconnect instead of the + * cron retrying forever. + */ +export function isRevokedCredentialsError(error: unknown): boolean { + if (!(error instanceof WooCommerceApiError)) return false + return error.status === 401 || error.status === 403 +} + +/** + * Hostnames the server must never fetch: the store URL is user input that we + * probe server-side, so loopback/link-local/private ranges and internal + * naming conventions are refused outright (SSRF guard). Hostname-level only: + * a public DNS name resolving to a private address is not caught here, which + * matches the app's other outbound-URL surfaces. + */ +function isDisallowedHost(hostname: string): boolean { + const h = hostname.toLowerCase() + if (h === 'localhost' || h.endsWith('.localhost')) return true + if (h.endsWith('.local') || h.endsWith('.internal')) return true + // IPv6 literals (URL.hostname strips the brackets): never a real store. + if (h.includes(':')) return true + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h) + if (v4) { + const a = Number(v4[1]) + const b = Number(v4[2]) + if (a === 0 || a === 10 || a === 127) return true + if (a === 169 && b === 254) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + } + return false +} + +/** + * Normalize and validate a user-entered store URL to an https origin plus + * optional subdirectory path (WordPress installs under a path are common), + * lowercased host, no trailing slash, no query/fragment/credentials, and no + * private/internal hosts. Returns null for anything invalid, including + * plain http. + */ +export function normalizeStoreUrl(input: string): string | null { + const trimmed = input.trim() + if (!trimmed) return null + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + let url: URL + try { + url = new URL(withScheme) + } catch { + return null + } + if (url.protocol !== 'https:') return null + if (url.username || url.password || url.search || url.hash) return null + if (isDisallowedHost(url.hostname)) return null + const path = url.pathname.replace(/\/+$/, '') + return `https://${url.host.toLowerCase()}${path}` +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function buildUrl( + creds: WooCredentials, + path: string, + params: Record, + credentialsInQuery: boolean, +): string { + const url = new URL(`${creds.storeUrl}/wp-json/wc/v3${path}`) + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) + if (credentialsInQuery) { + url.searchParams.set('consumer_key', creds.consumerKey) + url.searchParams.set('consumer_secret', creds.consumerSecret) + } + return url.toString() +} + +async function requestOnce( + creds: WooCredentials, + path: string, + params: Record, + credentialsInQuery: boolean, +): Promise { + const headers: Record = { Accept: 'application/json' } + if (!credentialsInQuery) { + const basic = Buffer.from(`${creds.consumerKey}:${creds.consumerSecret}`).toString('base64') + headers.Authorization = `Basic ${basic}` + } + return fetch(buildUrl(creds, path, params, credentialsInQuery), { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) +} + +async function parseError(response: Response): Promise { + let wooCode: string | null = null + let detail = '' + try { + const body = (await response.json()) as { code?: string; message?: string } + wooCode = body.code ?? null + detail = body.message ?? '' + } catch { + // Non-JSON error body (host error page); the status is enough. + } + return new WooCommerceApiError( + `WooCommerce API ${response.status}${detail ? `: ${detail}` : ''}`, + response.status, + wooCode, + ) +} + +/** + * GET a wc/v3 path. Retries the header-stripped-auth case (401 → query-string + * credentials) once, and 429/5xx with a short backoff. + */ +export async function wcGet( + creds: WooCredentials, + path: string, + params: Record = {}, +): Promise { + let credentialsInQuery = false + let lastError: unknown + for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { + let response: Response + try { + response = await requestOnce(creds, path, params, credentialsInQuery) + } catch (err) { + // Network/timeout errors: retry on the same backoff schedule. + lastError = new WooCommerceApiError( + `WooCommerce 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) return (await response.json()) as T + + if (response.status === 401 && !credentialsInQuery) { + // Host may be stripping the Authorization header; the documented + // fallback is credentials in the query string (HTTPS enforced upstream). + credentialsInQuery = true + lastError = await parseError(response) + continue + } + if (RETRYABLE_STATUS.has(response.status) && attempt < RETRY_DELAYS_MS.length) { + lastError = await parseError(response) + await sleep(RETRY_DELAYS_MS[attempt]) + continue + } + throw await parseError(response) + } + throw lastError instanceof Error + ? lastError + : new WooCommerceApiError('WooCommerce request failed', 0) +} + +export interface ListOrdersOptions { + /** ISO timestamp; interpreted as UTC (dates_are_gmt is always sent). */ + modifiedAfter: string + page: number +} + +/** + * One page of orders modified after the cursor, oldest-modified first so the + * caller's cursor advances chronologically. Requires WooCommerce 5.8+ + * (modified_after); older stores fail with a woocommerce_rest_invalid_param + * style error surfaced to the connection's error state. + */ +export async function listOrdersPage( + creds: WooCredentials, + options: ListOrdersOptions, +): Promise { + return wcGet(creds, '/orders', { + modified_after: options.modifiedAfter, + dates_are_gmt: 'true', + status: 'any', + orderby: 'modified', + order: 'asc', + per_page: String(WC_PAGE_SIZE), + page: String(options.page), + }) +} + +/** Hard cap on refund pages per order; a real order never approaches this. */ +const MAX_REFUND_PAGES = 10 + +/** + * All refunds of one order. Terminates on an EMPTY batch, not a short one + * (hosts may cap per_page below our request, same as the order pagination), + * dedupes by id so a host that ignores `page` cannot loop forever, and caps + * total pages as a final backstop. + */ +export async function listOrderRefunds( + creds: WooCredentials, + orderId: number, +): Promise { + const refunds: WooRefund[] = [] + const seen = new Set() + for (let page = 1; page <= MAX_REFUND_PAGES; page++) { + const batch = await wcGet(creds, `/orders/${orderId}/refunds`, { + per_page: String(WC_PAGE_SIZE), + page: String(page), + }) + if (batch.length === 0) return refunds + const fresh = batch.filter((r) => !seen.has(r.id)) + if (fresh.length === 0) return refunds + for (const refund of fresh) seen.add(refund.id) + refunds.push(...fresh) + } + // Cap exhausted with data still flowing: returning the partial list would + // let the sync advance its cursor past refunds it never saw. Throwing + // routes into the caller's refund-failure path instead (order held, cursor + // capped, retried next run). + throw new WooCommerceApiError( + `Refund pagination cap exceeded for order ${orderId}`, + 0, + ) +} + +/** + * Verify credentials and read store metadata. The one-order probe is the + * authoritative credential check (it exercises the read scope the feed + * needs); title and settings lookups are best-effort extras. + */ +export async function testConnectionAndFetchStoreInfo( + creds: WooCredentials, +): Promise { + await wcGet(creds, '/orders', { per_page: '1' }) + + const info: WooStoreInfo = { + name: null, + currency: null, + prices_include_tax: null, + wc_version: null, + } + + try { + const settings = await wcGet>( + creds, + '/settings/general', + ) + const currency = settings.find((s) => s.id === 'woocommerce_currency')?.value + if (typeof currency === 'string' && currency) info.currency = currency.toUpperCase() + const pricesIncludeTax = settings.find((s) => s.id === 'woocommerce_prices_include_tax')?.value + if (typeof pricesIncludeTax === 'string') info.prices_include_tax = pricesIncludeTax === 'yes' + } catch { + // Settings need broader permissions on some setups; the feed works without. + } + + try { + const status = await wcGet<{ environment?: { version?: string } }>(creds, '/system_status') + if (status.environment?.version) info.wc_version = status.environment.version + } catch { + // system_status is admin-capability data and often blocked; optional. + } + + try { + // The WP REST index is public and carries the site title. + const response = await fetch(`${creds.storeUrl}/wp-json/`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (response.ok) { + const body = (await response.json()) as { name?: string } + if (body.name) info.name = body.name + } + } catch { + // Cosmetic only. + } + + return info +} diff --git a/extensions/general/woocommerce/lib/connect.ts b/extensions/general/woocommerce/lib/connect.ts new file mode 100644 index 00000000..ef6f1070 --- /dev/null +++ b/extensions/general/woocommerce/lib/connect.ts @@ -0,0 +1,52 @@ +import type { WooCredentials } from './api-client' +import { decryptCredential } from './credentials' +import type { WooCommerceConnection } from '../types' + +/** + * WooCommerce "Auth Endpoint" handshake helpers. + * + * The merchant's browser is sent to {store}/wc-auth/v1/authorize; after they + * approve, WooCommerce POSTs the generated consumer key/secret server-to- + * server to our callback_url and redirects the browser to return_url. Our + * oauth_state UUID rides in the handshake's user_id parameter and comes back + * in both places, tying callback and return to the pending connection row. + * + * There is no signature on the callback POST, so possession of the + * single-use state is the CSRF defense, and authenticity is proven by + * probing the STORED store_url with the received keys before activation: a + * forged POST would need working read credentials for the exact store the + * user asked to connect. + */ + +const APP_NAME = 'Accounted' + +export function buildAuthorizeUrl(storeUrl: string, state: string): string { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL + if (!baseUrl) throw new Error('NEXT_PUBLIC_APP_URL is not configured') + const params = new URLSearchParams({ + app_name: APP_NAME, + // Read-only: the feed never writes to the store. + scope: 'read', + user_id: state, + return_url: `${baseUrl}/api/extensions/woocommerce/return`, + callback_url: `${baseUrl}/api/extensions/woocommerce/callback`, + }) + return `${storeUrl}/wc-auth/v1/authorize?${params.toString()}` +} + +/** Decrypted API credentials for an active connection. */ +export function credentialsOf( + connection: Pick< + WooCommerceConnection, + 'store_url' | 'consumer_key_encrypted' | 'consumer_secret_encrypted' + >, +): WooCredentials { + if (!connection.consumer_key_encrypted || !connection.consumer_secret_encrypted) { + throw new Error('Connection has no stored credentials') + } + return { + storeUrl: connection.store_url, + consumerKey: decryptCredential(connection.consumer_key_encrypted), + consumerSecret: decryptCredential(connection.consumer_secret_encrypted), + } +} diff --git a/extensions/general/woocommerce/lib/credentials.ts b/extensions/general/woocommerce/lib/credentials.ts new file mode 100644 index 00000000..60329edb --- /dev/null +++ b/extensions/general/woocommerce/lib/credentials.ts @@ -0,0 +1,44 @@ +import crypto from 'crypto' + +/** + * At-rest encryption for WooCommerce consumer key/secret. + * + * AES-256-GCM with a dedicated env key, mirroring the Skatteverket token + * store (extensions/general/skatteverket/lib/token-store.ts): 12-byte IV, + * 16-byte auth tag, layout iv|tag|ciphertext, base64url encoded. The key is + * deployment-wide (not per-tenant); what makes rows useless off-server is + * that WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY never leaves the environment. + */ + +const ALGORITHM = 'aes-256-gcm' + +/** Whether the integration is configured on this deployment. */ +export function isWooCommerceConfigured(): boolean { + return Boolean(process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY) +} + +function getEncryptionKey(): Buffer { + const key = process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY + if (!key) throw new Error('WOOCOMMERCE_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') +} diff --git a/extensions/general/woocommerce/lib/order-sync.ts b/extensions/general/woocommerce/lib/order-sync.ts new file mode 100644 index 00000000..d48b3cff --- /dev/null +++ b/extensions/general/woocommerce/lib/order-sync.ts @@ -0,0 +1,593 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { ingestTransactions } from '@/lib/transactions/ingest' +import { ensureManualCashAccount } from '@/lib/cash-accounts/service' +import { syncMappedAccounts } from '@/lib/import/account-sync' +import { createLogger, type Logger } from '@/lib/logger' +import type { RawTransaction } from '@/types' +import { + listOrdersPage, + listOrderRefunds, + isRevokedCredentialsError, + WC_PAGE_SIZE, + type WooCredentials, +} from './api-client' +import { credentialsOf } from './connect' +import type { WooCommerceConnection, WooOrder, WooRefund } from '../types' + +const defaultLog = createLogger('woocommerce/order-sync') + +/** + * WooCommerce order sync: the store's paid orders and refunds treated as a + * bank-style feed. + * + * The store becomes a cash account on ledger 1680 (Andra kortfristiga + * fordringar: money the payment gateways owe the merchant), and orders land + * in the transactions inbox exactly like PSD2 bank rows: deduped on + * external_id, bound to the cash account so booking settles against 1680, and + * categorized/booked by the user through the normal flows. Nothing here + * auto-books. 1686 (Fordringar för kontokort och kuponger) would be the + * closest BAS account but is owned by the Stripe feed, and cash_accounts + * enforces one account per ledger per company. + * + * Row model: a paid order produces one positive row for its gross total; each + * refund produces one negative row. Payment-processor fees never appear: + * core wc/v3 does not expose them (they belong to the gateway, e.g. the + * Stripe feed for Stripe-gateway stores). order.transaction_id rides along as + * the row reference for later gateway-side reconciliation. + * + * Pagination is CURSOR-based, not offset-based: each request asks for the + * oldest orders with date_modified strictly after the current cursor + * (orderby=modified asc, page=1), and the cursor advances to the last row of + * each processed page. Offset pages over a fixed window would silently skip + * rows whenever an already-fetched order is modified mid-run (it re-sorts to + * the end and shifts every later row one index down); with a moving cursor a + * mid-run modification simply re-surfaces the order later in the same run. + * The one case that still needs offsets is a run of >WC_PAGE_SIZE orders + * sharing the same date_modified second (bulk edits, migrations): those are + * paged through with an increasing page number at a FIXED cursor, because + * modified_after is strictly exclusive and advancing it would skip the rest + * of the tie. Ties that span a page boundary after cursor advancement are + * picked up by the next run's overlap re-poll. + * + * Cursor: woocommerce_connections.last_order_synced_at, re-polled with a 24h + * overlap. It never advances past failed work: a page with refund-fetch + * failures, ingest errors, or deadline-skipped refunds caps the persisted + * cursor just below the earliest affected order's date_modified, so the next + * run re-lists exactly the orders whose rows are incomplete (re-seen complete + * rows collide on (company_id, external_id) and are skipped). First run + * fetches BACKFILL_DAYS back. + * + * Lock-date guard: modified_after selects on date_modified, but rows are + * dated by date_paid / refund date_created, which can be arbitrarily older + * (a refund or edit bumps date_modified long after payment). Rows dated on or + * before company_settings.bookkeeping_locked_through are therefore dropped at + * map time on EVERY run: the enforce_company_lock_date trigger makes them + * permanently unbookable, and feed rows are undeletable by design, so + * importing them would create permanent inbox noise. Dropped rows are counted + * in skipped_locked and logged. + */ + +/** BAS ledger account for the WooCommerce store cash account. */ +export const WOOCOMMERCE_LEDGER_ACCOUNT = '1680' +/** BAS 2026 name for 1680; used when creating the chart account. */ +const WOOCOMMERCE_LEDGER_ACCOUNT_NAME = 'Andra kortfristiga fordringar' +/** transactions.import_source for WooCommerce feed rows. */ +export const WOOCOMMERCE_IMPORT_SOURCE = 'woocommerce' +/** First-run backfill window (matches the Enable Banking convention). */ +export const BACKFILL_DAYS = 90 +/** Cursor re-poll overlap; external_id dedup makes duplicates no-ops. */ +const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000 +/** + * Safety cap on orders per run (matches the Stripe feed's MAX_TXNS_PER_RUN). + * The real bound is the caller's deadline; hitting this cap is logged loudly + * because a silent cap reads as "covered everything" when it did not. The + * cursor resumes where a truncated run stopped. + */ +const MAX_ORDERS_PER_RUN = 10_000 + +/** + * ⚠️ STORED-KEY FORMATS. These are persisted to transactions.external_id and + * dedup compares stored ids byte-for-byte, exactly like the Stripe and Enable + * Banking schemes. Changing a template silently orphans every prior row and + * re-imports the whole feed on the next sync. Locked by the frozen-format + * test in order-sync.test.ts; any change MUST ship a coordinated backfill. + * + * The scope is the store's normalized host(+path), NOT the connection id, so + * a disconnect/reconnect of the same store keeps every previously imported + * row deduped. + */ +export function wooStoreScope(storeUrl: string): string { + return storeUrl.replace(/^https:\/\//, '') +} + +export function wooOrderExternalId(storeScope: string, orderId: number): string { + return `woo_${storeScope}_order_${orderId}` +} + +export function wooRefundExternalId(storeScope: string, refundId: number): string { + return `woo_${storeScope}_refund_${refundId}` +} + +export interface WooCommerceSyncSummary { + /** Orders listed from the store (all statuses in the window). */ + fetched: number + /** Refund objects fetched for refunded orders in the window. */ + refundsFetched: number + /** New inbox rows inserted. */ + imported: number + /** Rows skipped by external_id / content dedup. */ + duplicates: number + /** Rows dropped because they are dated on/before the bookkeeping lock. */ + skippedLocked: number + errors: number + /** Set when the caller's time budget ran out before all pages processed. */ + deadlineReached?: boolean + /** Set when the store reported the credentials revoked (401/403). */ + revoked?: boolean +} + +const round = (n: number) => Math.round(n * 100) / 100 + +/** + * Money fields arrive as strings; unparseable input returns null so callers + * can tell a corrupt total (counted + logged in buildPageRows) from a + * legitimate zero (silently skipped). + */ +function parseAmount(value: string): number | null { + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? round(parsed) : null +} + +/** Whether a qualifying order's total cannot be read as money. */ +export function orderAmountUnparseable(order: Pick): boolean { + return parseAmount(order.total) === null +} + +/** Date part of a wc/v3 _gmt timestamp ("2026-08-01T12:34:56", no zone suffix). */ +function isoDateOfGmt(timestamp: string): string { + return timestamp.split('T')[0] +} + +/** wc/v3 _gmt timestamps lack a zone suffix; brand them UTC for timestamptz. */ +function gmtToIso(timestamp: string): string { + return timestamp.endsWith('Z') ? timestamp : `${timestamp}Z` +} + +function gmtToMs(timestamp: string): number { + return Date.parse(gmtToIso(timestamp)) +} + +/** + * Whether an order belongs in the feed: it must have been paid (date_paid is + * the revenue signal; pending/failed/cancelled-before-payment orders never + * carry one) and not be trashed. Status 'refunded' stays IN: a fully refunded + * order was still paid, and its refunds land as separate negative rows so the + * pair nets to zero instead of the gross silently disappearing. + */ +export function orderQualifies(order: Pick): boolean { + return Boolean(order.date_paid_gmt) && order.status !== 'trash' +} + +/** + * Map a paid order to its gross feed row. Dates use date_paid (when the money + * event happened), not date_created: booked entries, invoice matching, and + * month boundaries all want the payment date. Descriptions are deterministic + * from immutable data (order numbers never change) because the content-dedup + * bridge keys off them. + */ +export function mapOrder(storeScope: string, order: WooOrder): RawTransaction[] { + if (!orderQualifies(order)) return [] + const amount = parseAmount(order.total) + if (amount === null || amount === 0) return [] + return [ + { + date: isoDateOfGmt(order.date_paid_gmt!), + description: `WooCommerce-order #${order.number}`, + amount, + currency: order.currency.toUpperCase(), + external_id: wooOrderExternalId(storeScope, order.id), + import_source: WOOCOMMERCE_IMPORT_SOURCE, + reference: order.transaction_id || null, + }, + ] +} + +/** Map one refund of a paid order to its negative feed row. */ +export function mapRefund( + storeScope: string, + order: Pick, + refund: WooRefund, +): RawTransaction[] { + const amount = parseAmount(refund.amount) + if (amount === null || amount === 0) return [] + return [ + { + date: isoDateOfGmt(refund.date_created_gmt), + description: `WooCommerce-återbetalning order #${order.number}`, + amount: -amount, + currency: order.currency.toUpperCase(), + external_id: wooRefundExternalId(storeScope, refund.id), + import_source: WOOCOMMERCE_IMPORT_SOURCE, + reference: null, + }, + ] +} + +/** Company lock date (YYYY-MM-DD) or null; read once per run. */ +async function fetchLockThrough( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { data: settings } = await supabase + .from('company_settings') + .select('bookkeeping_locked_through') + .eq('company_id', companyId) + .maybeSingle() + return ( + (settings as { bookkeeping_locked_through?: string | null } | null) + ?.bookkeeping_locked_through ?? null + ) +} + +/** Whether a feed-row date is on/before the lock date (=> never bookable). */ +export function rowBehindLock(rowDate: string, lockThrough: string | null): boolean { + return lockThrough !== null && rowDate <= lockThrough +} + +/** + * Window start (ISO, UTC) for the first modified_after list call. With a + * cursor: cursor minus the 24h overlap. First run: BACKFILL_DAYS back. (The + * lock date no longer floors the window: it selects on date_modified while + * rows are dated by date_paid, so the real guard is rowBehindLock at map + * time, applied on every run.) + */ +function resolveWindowStartIso(connection: WooCommerceConnection): 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() +} + +/** + * Make sure the store cash account exists (ledger 1680, source manual so a + * later remap/promotion follows the normal cash-account rules) and, on the + * first run, that 1680 exists in the chart of accounts: the booking dialog + * and AccountPicker only list chart accounts. + * + * Currency comes from the store settings read at connect time, falling back + * to the first fetched order's real currency (settings/general is blocked on + * some hardened stores, and guessing SEK for an EUR store would poison the + * account). A conflict with an existing 1680 cash account throws; the caller + * surfaces that on the connection so the panel shows why nothing syncs. + */ +async function ensureStoreAccount( + supabase: SupabaseClient, + connection: WooCommerceConnection, + fallbackCurrency: string | undefined, + firstRun: boolean, + log: Logger, +): Promise { + const currency = + connection.currency?.toUpperCase() || fallbackCurrency?.toUpperCase() || 'SEK' + try { + await ensureManualCashAccount( + supabase, + connection.company_id, + WOOCOMMERCE_LEDGER_ACCOUNT, + currency, + 'WooCommerce-saldo', + ) + } catch (accountError) { + // Typically a currency conflict with an existing 1680 cash account. Made + // visible on the connection: without this the panel shows a healthy + // "Ansluten" store that silently never syncs. + await supabase + .from('woocommerce_connections') + .update({ + error_message: + 'Kassakontot för butiken (1680) kunde inte skapas. Kontrollera att befintligt konto 1680 har samma valuta som butiken.', + }) + .eq('id', connection.id) + throw accountError + } + if (firstRun) { + const sync = await syncMappedAccounts( + supabase, + connection.company_id, + connection.user_id, + [ + { + sourceAccount: WOOCOMMERCE_LEDGER_ACCOUNT, + sourceName: WOOCOMMERCE_LEDGER_ACCOUNT_NAME, + targetAccount: WOOCOMMERCE_LEDGER_ACCOUNT, + targetName: WOOCOMMERCE_LEDGER_ACCOUNT_NAME, + confidence: 1, + matchType: 'exact', + isOverride: false, + }, + ], + false, + ) + if (sync.error) { + // Rows still import and bind to the cash account; only the chart + // listing is affected (the account can be added manually), so this is + // deliberately non-fatal. + log.warn('chart sync for 1680 failed', { + companyId: connection.company_id, + error: sync.error, + }) + } + } +} + +interface PageRowsOutcome { + rows: RawTransaction[] + /** + * date_modified (ms) of every order whose refund rows are incomplete this + * run (fetch failed or skipped on deadline). The cursor must not advance + * past these: the next run has to re-list them. + */ + incompleteModifiedMs: number[] + hitDeadline: boolean +} + +/** Rows for one page of orders: gross rows plus refund rows where present. */ +async function buildPageRows( + creds: WooCredentials, + storeScope: string, + orders: WooOrder[], + lockThrough: string | null, + summary: WooCommerceSyncSummary, + log: Logger, + deadlineMs?: number, +): Promise { + const outcome: PageRowsOutcome = { rows: [], incompleteModifiedMs: [], hitDeadline: false } + + const push = (mapped: RawTransaction[]) => { + for (const row of mapped) { + if (rowBehindLock(row.date, lockThrough)) { + summary.skippedLocked += 1 + continue + } + outcome.rows.push(row) + } + } + + for (const order of orders) { + // A corrupt total is counted and logged, never silently identical to a + // zero-total order. Deliberately NOT held via the cursor: a permanently + // corrupt total would stall the whole feed forever, where a skipped row + // plus a loud error can be followed up. + if (orderQualifies(order) && orderAmountUnparseable(order)) { + summary.errors += 1 + log.warn('unparseable order total; row skipped', { + orderId: order.id, + total: order.total, + }) + } + push(mapOrder(storeScope, order)) + // Refunds only exist for qualifying (paid) orders: a refund row without + // its gross counterpart would be an unexplainable negative in the inbox. + if (!orderQualifies(order) || order.refunds.length === 0) continue + + // Refund fetches are one request per refunded order against a slow host; + // without this check a single mass-refund page could blow through the + // function's maxDuration and the cursor would never persist. + if (outcome.hitDeadline || (deadlineMs !== undefined && Date.now() >= deadlineMs)) { + outcome.hitDeadline = true + outcome.incompleteModifiedMs.push(gmtToMs(order.date_modified_gmt)) + continue + } + + try { + const refunds = await listOrderRefunds(creds, order.id) + summary.refundsFetched += refunds.length + for (const refund of refunds) { + if (parseAmount(refund.amount) === null) { + summary.errors += 1 + log.warn('unparseable refund amount; row skipped', { + orderId: order.id, + refundId: refund.id, + amount: refund.amount, + }) + } + push(mapRefund(storeScope, order, refund)) + } + } catch (refundError) { + // The order row still imports; the cursor is capped below this order's + // date_modified so the next run re-lists it and retries the refunds. + summary.errors += 1 + outcome.incompleteModifiedMs.push(gmtToMs(order.date_modified_gmt)) + log.warn('refund fetch failed; order held for retry next run', { + orderId: order.id, + message: refundError instanceof Error ? refundError.message : String(refundError), + }) + } + } + return outcome +} + +export async function syncWooCommerceOrders( + supabase: SupabaseClient, + connection: WooCommerceConnection, + log: Logger = defaultLog, + /** + * Absolute deadline (epoch ms) from the caller's time budget. Enforced + * between pages AND between refund fetches inside a page: the cursor + * advances only over fully-processed work, so the next run resumes exactly + * where this one stopped. + */ + deadlineMs?: number, +): Promise { + const summary: WooCommerceSyncSummary = { + fetched: 0, + refundsFetched: 0, + imported: 0, + duplicates: 0, + skippedLocked: 0, + errors: 0, + } + if ( + connection.status !== 'active' || + !connection.consumer_key_encrypted || + !connection.consumer_secret_encrypted + ) { + return summary + } + + const creds = credentialsOf(connection) + const storeScope = wooStoreScope(connection.store_url) + const firstRun = !connection.last_order_synced_at + const lockThrough = await fetchLockThrough(supabase, connection.company_id) + + let modifiedAfter = resolveWindowStartIso(connection) + // Offset page within a same-timestamp tie only; 1 whenever the cursor moves. + let tiePage = 1 + let prevCursorMs = connection.last_order_synced_at + ? Date.parse(connection.last_order_synced_at) + : 0 + // Earliest incomplete work this run; the persisted cursor never passes it. + let failureFloorMs = Number.POSITIVE_INFINITY + let accountEnsured = false + + try { + for (;;) { + if (deadlineMs !== undefined && Date.now() >= deadlineMs) { + summary.deadlineReached = true + log.info('time budget exhausted; stopping order sync', { + connectionId: connection.id, + processed: summary.imported + summary.duplicates, + }) + break + } + + const orders = await listOrdersPage(creds, { modifiedAfter, page: tiePage }) + // Termination is an EMPTY page, not a short one: hosts and security + // plugins may cap per_page below our request, and treating a short page + // as the end would strand the cursor at the first page forever. + if (orders.length === 0) break + summary.fetched += orders.length + + // Deferred until the window is known non-empty so a quiet store costs + // one API call and zero DB writes; also gives us a real order currency + // as the fallback for stores whose settings are unreadable. + if (!accountEnsured) { + await ensureStoreAccount(supabase, connection, orders[0].currency, firstRun, log) + accountEnsured = true + } + + const page = await buildPageRows( + creds, + storeScope, + orders, + lockThrough, + summary, + log, + deadlineMs, + ) + if (page.hitDeadline) summary.deadlineReached = true + + const firstMs = gmtToMs(orders[0].date_modified_gmt) + const lastMs = gmtToMs(orders[orders.length - 1].date_modified_gmt) + + if (page.rows.length > 0) { + // Auto-categorization is skipped on purpose: booking WooCommerce + // money is a human decision in the inbox (feed-only doctrine, same + // as the Stripe feed). Invoice matching still runs (suggestions + // only), and FX enrichment covers non-SEK stores. + const result = await ingestTransactions( + supabase, + connection.company_id, + connection.user_id, + page.rows, + { settlementAccount: WOOCOMMERCE_LEDGER_ACCOUNT, skipAutoCategorization: true }, + ) + summary.imported += result.imported + summary.duplicates += result.duplicates + summary.errors += result.errors + if (result.errors > 0) { + // Failed inserts are dropped inside ingest; hold the cursor below + // this page so the next run re-lists and retries it rather than + // turning a transient DB error into permanently missing rows. + failureFloorMs = Math.min(failureFloorMs, firstMs - 1000) + } + } + for (const ms of page.incompleteModifiedMs) { + failureFloorMs = Math.min(failureFloorMs, ms - 1000) + } + + // Persist the cursor after each page: monotonic (never regresses below + // the pre-run cursor) and capped by the failure floor. error_message is + // cleared on progress so a resolved incident stops showing in the panel. + const candidateMs = Math.min(lastMs, failureFloorMs) + if (candidateMs > prevCursorMs) { + const cursorIso = new Date(candidateMs).toISOString() + await supabase + .from('woocommerce_connections') + .update({ last_order_synced_at: cursorIso, error_message: null }) + .eq('id', connection.id) + connection.last_order_synced_at = cursorIso + prevCursorMs = candidateMs + } + + if (summary.deadlineReached) break + + // Advance. A full page entirely inside one date_modified second cannot + // move the cursor (modified_after is strictly exclusive): page through + // the tie by offset. Otherwise move the cursor to the page's last row; + // tie rows cut off at the boundary are recovered by the next run's + // overlap re-poll. + if (orders.length >= WC_PAGE_SIZE && lastMs === firstMs) { + tiePage += 1 + } else { + modifiedAfter = new Date(lastMs).toISOString() + tiePage = 1 + } + + if (summary.fetched >= MAX_ORDERS_PER_RUN) { + log.warn('order cap reached; remaining orders resume next run', { + connectionId: connection.id, + cap: MAX_ORDERS_PER_RUN, + }) + break + } + } + } catch (err) { + if (isRevokedCredentialsError(err)) { + // The key was deleted or demoted in wp-admin: flip the connection so + // the UI offers a reconnect instead of the cron retrying forever. + summary.revoked = true + await supabase + .from('woocommerce_connections') + .update({ + status: 'revoked', + error_message: 'Butiken avvisade API-nyckeln. Anslut butiken igen.', + // The store already rejected these; keeping decryptable dead + // credentials would be pure data retention (same as /disconnect). + consumer_key_encrypted: null, + consumer_secret_encrypted: 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 + } + + if (summary.skippedLocked > 0) { + log.info('rows behind the bookkeeping lock were skipped', { + connectionId: connection.id, + skippedLocked: summary.skippedLocked, + }) + } + log.info('woocommerce order sync done', { + connectionId: connection.id, + ...summary, + }) + return summary +} diff --git a/extensions/general/woocommerce/lib/settings-actions.ts b/extensions/general/woocommerce/lib/settings-actions.ts new file mode 100644 index 00000000..09753102 --- /dev/null +++ b/extensions/general/woocommerce/lib/settings-actions.ts @@ -0,0 +1,160 @@ +/** + * The WooCommerce settings panel's server calls, each classified into exactly + * one outcome. Same doctrine as the Stripe panel's settings-actions (see the + * doc block there): never throw, one toast sentence per click, and the + * classification lives outside the component because component logic has no + * tests in this repo. + */ + +import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import type { ActionFailure } from '@/lib/browser/action-failure' + +/** + * Deadline for the quick calls (status, toggle, disconnect). Connect and + * manual-connect probe the merchant's WooCommerce host (often a slow shared + * PHP box, with retries), so they get a longer one. + */ +export const WOO_ACTION_TIMEOUT_MS = 15_000 +export const WOO_CONNECT_TIMEOUT_MS = 120_000 + +/** + * Deadline for "Synka nu": the route's own ceiling (maxDuration 300 on the + * extension dispatcher) plus margin, same reasoning as the Stripe panel. A + * first sync backfills 90 days from a slow host and legitimately takes + * minutes; the server keeps working and advances the cursor even if we + * aborted, so aborting early would misreport a sync that landed. + */ +export const WOO_SYNC_TIMEOUT_MS = 310_000 + +export type WooRequestResult = + /** 2xx. `data` is null when the body was not readable JSON. */ + | { ok: true; data: T | null } + | ActionFailure + +export interface WooRequestOptions { + url: string + method?: 'GET' | 'POST' | 'DELETE' + body?: unknown + locale?: ErrorLocale + timeoutMs?: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** The one sentence for a non-2xx; route copy wins over the generic map. */ +export function serverErrorMessage( + body: unknown, + status: number, + locale: ErrorLocale, +): string { + if (isRecord(body)) { + if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) { + return body.error_en.trim() + } + if (typeof body.error === 'string' && body.error.trim()) { + return body.error.trim() + } + } + return getErrorMessage(body, { statusCode: status, locale }) +} + +/** Call one of the panel's endpoints and report exactly why it failed. */ +export async function wooRequest({ + url, + method = 'POST', + body, + locale = 'sv', + timeoutMs = WOO_ACTION_TIMEOUT_MS, +}: WooRequestOptions): Promise> { + try { + const res = await fetchWithTimeout( + url, + body === undefined + ? { method } + : { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { timeoutMs, description: `${method} ${url}` }, + ) + + const payload = await res.json().catch(() => null) + + if (!res.ok) { + return { + ok: false, + reason: 'server', + status: res.status, + message: serverErrorMessage(payload, res.status, locale), + } + } + + return { ok: true, data: payload as T | null } + } catch (err) { + if (isTimeoutError(err)) return { ok: false, reason: 'timeout' } + return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) } + } +} + +/** Success body of POST /api/extensions/ext/woocommerce/sync. */ +export interface WooSyncPayload { + success?: boolean + /** `WooCommerceSyncSummary` from lib/order-sync.ts, over the wire. */ + transactions?: { + fetched?: number + refundsFetched?: number + imported?: number + duplicates?: number + errors?: number + revoked?: boolean + deadlineReached?: boolean + } +} + +type SyncCounts = { + fetched: number + imported: number +} + +export type WooSyncSummary = + /** The store rejected the credentials; the connection was flipped to revoked. */ + | { reason: 'revoked' } + /** The window genuinely held nothing. A real answer, not a silent success. */ + | { reason: 'empty' } + /** + * The time budget ran out with orders still unfetched. Reported before the + * count-based outcomes so a truncated run never reads as a complete one; + * the cursor persisted, so pressing sync again continues where it stopped. + * Carries the error count too: a truncated run can also have failed rows, + * and dropping that number would repeat the silent-partial mistake. + */ + | { reason: 'partial'; values: SyncCounts & { errors: number } } + /** Rows landed, and some rows did not. Both halves get said. */ + | { reason: 'errors'; values: SyncCounts & { errors: number } } + /** Rows landed. */ + | { reason: 'feed'; values: SyncCounts } + /** 2xx whose body could not be read: the sync ran, the counts are unknown. */ + | { reason: 'unknown' } + +/** Turn the sync route's success body into the single sentence the user gets. */ +export function syncSummary(payload: WooSyncPayload | null): WooSyncSummary { + 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.imported === 'number' ? summary.imported : 0 + const errors = typeof summary.errors === 'number' ? summary.errors : 0 + + if (summary.deadlineReached === true) { + return { reason: 'partial', values: { fetched, imported, errors } } + } + if (fetched === 0) return { reason: 'empty' } + if (errors > 0) return { reason: 'errors', values: { fetched, imported, errors } } + return { reason: 'feed', values: { fetched, imported } } +} diff --git a/extensions/general/woocommerce/manifest.json b/extensions/general/woocommerce/manifest.json new file mode 100644 index 00000000..07fccf3c --- /dev/null +++ b/extensions/general/woocommerce/manifest.json @@ -0,0 +1,19 @@ +{ + "id": "woocommerce", + "sector": "general", + "exportName": "woocommerceExtension", + "entryPoint": "@/extensions/general/woocommerce", + "workspace": null, + "requiredEnvVars": ["WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY"], + "optionalEnvVars": [], + "npmDependencies": [], + "definition": { + "name": "WooCommerce", + "category": "import", + "icon": "ShoppingCart", + "dataPattern": "manual", + "hasOwnData": true, + "description": "Hämta betalda ordrar och återbetalningar från din WooCommerce-butik till transaktionsinkorgen", + "longDescription": "Anslut din WooCommerce-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till transaktionsinkorgen, som ett bankflöde för butiken. Inget bokförs automatiskt: du bokför raderna själv precis som vanliga banktransaktioner." + } +} diff --git a/extensions/general/woocommerce/types.ts b/extensions/general/woocommerce/types.ts new file mode 100644 index 00000000..2c075f17 --- /dev/null +++ b/extensions/general/woocommerce/types.ts @@ -0,0 +1,89 @@ +/** Row shape of public.woocommerce_connections. */ +export interface WooCommerceConnection { + id: string + company_id: string + user_id: string + /** Normalized https origin (+ optional subdirectory path), no trailing slash. */ + store_url: string + store_name: string | null + consumer_key_encrypted: string | null + consumer_secret_encrypted: string | null + key_permissions: string | null + status: 'pending' | 'active' | 'revoked' | 'error' + oauth_state: string | null + currency: string | null + prices_include_tax: boolean | null + wc_version: string | null + /** Opt-in: nightly order-feed cron (the manual sync button ignores it). */ + transaction_sync_enabled: boolean + /** Order-polling cursor (max date_modified_gmt 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/woocommerce/status. */ +export interface WooCommerceStatusResponse { + configured: boolean + connection: Pick< + WooCommerceConnection, + | 'id' + | 'status' + | 'store_url' + | 'store_name' + | 'currency' + | 'error_message' + | 'connected_at' + | 'transaction_sync_enabled' + | 'last_order_synced_at' + > | null +} + +/** + * Minimal wc/v3 order shape consumed by the feed. All money fields are strings + * in the order's `currency`; every date has a `_gmt` twin and the feed only + * ever reads the `_gmt` variants (store-local dates shift with DST). + */ +export interface WooOrder { + id: number + /** Display order number; usually the id, but plugins can renumber. */ + number: string + status: string + currency: string + /** Grand total actually charged (gross, incl. tax and shipping). */ + total: string + total_tax: string + prices_include_tax: boolean + date_created_gmt: string + date_modified_gmt: string + /** Set when payment completed; the feed's inclusion criterion and row date. */ + date_paid_gmt: string | null + payment_method: string + payment_method_title: string + /** Gateway charge reference; join key for later gateway-side reconciliation. */ + transaction_id: string + /** Summary of refunds against this order; totals are negative strings. */ + refunds: Array<{ id: number; reason: string; total: string }> +} + +/** Minimal wc/v3 order-refund shape (GET /orders/{id}/refunds). */ +export interface WooRefund { + id: number + /** Refund amount as a positive string. */ + amount: string + reason: string + date_created_gmt: string +} + +/** Store metadata read at connect time. */ +export interface WooStoreInfo { + /** WordPress site title from GET {store}/wp-json/ (public, unauthenticated). */ + name: string | null + /** ISO 4217 store currency, when readable. */ + currency: string | null + prices_include_tax: boolean | null + wc_version: string | null +} diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index 00f8484d..d0e378fa 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -30,6 +30,8 @@ export const CAPABILITY = { bolagsverket: 'bolagsverket', /** Stripe Connect: auto payment links on invoices + payment/payout sync. */ stripe_payments: 'stripe_payments', + /** WooCommerce store sync: orders/refunds imported as a transaction feed. */ + woocommerce_sync: 'woocommerce_sync', } as const export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY] @@ -55,6 +57,7 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [ CAPABILITY.skatteverket, CAPABILITY.email_send, CAPABILITY.stripe_payments, + CAPABILITY.woocommerce_sync, ] as const /** diff --git a/lib/events/types.ts b/lib/events/types.ts index 228bbf0b..d989a99a 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -78,6 +78,10 @@ export type CoreEvent = // land in event_log for the audit trail, mirroring bank_connection.*. | { type: 'stripe.connected'; payload: { connectionId: string; stripeAccountId: string; livemode: boolean; userId: string; companyId: string } } | { type: 'stripe.disconnected'; payload: { connectionId: string; stripeAccountId: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } } + // WooCommerce store lifecycle: same audit doctrine as stripe.* (a third + // party's API credentials are granted/dropped). + | { type: 'woocommerce.connected'; payload: { connectionId: string; storeUrl: string; userId: string; companyId: string } } + | { type: 'woocommerce.disconnected'; payload: { connectionId: string; storeUrl: 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 f8045eff..d120ec5a 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 15 total extensions', () => { - expect(getAllExtensions().length).toBe(15) + it('should have 16 total extensions', () => { + expect(getAllExtensions().length).toBe(16) }) 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(15) + expect(extensions.length).toBe(16) }) it('all extensions have required fields', () => { diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts index ecf4713f..1656c8c9 100644 --- a/lib/extensions/_generated/enabled-extensions.ts +++ b/lib/extensions/_generated/enabled-extensions.ts @@ -12,4 +12,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([ 'document-extraction', 'stripe', 'whatsapp-inbox', + 'woocommerce', ]) diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts index 4fd8032b..0525d71d 100644 --- a/lib/extensions/_generated/extension-list.ts +++ b/lib/extensions/_generated/extension-list.ts @@ -11,6 +11,7 @@ import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' import { documentExtractionExtension } from '@/extensions/general/document-extraction' import { stripeExtension } from '@/extensions/general/stripe' import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox' +import { woocommerceExtension } from '@/extensions/general/woocommerce' export const FIRST_PARTY_EXTENSIONS: Extension[] = [ enableBankingExtension, @@ -24,4 +25,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [ documentExtractionExtension, stripeExtension, whatsappInboxExtension, + woocommerceExtension, ] diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 53d8090e..a21c3326 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -147,5 +147,16 @@ export const EXTENSION_DEFINITIONS: Record = { ], "hasOwnData": true }, + { + "slug": "woocommerce", + "name": "WooCommerce", + "sector": "general", + "category": "import", + "icon": "ShoppingCart", + "dataPattern": "manual", + "description": "Hämta betalda ordrar och återbetalningar från din WooCommerce-butik till transaktionsinkorgen", + "longDescription": "Anslut din WooCommerce-butik så hämtas betalda ordrar och återbetalningar automatiskt varje natt till transaktionsinkorgen, som ett bankflöde för butiken. Inget bokförs automatiskt: du bokför raderna själv precis som vanliga banktransaktioner.", + "hasOwnData": true + }, ], } diff --git a/lib/extensions/settings-panel-registry.tsx b/lib/extensions/settings-panel-registry.tsx index 46530662..34d7bdd1 100644 --- a/lib/extensions/settings-panel-registry.tsx +++ b/lib/extensions/settings-panel-registry.tsx @@ -19,6 +19,9 @@ const SETTINGS_PANELS: Record = { stripe: dynamic( () => import('@/extensions/general/stripe/components/StripeSettingsPanel') ), + woocommerce: dynamic( + () => import('@/extensions/general/woocommerce/components/WooCommerceSettingsPanel') + ), } /** diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 77e286c1..1e6d73f0 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -1014,6 +1014,7 @@ export const ARCHIVE_EXCLUDED_TABLES: Record = { whatsapp_conversations: 'WhatsApp bot conversation state (company_id is only a which-company pin); receipts live in document_attachments', webhooks: 'automation config with signing secrets', + woocommerce_connections: 'WooCommerce connection state (encrypted API secrets)', } /** 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 cea952fb..61594bcf 100644 --- a/lib/stripe/__tests__/subscription-sync.test.ts +++ b/lib/stripe/__tests__/subscription-sync.test.ts @@ -115,7 +115,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', 'skatteverket', 'stripe_payments']) + expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'skatteverket', 'stripe_payments', 'woocommerce_sync']) expect(rows.every((r) => r.source === 'stripe')).toBe(true) }) diff --git a/messages/en.json b/messages/en.json index d535aeda..bbac7365 100644 --- a/messages/en.json +++ b/messages/en.json @@ -409,6 +409,59 @@ "transaction_sync_toggle_failed": "Could not save the setting. Please try again.", "sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers." }, + "woocommerce": { + "title": "WooCommerce", + "description": "Connect your WooCommerce store to fetch paid orders and refunds into the transaction inbox, as a bank-style feed for the store. You book the rows from the inbox as usual.", + "not_configured": "The WooCommerce integration is not configured on this installation. Contact your administrator.", + "load_failed": "Could not read the WooCommerce status. Check your connection and try again.", + "action_timeout": "The action took too long. Reload the page to see whether it went through.", + "action_network": "No contact with the server. Check your connection and try again.", + "store_url_label": "Store address", + "connect": "Connect store", + "connecting": "Connecting…", + "connect_hint": "You are sent to your store to approve the connection with read access. The keys are stored encrypted and you can revoke them at any time, here or in WooCommerce.", + "manual_toggle": "Enter API keys manually", + "manual_hint": "Create a read-only API key in WooCommerce under Settings, Advanced, REST API and paste the keys here.", + "consumer_key_label": "Consumer key", + "consumer_secret_label": "Consumer secret", + "manual_connect": "Connect with keys", + "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": "WooCommerce store", + "pending_note": "Waiting for the store to send the keys. Finish the approval in the store if you have not already.", + "connected_toast_title": "WooCommerce connected", + "connected_toast_description": "The store's paid orders and refunds are now fetched every night.", + "disconnected_toast_title": "WooCommerce disconnected", + "disconnected_toast_description": "Also remove the API key in the store's WooCommerce settings.", + "connect_failed_title": "Connection failed", + "disconnect_failed_title": "Disconnect failed", + "error_generic": "Something went wrong. Please try again.", + "error_denied": "The connection was denied in the store.", + "sync_now": "Sync now", + "syncing": "Syncing…", + "sync_done_title": "Sync complete", + "sync_done_feed": "{fetched} order(s) fetched: {imported} new rows in the inbox.", + "sync_done_empty": "The store returned no orders for the period. Check that the right store is connected if you expected orders.", + "sync_done_feed_errors": "{fetched} order(s) fetched: {imported} new rows in the inbox. {errors} row(s) could not be imported: sync again.", + "sync_partial_title": "Sync paused", + "sync_partial": "{fetched} order(s) fetched so far: {imported} new rows in the inbox.{errors, plural, =0 {} other { # row(s) could not be imported.}} Not all orders were fetched in time: sync again to continue where it stopped.", + "sync_failed_title": "Sync failed", + "sync_revoked": "The store rejected the API key, so no orders could be fetched. Connect the store again.", + "transaction_sync_title": "Orders from WooCommerce", + "transaction_sync_description": "Fetch the store's paid orders and refunds into the transaction inbox every night, as a bank-style feed for the store. You book the rows from the inbox as usual.", + "transaction_sync_backfill_note": "The first sync fetches up to 90 days of history, but never before the bookkeeping lock.", + "transaction_sync_last_synced": "Last synced {date}", + "transaction_sync_never_synced": "Not synced yet", + "transaction_sync_enabled_toast": "Order sync enabled. History is fetched on the next sync.", + "transaction_sync_disabled_toast": "Order sync disabled.", + "transaction_sync_toggle_failed": "Could not save the setting. Please try again." + }, "settings_modal": { "title": "Settings", "description": "Manage your company and account" @@ -6356,6 +6409,10 @@ "stripe_description": "Connect your company's Stripe account to fetch payments, fees, and payouts continuously and book them against the Stripe balance.", "stripe_not_enabled_title": "The Stripe extension is not enabled", "stripe_not_enabled_description": "Enable the Stripe extension to connect your Stripe account and sync transactions automatically.", + "woocommerce_title": "WooCommerce", + "woocommerce_description": "Connect your WooCommerce store to fetch paid orders and refunds into the transaction inbox.", + "woocommerce_not_enabled_title": "The WooCommerce extension is not enabled", + "woocommerce_not_enabled_description": "Enable the WooCommerce extension to connect your store and fetch orders 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 eefc882e..f78094b3 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -409,6 +409,59 @@ "transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.", "sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat." }, + "woocommerce": { + "title": "WooCommerce", + "description": "Koppla din WooCommerce-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen.", + "not_configured": "WooCommerce-integrationen är inte konfigurerad på den här installationen. Kontakta administratören.", + "load_failed": "Kunde inte läsa WooCommerce-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.", + "store_url_label": "Butikens adress", + "connect": "Anslut butik", + "connecting": "Ansluter…", + "connect_hint": "Du skickas till din butik för att godkänna kopplingen med läsbehörighet. Nycklarna lagras krypterade och du kan när som helst återkalla dem här eller i WooCommerce.", + "manual_toggle": "Ange API-nycklar manuellt", + "manual_hint": "Skapa en API-nyckel med läsbehörighet i WooCommerce under Inställningar, Avancerat, REST-API och klistra in nycklarna här.", + "consumer_key_label": "Konsumentnyckel (consumer key)", + "consumer_secret_label": "Konsumenthemlighet (consumer secret)", + "manual_connect": "Anslut med nycklar", + "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": "WooCommerce-butik", + "pending_note": "Väntar på att butiken ska skicka nycklarna. Slutför godkännandet i butiken om du inte redan gjort det.", + "connected_toast_title": "WooCommerce anslutet", + "connected_toast_description": "Butikens betalda ordrar och återbetalningar hämtas nu varje natt.", + "disconnected_toast_title": "WooCommerce frånkopplat", + "disconnected_toast_description": "Ta även bort API-nyckeln i butikens WooCommerce-inställningar.", + "connect_failed_title": "Anslutningen misslyckades", + "disconnect_failed_title": "Frånkopplingen misslyckades", + "error_generic": "Något gick fel. Försök igen.", + "error_denied": "Anslutningen nekades i butiken.", + "sync_now": "Synka nu", + "syncing": "Synkar…", + "sync_done_title": "Synkronisering klar", + "sync_done_feed": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen.", + "sync_done_empty": "Butiken returnerade inga ordrar för perioden. Kontrollera att rätt butik är ansluten om du väntade dig ordrar.", + "sync_done_feed_errors": "{fetched} order/ordrar hämtade: {imported} nya rader i inkorgen. {errors} rad(er) kunde inte importeras: synka igen.", + "sync_partial_title": "Synkroniseringen pausades", + "sync_partial": "{fetched} order/ordrar hämtade hittills: {imported} nya rader i inkorgen.{errors, plural, =0 {} other { # rad(er) kunde inte importeras.}} Alla ordrar hann inte hämtas: synka igen för att fortsätta där det stannade.", + "sync_failed_title": "Synkroniseringen misslyckades", + "sync_revoked": "Butiken avvisade API-nyckeln, så inga ordrar kunde hämtas. Anslut butiken igen.", + "transaction_sync_title": "Ordrar från WooCommerce", + "transaction_sync_description": "Hämta butikens betalda ordrar och återbetalningar till transaktionsinkorgen varje natt, som ett bankflöde för butiken. Du bokför raderna som vanligt från inkorgen.", + "transaction_sync_backfill_note": "Vid första synkningen hämtas upp till 90 dagars historik, dock inte före bokföringslåset.", + "transaction_sync_last_synced": "Senast synkad {date}", + "transaction_sync_never_synced": "Inte synkad ännu", + "transaction_sync_enabled_toast": "Ordersynk aktiverad. Historiken hämtas vid nästa synkning.", + "transaction_sync_disabled_toast": "Ordersynk 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" @@ -6356,6 +6409,10 @@ "stripe_description": "Koppla företagets Stripe-konto så hämtas betalningar, avgifter och utbetalningar löpande och bokförs mot Stripe-saldot.", "stripe_not_enabled_title": "Stripe-tillägget är inte aktiverat", "stripe_not_enabled_description": "Aktivera tillägget Stripe för att koppla ditt Stripe-konto och synka transaktioner automatiskt.", + "woocommerce_title": "WooCommerce", + "woocommerce_description": "Koppla din WooCommerce-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen.", + "woocommerce_not_enabled_title": "WooCommerce-tillägget är inte aktiverat", + "woocommerce_not_enabled_description": "Aktivera tillägget WooCommerce för att koppla din butik och hämta ordrar 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/woocommerce.svg b/public/logos/woocommerce.svg new file mode 100644 index 00000000..0c75e82e --- /dev/null +++ b/public/logos/woocommerce.svg @@ -0,0 +1,4 @@ + + + W + diff --git a/supabase/migrations/20260806170000_woocommerce_connections.sql b/supabase/migrations/20260806170000_woocommerce_connections.sql new file mode 100644 index 00000000..38008d0c --- /dev/null +++ b/supabase/migrations/20260806170000_woocommerce_connections.sql @@ -0,0 +1,99 @@ +-- WooCommerce store connections: per-company WooCommerce REST API credentials +-- for the order/refund transaction feed (extensions/general/woocommerce). +-- +-- Unlike Stripe Connect there is no platform account: WooCommerce hands each +-- connected app a per-store consumer key/secret (via the /wc-auth/v1/authorize +-- handshake or manual key entry). Both are secrets, so they are stored +-- AES-256-GCM encrypted with a dedicated server-side key +-- (WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY), never in plaintext. The encrypted +-- blobs are useless without that env key, mirroring the Skatteverket token +-- store. +-- +-- Modeled on stripe_connections (20260712100000): same status lifecycle, same +-- member-scoped RLS, no DELETE policy (connections are revoked, never deleted, +-- for audit). Like stripe_connections there is no write_audit_log trigger: +-- this is connection state, not accounting data, and audit-logging rows that +-- carry encrypted credentials would copy secret ciphertext into audit_log. + +create table public.woocommerce_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, + -- Normalized https origin (optionally with a subdirectory path for + -- WordPress installs under a path), no trailing slash. Set at connect + -- start; the wc-auth callback and every API call use this stored URL, so a + -- forged callback cannot redirect the integration to another store. + store_url text not null, + -- Store display name (WordPress site title) for the settings panel. + store_name text, + -- AES-256-GCM encrypted consumer key/secret (ck_... / cs_...). + -- NULL while the wc-auth round-trip is pending. + consumer_key_encrypted text, + consumer_secret_encrypted text, + -- Permission level WooCommerce granted the key ('read' expected). + key_permissions text, + status text not null default 'pending' + check (status in ('pending', 'active', 'revoked', 'error')), + -- Single-use CSRF token for the wc-auth round-trip; passed as the handshake + -- user_id correlation parameter and cleared on activation. + oauth_state uuid, + -- Store settings read at connect time; drive the feed's cash account. + currency text, + prices_include_tax boolean, + wc_version text, + -- Opt-in for the nightly order feed cron (the manual sync button ignores it). + transaction_sync_enabled boolean not null default false, + -- Order-polling cursor: max date_modified_gmt processed. Re-polled with a + -- 24h overlap; (company_id, external_id) dedup makes overlaps no-ops. + 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() +); + +-- One active connection per company. +create unique index woocommerce_connections_one_active_per_company + on public.woocommerce_connections (company_id) where (status = 'active'); + +-- A store may be actively connected to at most one company: two companies +-- importing the same order stream would double-book it. +create unique index woocommerce_connections_store_active_uniq + on public.woocommerce_connections (store_url) where (status = 'active'); + +create index idx_woocommerce_connections_company_id + on public.woocommerce_connections (company_id); +create index idx_woocommerce_connections_oauth_state + on public.woocommerce_connections (oauth_state) where (oauth_state is not null); + +alter table public.woocommerce_connections enable row level security; + +-- Members read their company's connection. Insert/update are member-scoped so +-- the connect/disconnect routes can run on the user's cookie session; the +-- wc-auth callback and the sync cron use the service role (bypasses RLS). +-- No DELETE policy: connections are revoked (status flip), never deleted. +create policy "members read woocommerce_connections" + on public.woocommerce_connections for select + using (company_id in (select public.user_company_ids())); + +create policy "members insert woocommerce_connections" + on public.woocommerce_connections for insert + with check ( + company_id in (select public.user_company_ids()) + and user_id = auth.uid() + ); + +create policy "members update woocommerce_connections" + on public.woocommerce_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_woocommerce_connections + before update on public.woocommerce_connections + for each row execute function public.update_updated_at_column(); + +comment on table public.woocommerce_connections is + 'WooCommerce store connections per company. Consumer key/secret stored AES-256-GCM encrypted; decryption requires the server-side WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260806170100_woocommerce_sync_capability_backfill.sql b/supabase/migrations/20260806170100_woocommerce_sync_capability_backfill.sql new file mode 100644 index 00000000..0c30fd01 --- /dev/null +++ b/supabase/migrations/20260806170100_woocommerce_sync_capability_backfill.sql @@ -0,0 +1,29 @@ +-- Backfill capability_grants for the new 'woocommerce_sync' capability. +-- +-- woocommerce_sync joins PAID_CAPABILITIES, but existing companies' grants +-- were written by the billing webhook / trial seeding BEFORE this key existed, +-- and are only refreshed on the next subscription event. Without a backfill, +-- every current payer and trialer would see the WooCommerce integration as not +-- entitled until their next webhook. Mirror each existing bank_sync grant (the +-- same sibling used by the stripe_payments backfill, 20260712100100) with +-- identical scope, source and expiry, so entitlement state stays exactly +-- aligned. +-- +-- Idempotent: the (scope, key, source) unique index makes re-runs no-ops. + +insert into public.capability_grants + (company_id, team_id, capability_key, source, granted_at, expires_at, metadata) +select + g.company_id, + g.team_id, + 'woocommerce_sync', + g.source, + g.granted_at, + g.expires_at, + jsonb_build_object( + 'backfilled_from', 'bank_sync', + 'backfill_migration', '20260806170100' + ) +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/tests/pg/woocommerce-connections.pg.test.ts b/tests/pg/woocommerce-connections.pg.test.ts new file mode 100644 index 00000000..4b052f2f --- /dev/null +++ b/tests/pg/woocommerce-connections.pg.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import { getPool, withUserContext } from './setup' +import { randomUUID } from 'crypto' +import { seedCompany } from './fixtures' + +// Committed (pool) inserts persist across pg-real runs, and the store_url +// partial unique index is global: fixed URLs would collide with rows left by +// a previous run before the assertion under test is ever reached. +const uniqueStore = (label: string) => 'https://' + label + '-' + randomUUID() + '.example.se' + +/** + * Covers migration 20260806170000_woocommerce_connections: + * 1. RLS: members insert and read their own company's connection, + * non-members see nothing and cannot insert for a foreign company. + * 2. One ACTIVE connection per company (partial unique index). + * 3. One store actively connected to at most one company. + * 4. No DELETE policy: a member DELETE silently affects zero rows. + */ + +describe('woocommerce_connections RLS', () => { + it('a member can insert and read their company connection', async () => { + const { userId, companyId } = await seedCompany() + await withUserContext(userId, async (client) => { + const inserted = await client.query( + `INSERT INTO public.woocommerce_connections + (company_id, user_id, store_url, status, oauth_state) + VALUES ($1, $2, 'https://shop.example.se', 'pending', gen_random_uuid()) + RETURNING id`, + [companyId, userId], + ) + expect(inserted.rows).toHaveLength(1) + + const read = await client.query( + `SELECT status, store_url FROM public.woocommerce_connections WHERE company_id = $1`, + [companyId], + ) + expect(read.rows).toEqual([ + { status: 'pending', store_url: 'https://shop.example.se' }, + ]) + }) + }) + + 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.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'active')`, + [companyId, ownerId, uniqueStore('foreign')], + ) + const { userId: outsiderId } = await seedCompany() // member of a DIFFERENT company + + await withUserContext(outsiderId, async (client) => { + const read = await client.query( + `SELECT id FROM public.woocommerce_connections WHERE company_id = $1`, + [companyId], + ) + expect(read.rows).toHaveLength(0) + + await expect( + client.query( + `INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, 'https://intruder.example.se', 'pending')`, + [companyId, outsiderId], + ), + ).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.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'active')`, + [companyId, userId, uniqueStore('store-one')], + ) + await expect( + getPool().query( + `INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'active')`, + [companyId, userId, uniqueStore('store-two')], + ), + ).rejects.toMatchObject({ code: '23505' }) // unique_violation + }) + + it('a store 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 sharedUrl = uniqueStore('shared') + await getPool().query( + `INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'active')`, + [companyA, userA, sharedUrl], + ) + await expect( + getPool().query( + `INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'active')`, + [companyB, userB, sharedUrl], + ), + ).rejects.toMatchObject({ code: '23505' }) + + // A revoked row for the same store is fine (history is kept). + const revoked = await getPool().query( + `INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'revoked') RETURNING id`, + [companyB, userB, sharedUrl], + ) + expect(revoked.rows).toHaveLength(1) + }) + + it('members cannot DELETE (no DELETE policy; revoke is a status flip)', async () => { + const { userId, companyId } = await seedCompany() + const { rows } = await getPool().query( + `INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status) + VALUES ($1, $2, $3, 'active') RETURNING id`, + [companyId, userId, uniqueStore('keep')], + ) + await withUserContext(userId, async (client) => { + const del = await client.query( + `DELETE FROM public.woocommerce_connections WHERE id = $1`, + [rows[0].id], + ) + expect(del.rowCount).toBe(0) + }) + const still = await getPool().query( + `SELECT id FROM public.woocommerce_connections WHERE id = $1`, + [rows[0].id], + ) + expect(still.rows).toHaveLength(1) + }) +}) diff --git a/vercel.json b/vercel.json index 1094f3ae..630ca370 100644 --- a/vercel.json +++ b/vercel.json @@ -25,6 +25,10 @@ "path": "/api/extensions/stripe/transactions/cron", "schedule": "30 3 * * *" }, + { + "path": "/api/extensions/woocommerce/orders/cron", + "schedule": "45 3 * * *" + }, { "path": "/api/documents/verify/cron", "schedule": "0 3 * * *"