From c35b2547fbbe63c9e6337df13f20881142d30687 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:08:13 +0200 Subject: [PATCH] feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525) * feat(webshop-orders): schema, types and error codes for the orders surface webshop_orders (order/refund rows, financial-freeze trigger, member select/update RLS, no DELETE) + webshop_store_settings (per-store payment method -> account map), source_type 'webshop_order', multi-store index drop, customer_country, and a one-time woo cursor reset so the switch-over backfills and cross-marks existing feed rows. Tables classified in the full-archive export; pg-real coverage for RLS, freeze and CHECK. Co-Authored-By: Claude Fable 5 * feat(webshop-orders): core service (ingest, booking lines) upsertWebshopOrders: two-phase order/refund upsert with FX enrichment, legacy-feed cross-marking, frozen-row protection and field-wise jsonb comparisons (Postgres does not preserve object key order). Booking-line builder: per-rate VAT split with SIGNED buckets (discounts book as revenue reductions), refund mirroring, 3740 residual, per-store account prefill, and advisory export/EU + OSS warnings. Co-Authored-By: Claude Fable 5 * feat(webshop-orders): API routes for list, booking, invoicing and mapping Booking is draft -> atomic claim -> commit (conditional link-back closes the concurrent double-book race; a lost claim cancels the voucher-free draft). Legacy-feed guard honors transactions.is_ignored on both the book and create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an unnumbered draft with dominant-rate fallback and drift-safe unit prices. Co-Authored-By: Claude Fable 5 * feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav /orders lists per-store orders with status tabs (server-side filters), exception chips and one action per row. Booking dialog prefills from the per-store payment-method mapping with an opt-in remember; invoice dialog converts to a draft kundfaktura. The Order nav item renders only for companies with an active WooCommerce connection or existing order rows (Shopify deliberately excluded until its sync writes webshop_orders). Co-Authored-By: Claude Fable 5 * feat(woocommerce): switch the order sync to webshop_orders, multi-store The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund allocations with parent-prorated VAT fallback) and upserts order rows instead of transactions-inbox rows; already-imported feed rows stay bookable and get cross-marked. Multi-store: several active connections per company, per-store panel cards with the account-mapping editor. Co-Authored-By: Claude Fable 5 * docs(webshop-orders): decision log entries and ratchet baseline Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption; hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined). Co-Authored-By: Claude Fable 5 * fix(webshop-orders): resolve PR #1525 review findings and CI failures Review batch (Superagent, CodeRabbit, Swedish compliance review): - Mutual-exclusion claims: booking guards invoice_id, invoice link-back guards journal_entry_id AND treats zero matched rows as the conflict it is (409 + rollback), closing both TOCTOU races. - Freeze v2 migration (20260812124858): the link columns themselves are protected: invoice links immutable, journal links clearable only while the entry is still a draft (the booking rollback path). - Scraped orgnr no longer auto-written to customers.org_number; rate fallback applies only on single-VAT-bucket orders; refunds get their own WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the invoice-mode hint in the booking dialog. - Ingest compares every synced field (billing corrections no longer drop as unchanged); sync guards absent refunds arrays; /sync aggregates per-store results; panel disables all cards while a request runs; orders page separates load failure from empty; account field explains itself. CI: regenerated skills/accounted-api; pg tests restructured for transaction-abort/rollback semantics + freeze-link coverage; unresolvable- expression ceiling 375 -> 378 with documented reason (partial-update payloads in ingest, shapes covered by unit tests). Declined: CodeRabbit docstring-coverage advisory (house style: comments only where the code cannot say it). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 4 + app/(dashboard)/layout.tsx | 18 + app/(dashboard)/orders/page.tsx | 423 ++++++++++++ .../woocommerce/orders/cron/route.ts | 28 +- app/api/webshop-orders/[id]/book/route.ts | 238 +++++++ .../[id]/create-invoice/route.ts | 307 +++++++++ app/api/webshop-orders/__tests__/book.test.ts | 301 ++++++++ .../__tests__/create-invoice.test.ts | 312 +++++++++ .../__tests__/list-and-settings.test.ts | 195 ++++++ app/api/webshop-orders/route.ts | 72 ++ app/api/webshop-orders/settings/route.ts | 66 ++ components/dashboard/DashboardNav.tsx | 20 +- .../orders/CreateInvoiceFromOrderDialog.tsx | 140 ++++ components/orders/OrderBookingDialog.tsx | 213 ++++++ .../orders/PaymentMethodMappingForm.tsx | 216 ++++++ .../VoucherSeriesPerSourceTypeForm.tsx | 2 + .../woocommerce/__tests__/api-routes.test.ts | 13 +- .../woocommerce/__tests__/order-sync.test.ts | 537 +++++++++++---- .../__tests__/settings-actions.test.ts | 10 +- extensions/general/woocommerce/api-routes.ts | 108 ++- .../components/WooCommerceSettingsPanel.tsx | 446 ++++++------ .../general/woocommerce/lib/order-sync.ts | 649 +++++++++++------- .../woocommerce/lib/settings-actions.ts | 10 +- extensions/general/woocommerce/types.ts | 92 ++- lib/api/schemas.ts | 46 ++ lib/errors/structured-errors.ts | 75 ++ lib/reports/full-archive-export.ts | 4 + .../__tests__/booking-lines.test.ts | 279 ++++++++ lib/webshop-orders/__tests__/ingest.test.ts | 296 ++++++++ lib/webshop-orders/booking-lines.ts | 251 +++++++ lib/webshop-orders/ingest.ts | 439 ++++++++++++ lib/webshop-orders/types.ts | 61 ++ messages/en.json | 68 ++ messages/sv.json | 68 ++ scripts/checks/antipatterns-baseline.json | 2 +- .../references/journal-entries.md | 4 +- .../20260811073315_webshop_orders.sql | 166 +++++ .../20260811073333_webshop_store_settings.sql | 60 ++ ...3416_journal_source_type_webshop_order.sql | 44 ++ ...20260811073422_woocommerce_multi_store.sql | 10 + ...211335_webshop_orders_customer_country.sql | 14 + ...merce_cursor_reset_for_orders_backfill.sql | 26 + ...858_webshop_orders_freeze_link_columns.sql | 66 ++ tests/pg/webshop-orders.pg.test.ts | 284 ++++++++ tests/pg/woocommerce-connections.pg.test.ts | 18 +- tests/schema/no-phantom-columns.test.ts | 7 +- types/index.ts | 94 +++ 47 files changed, 6104 insertions(+), 698 deletions(-) create mode 100644 app/(dashboard)/orders/page.tsx create mode 100644 app/api/webshop-orders/[id]/book/route.ts create mode 100644 app/api/webshop-orders/[id]/create-invoice/route.ts create mode 100644 app/api/webshop-orders/__tests__/book.test.ts create mode 100644 app/api/webshop-orders/__tests__/create-invoice.test.ts create mode 100644 app/api/webshop-orders/__tests__/list-and-settings.test.ts create mode 100644 app/api/webshop-orders/route.ts create mode 100644 app/api/webshop-orders/settings/route.ts create mode 100644 components/orders/CreateInvoiceFromOrderDialog.tsx create mode 100644 components/orders/OrderBookingDialog.tsx create mode 100644 components/orders/PaymentMethodMappingForm.tsx create mode 100644 lib/webshop-orders/__tests__/booking-lines.test.ts create mode 100644 lib/webshop-orders/__tests__/ingest.test.ts create mode 100644 lib/webshop-orders/booking-lines.ts create mode 100644 lib/webshop-orders/ingest.ts create mode 100644 lib/webshop-orders/types.ts create mode 100644 supabase/migrations/20260811073315_webshop_orders.sql create mode 100644 supabase/migrations/20260811073333_webshop_store_settings.sql create mode 100644 supabase/migrations/20260811073416_journal_source_type_webshop_order.sql create mode 100644 supabase/migrations/20260811073422_woocommerce_multi_store.sql create mode 100644 supabase/migrations/20260811211335_webshop_orders_customer_country.sql create mode 100644 supabase/migrations/20260811211418_woocommerce_cursor_reset_for_orders_backfill.sql create mode 100644 supabase/migrations/20260812124858_webshop_orders_freeze_link_columns.sql create mode 100644 tests/pg/webshop-orders.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index e7db64a3..fde84df4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -847,6 +847,10 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-08] extensions.schema.json enum also gained "stripe" while adding "shopify": the enum had drifted (stripe was enabled in extensions.config.json but missing from the schema, failing editor validation); fixed in the same touch since the file had to change anyway. [2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt. [2026-08-09] Upsell FAB dismissal is session-scoped (sessionStorage), not persisted: closing the paywalled sheet or the pill's X hides all floating assistant UI for non-payers until the next browser session; a permanent dismissal would let one click silence the conversion surface forever, and payer/collapsed FAB behavior stays untouched. +[2026-08-11] Webshop Orders replaces the WooCommerce transactions feed with a core `webshop_orders` table (platform column, Shopify plugs in later) + core page/API; the extension keeps fetch/mapping and writes through `upsertWebshopOrders()` (same direction as ingestTransactions). Refunds are separate bookable rows with parent self-FK; external_id keeps the frozen `woo_{scope}_order|refund_{id}` scheme so the overlap with already-imported transactions rows is a string join (`legacy_transaction_id` cross-mark; booking route 409s both directions). Booked/invoiced rows get a DB financial-freeze trigger; remote drift sets `remote_changed_after_freeze` instead of mutating (storno is the correction path). +[2026-08-11] Per-store payment-method -> account mapping lives in `webshop_store_settings` keyed by (company, platform, store_scope), not as JSONB on woocommerce_connections: survives disconnect/reconnect, readable by core without touching extension state, reused by Shopify unchanged. Mapping only prefills the booking dialog (manual base); persistence of a dialog override is opt-in ("Kom ihåg"). Invoice-paid orders convert MANUALLY to unnumbered draft kundfakturor (no auto-invoicing: webshop may be invoice-of-record; scraped orgnr never lands on legal fields unreviewed). +[2026-08-11] `default_voucher_series_per_source_type` JSONB deliberately NOT reseeded for 'webshop_order' (resolver falls back to 'A'; same precedent as stripe_payout et al). `webshop_orders` deliberately has NO write_audit_log trigger: nightly sync upserts would flood audit_log; booking/invoicing are audited on journal_entries/invoices. Migration files carry apply-time versions 20260811073315-073422 (applied to staging via MCP; renamed per the orphan-fix recipe). +[2026-08-11] Skeptic pass on webshop orders BLOCKED the first build; fixes shipped in the same worktree: (1) refunds now carry a VAT reversal (refund line_items fetched, else prorated from the parent's breakdown; a zero-tax refund of a taxed sale was the worst finding, silently over-declaring ruta 10); (2) booking is draft-claim-commit with a conditional link-back (.is journal_entry_id null), closing the concurrent double-book race, and drafts cancel voucher-free on a lost claim; (3) jsonb comparisons in the ingest are field-wise (Postgres does not preserve object key order; JSON.stringify falsely flagged every booked row as remote-changed); (4) VAT buckets are SIGNED (discount/gift-card lines book as revenue reductions, never abs-flipped) and fee_lines are part of breakdown + line snapshot; (5) legacy-feed guard honors transactions.is_ignored and also covers create-invoice; buttons stay visible and the server 409 guides (soft-guard rule); (6) cursor reset migration (20260811211418) makes the switch-over backfill 90 days so existing feed rows actually get cross-marked twins; (7) 0%-sale-to-non-SE and foreign-VAT advisories in the booking dialog, driven by new customer_country (20260811211335); (8) Shopify connections do NOT gate the Order nav until the Shopify sync writes webshop_orders. [2026-08-10] Transactions inbox fetches ALL pending rows merged into the single transactions state array (tracked window boundary via pagedCountRef/pagedThroughDate) instead of a parallel pendingTransactions state: ~20 setTransactions mutation call sites (book/ignore/edit/delete) would each need dual updates and would drift; the merged array keeps mutations one code path, at the cost of a date-boundary filter for the history view. [2026-08-09] /migrate streaming is opt-in via Accept: application/x-ndjson instead of replacing the JSON contract: the wizard is the only caller today but a hard cutover would break open pre-deploy tabs and the route's locked error-status tests; mid-stream failures re-send the structured envelope as a terminal error event because the 200 is already committed once the stream opens. [2026-08-09] Regeluppdat + docs-freshness scans (#1417) built as local loop skills with due-date self-gating, not cloud crons: cloud routines were retired 2026-07-20, and session crons die at 7 days, so weekly/monthly cadence is achieved by loop-ignite running each loop when its run marker says it is due. loop-regeluppdat files tickets only (no auto-fix PRs): regulatory changes touch money math and compliance logic, which .claude/loops.md forbids loops from changing. Docs check diffs the live .md mirror routes against repo-built markdown (exact, canonicalised both sides) instead of diffing the gnubok-website checkout, so it also catches deployed-but-stale and route-404 states. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 30c3b364..d8bc6d7c 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -151,6 +151,7 @@ export default async function DashboardLayout({ entitlements, { data: allSettingsNames }, { data: userPrefs }, + hasWebshop, ] = await Promise.all([ supabase.from('companies').select('*').eq('id', companyId).single(), supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(), @@ -178,6 +179,22 @@ export default async function DashboardLayout({ // preference (Inställningar → Assistenten). Batched here so it costs no // extra round-trip on the dashboard critical path. supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(), + // Whether the company has a webshop hooked up: an ACTIVE WooCommerce + // connection, or already-imported webshop_orders rows (a disconnected + // store's orders are accounting underlag and must stay reachable). + // Shopify connections deliberately do NOT count until the Shopify sync + // is switched from the transactions feed to webshop_orders: gating on + // them today would surface a permanently empty Orders page. Two + // indexed limit-1 selects, parallel with the batch; accepted cost on + // the first-paint path (gates a nav destination, unlike the badge + // counts that moved client-side above). + Promise.all([ + supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1), + supabase.from('webshop_orders').select('id').eq('company_id', companyId).limit(1), + ]).then( + ([woo, orders]) => + (woo.data?.length ?? 0) > 0 || (orders.data?.length ?? 0) > 0, + ), ]) // company_id -> current display name for every company the user belongs to. @@ -318,6 +335,7 @@ export default async function DashboardLayout({ entityType={entityType} paysSalaries={paysSalaries} dimensionsEnabled={dimensionsEnabled} + hasWebshop={hasWebshop} isSandbox={isSandbox} extensionNavItems={getExtensionNavItems()} userName={userProfile?.full_name ?? null} diff --git a/app/(dashboard)/orders/page.tsx b/app/(dashboard)/orders/page.tsx new file mode 100644 index 00000000..ebe5c99b --- /dev/null +++ b/app/(dashboard)/orders/page.tsx @@ -0,0 +1,423 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import dynamic from 'next/dynamic' +import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { ShoppingCart } from 'lucide-react' +import { PageHeader } from '@/components/ui/page-header' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { EmptyState } from '@/components/ui/empty-state' +import { Skeleton } from '@/components/ui/skeleton' +import { ContextPicker } from '@/components/common/ContextPicker' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { cn, formatCurrency, formatDate } from '@/lib/utils' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import type { WebshopOrder, WebshopStoreSettings } from '@/types' + +const OrderBookingDialog = dynamic(() => import('@/components/orders/OrderBookingDialog'), { + ssr: false, +}) +const CreateInvoiceFromOrderDialog = dynamic( + () => import('@/components/orders/CreateInvoiceFromOrderDialog'), + { ssr: false }, +) + +interface StoreFacet { + platform: string + store_scope: string + store_label: string | null +} + +type StatusTab = 'all' | 'unpaid' | 'paid' | 'refunds' | 'booked' + +const PAGE_SIZE = 50 + +/** Server-side query params per status tab (pagination stays correct). */ +function tabQuery(tab: StatusTab): string { + switch (tab) { + case 'unpaid': + return '&paid=unpaid' + case 'paid': + return '&paid=paid&booked=unbooked' + case 'refunds': + return '&row_type=refund' + case 'booked': + return '&booked=booked' + default: + return '' + } +} + +export default function OrdersPage() { + const t = useTranslations('webshop_orders') + const { canWrite } = useCanWrite() + const [rows, setRows] = useState([]) + const [stores, setStores] = useState([]) + const [settings, setSettings] = useState([]) + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + // A transient fetch failure must not render as "no orders" with a connect + // CTA (review finding): failure gets its own retry state. + const [loadFailed, setLoadFailed] = useState(false) + const [tab, setTab] = useState('all') + const [storeScope, setStoreScope] = useState(null) + const [page, setPage] = useState(0) + const [bookingOrder, setBookingOrder] = useState(null) + const [invoicingOrder, setInvoicingOrder] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + try { + const storeParam = storeScope ? `&store_scope=${encodeURIComponent(storeScope)}` : '' + const res = await fetch( + `/api/webshop-orders?limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}${tabQuery(tab)}${storeParam}`, + ) + if (!res.ok) throw new Error(`list failed: ${res.status}`) + const json = (await res.json()) as { + data: WebshopOrder[] + count: number | null + stores: StoreFacet[] + } + setRows(json.data) + setCount(json.count ?? json.data.length) + setStores(json.stores) + setLoadFailed(false) + } catch { + setRows([]) + setCount(0) + setLoadFailed(true) + } finally { + setLoading(false) + } + }, [tab, storeScope, page]) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + fetch('/api/webshop-orders/settings') + .then((r) => (r.ok ? r.json() : { data: [] })) + .then((json: { data: WebshopStoreSettings[] }) => setSettings(json.data ?? [])) + .catch(() => setSettings([])) + }, []) + + const visibleRows = rows + + const multiStore = stores.length > 1 + const activeStore = stores.find((s) => s.store_scope === storeScope) ?? null + const storeItems = [ + { id: 'all', label: t('all_stores') }, + ...stores.map((s) => ({ id: s.store_scope, label: s.store_label || s.store_scope })), + ] + + const settingsFor = useCallback( + (order: WebshopOrder) => + settings.find( + (s) => s.platform === order.platform && s.store_scope === order.store_scope, + ) ?? null, + [settings], + ) + + const tabs: Array<{ key: StatusTab; label: string }> = [ + { key: 'all', label: t('tab_all') }, + { key: 'unpaid', label: t('tab_unpaid') }, + { key: 'paid', label: t('tab_to_book') }, + { key: 'refunds', label: t('tab_refunds') }, + { key: 'booked', label: t('tab_booked') }, + ] + + return ( +
+ + +
+
+ {tabs.map(({ key, label }) => ( + + ))} +
+
+ { + setStoreScope(id === 'all' ? null : id) + setPage(0) + }} + triggerLabel={ + activeStore ? activeStore.store_label || activeStore.store_scope : t('all_stores') + } + ariaLabel={t('store_picker_aria')} + /> +
+
+ + {loading ? ( +
+ + + +
+ ) : loadFailed ? ( +
+

{t('load_failed')}

+ +
+ ) : visibleRows.length === 0 ? ( + + ) : ( +
+ + + + + + {multiStore && } + + + + + + + + {visibleRows.map((order) => ( + setBookingOrder(order)} + onInvoice={() => setInvoicingOrder(order)} + t={t} + /> + ))} + +
{t('col_date')}{t('col_order')}{t('col_store')}{t('col_customer')}{t('col_payment_method')}{t('col_total')}{t('col_status')} +
+ {count > PAGE_SIZE && ( +
+ + {t('pagination', { + from: page * PAGE_SIZE + 1, + to: Math.min((page + 1) * PAGE_SIZE, count), + total: count, + })} + +
+ + +
+
+ )} +
+ )} + + {bookingOrder && ( + { + if (!open) setBookingOrder(null) + }} + order={bookingOrder} + storeSettings={settingsFor(bookingOrder)} + onBooked={() => { + setBookingOrder(null) + void load() + }} + onSettingsSaved={(updated) => { + setSettings((prev) => { + const rest = prev.filter( + (s) => !(s.platform === updated.platform && s.store_scope === updated.store_scope), + ) + return [...rest, updated] + }) + }} + /> + )} + {invoicingOrder && ( + { + if (!open) setInvoicingOrder(null) + }} + order={invoicingOrder} + onCreated={() => { + setInvoicingOrder(null) + void load() + }} + /> + )} +
+ ) +} + +function OrderRow({ + order, + multiStore, + canWrite, + onBook, + onInvoice, + t, +}: { + order: WebshopOrder + multiStore: boolean + canWrite: boolean + onBook: () => void + onInvoice: () => void + t: ReturnType> +}) { + const isRefund = order.row_type === 'refund' + const booked = order.journal_entry_id !== null + const invoiced = order.invoice_id !== null + // Cross-marked rows (legacy_transaction_id) keep their action buttons: the + // server guard decides (it allows booking once the feed row is booked- + // elsewhere-no, ignored-yes) and its 409 message explains what to do. + // Hiding the button would be a dead-end soft guard. + const bookable = canWrite && !booked && !invoiced && (isRefund || order.is_paid) + const invoiceable = canWrite && !isRefund && !booked && !invoiced + + return ( + + + {formatDate(order.order_date)} + + + {isRefund ? ( + + {t('refund_prefix')} {order.order_number} + + ) : ( + {order.order_number} + )} + + {multiStore && ( + + {order.store_label || order.store_scope} + + )} + {/* One-line rows (design convention 4): the full beställningsuppgifter + (kontaktperson, orgnr, e-post) live in the native tooltip. */} + + {order.customer_company || order.customer_name || ( + {'–'} + )} + + + {order.payment_method_title || order.payment_method || ''} + + + {formatCurrency(order.total, order.currency)} + + + + + {/* One action per row: Bokför when the money event is bookable, else + Skapa faktura for unpaid invoice-flow orders. Two buttons side by + side pushed the table past the panel width and the overflow clip + swallowed single-button cells. */} + + {bookable ? ( + + ) : invoiceable ? ( + + ) : null} + + + ) +} + +/** + * Chips mark exceptions only (design convention 5): booked rows render as + * muted text; deviations (unbooked-but-paid, unpaid, legacy overlap, remote + * drift) get a Badge. + */ +function OrderStatus({ + order, + t, +}: { + order: WebshopOrder + t: ReturnType> +}) { + if (order.remote_changed_after_freeze) { + return {t('status_remote_changed')} + } + if (order.journal_entry_id) { + return {t('status_booked')} + } + if (order.invoice_id) { + return ( + + {t('status_invoiced')} + + ) + } + if (order.legacy_transaction_id) { + return ( + + {t('status_in_transactions')} + + ) + } + if (!order.is_paid && order.row_type === 'order') { + return {t('status_unpaid')} + } + return {t('status_to_book')} +} diff --git a/app/api/extensions/woocommerce/orders/cron/route.ts b/app/api/extensions/woocommerce/orders/cron/route.ts index 5f5ccd0d..b8b1ac1f 100644 --- a/app/api/extensions/woocommerce/orders/cron/route.ts +++ b/app/api/extensions/woocommerce/orders/cron/route.ts @@ -15,13 +15,13 @@ 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. + * upserts each connected store's orders and refunds into webshop_orders + * (the Orders page), replacing the earlier transactions-inbox feed. * * 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. + * unbooked; booking stays a human decision on the Orders page. Idempotent via + * the (company_id, external_id) unique index; overlap re-polls become status + * updates. 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, @@ -84,8 +84,8 @@ export const GET = withCronContext('cron.woocommerce_order_sync', async (_reques const results: Array<{ connectionId: string - imported: number - duplicates: number + inserted: number + updated: number status: 'synced' | 'revoked' | 'error' }> = [] @@ -109,8 +109,8 @@ export const GET = withCronContext('cron.woocommerce_order_sync', async (_reques } results.push({ connectionId: connection.id, - imported: summary.imported, - duplicates: summary.duplicates, + inserted: summary.inserted, + updated: summary.updated, status: summary.revoked ? 'revoked' : 'synced', }) } catch (error) { @@ -120,19 +120,19 @@ export const GET = withCronContext('cron.woocommerce_order_sync', async (_reques }) results.push({ connectionId: connection.id, - imported: 0, - duplicates: 0, + inserted: 0, + updated: 0, status: 'error', }) } } - const totalImported = results.reduce((acc, r) => acc + r.imported, 0) + const totalInserted = results.reduce((acc, r) => acc + r.inserted, 0) ctx.log.info('woocommerce order sync summary', { processed: results.length, - totalImported, + totalInserted, failed: results.filter((r) => r.status === 'error').length, }) - return NextResponse.json({ processed: results.length, imported: totalImported, results }) + return NextResponse.json({ processed: results.length, inserted: totalInserted, results }) }) diff --git a/app/api/webshop-orders/[id]/book/route.ts b/app/api/webshop-orders/[id]/book/route.ts new file mode 100644 index 00000000..1c3eb8ee --- /dev/null +++ b/app/api/webshop-orders/[id]/book/route.ts @@ -0,0 +1,238 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine' +import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { validateBody } from '@/lib/api/validate' +import { BookWebshopOrderSchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import { roundOre } from '@/lib/money' +import type { Currency, WebshopOrder } from '@/types' + +ensureInitialized() + +/** + * Book one webshop order/refund row as a verifikat. The dialog sends the + * (user-reviewed) lines built by lib/webshop-orders/booking-lines; the server + * re-guards state and routes everything through the engine + * (source_type 'webshop_order'). Period/company locks and balance are + * enforced by the engine + DB triggers as usual. + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'webshop_order.book', + async (request, { supabase, user, companyId, log, requestId }, { params }) => { + const { id } = await params + + const validation = await validateBody(request, BookWebshopOrderSchema) + if (!validation.success) return validation.response + const { fiscal_period_id, entry_date, description, lines, voucher_series, notes } = + validation.data + + const { data: order, error: fetchError } = await supabase + .from('webshop_orders') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !order) { + return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId }) + } + + if (order.journal_entry_id) { + return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { + requestId, + details: { journal_entry_id: order.journal_entry_id }, + }) + } + if (order.invoice_id) { + return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, { + requestId, + details: { invoice_id: order.invoice_id }, + }) + } + // Refunds of an invoiced order belong in the credit-note flow. + if (order.row_type === 'refund' && order.parent_order_id) { + const { data: parent } = await supabase + .from('webshop_orders') + .select('invoice_id') + .eq('id', order.parent_order_id) + .eq('company_id', companyId) + .maybeSingle() + if (parent?.invoice_id) { + return errorResponseFromCode('WEBSHOP_ORDER_REFUND_PARENT_INVOICED', log, { + requestId, + details: { invoice_id: parent.invoice_id }, + }) + } + } + if (!order.is_paid && order.row_type === 'order') { + return errorResponseFromCode('WEBSHOP_ORDER_NOT_PAID', log, { requestId }) + } + + // Double-booking lock against the legacy transactions feed: the same + // money event may already sit in the inbox (imported before the Orders + // switch-over). A booked feed row means this order IS booked via the + // feed; an open one must be booked or IGNORED there first — and an + // ignored row (is_ignored) unlocks order-side booking, exactly as the + // error message instructs. + if (order.legacy_transaction_id) { + const { data: legacyTxn } = await supabase + .from('transactions') + .select('id, journal_entry_id, is_ignored') + .eq('id', order.legacy_transaction_id) + .eq('company_id', companyId) + .maybeSingle() + if (legacyTxn) { + if (legacyTxn.journal_entry_id) { + return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED', log, { + requestId, + details: { + transaction_id: legacyTxn.id, + journal_entry_id: legacyTxn.journal_entry_id, + }, + }) + } + if (!legacyTxn.is_ignored) { + return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN', log, { + requestId, + details: { transaction_id: legacyTxn.id }, + }) + } + } + } + + // Non-SEK rows book in SEK; retry the rate once at booking time before + // refusing (a sync-time Riksbanken hiccup should not strand the order). + if (order.currency.toUpperCase() !== 'SEK' && order.total_sek === null) { + let resolved = false + try { + const rate = await fetchExchangeRate( + order.currency.toUpperCase() as Currency, + new Date(`${order.paid_date ?? order.order_date}T00:00:00Z`), + supabase, + ) + if (rate?.rate) { + const totalSek = roundOre(order.total * rate.rate) + const { error: fxError } = await supabase + .from('webshop_orders') + .update({ total_sek: totalSek, exchange_rate: rate.rate }) + .eq('id', id) + .eq('company_id', companyId) + resolved = !fxError + } + } catch (err) { + log.warn('booking-time FX retry failed', err as Error) + } + if (!resolved) { + return errorResponseFromCode('WEBSHOP_ORDER_FX_UNRESOLVED', log, { + requestId, + details: { currency: order.currency }, + }) + } + } + + // Race-free booking: draft -> atomic claim -> commit. The read-then-book + // pattern let two concurrent requests each post an immutable verifikat + // for the same order (skeptic finding). Instead the order row is claimed + // with a conditional update BEFORE anything gets a voucher number: the + // loser's claim matches zero rows and its draft (no voucher yet, so no + // series gap) is cancelled. + let draft + try { + draft = await createDraftEntry(supabase, companyId, user.id, { + fiscal_period_id, + entry_date, + description, + source_type: 'webshop_order', + source_id: id, + voucher_series, + notes, + lines, + }) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + log.error('failed to draft journal entry for webshop order', err as Error) + return NextResponse.json( + { error: getErrorMessage(err, { context: 'transaction' }) }, + { status: 400 }, + ) + } + + const cancelDraft = async () => { + const { error: cancelError } = await supabase + .from('journal_entries') + .update({ status: 'cancelled' }) + .eq('id', draft.id) + .eq('status', 'draft') + if (cancelError) { + log.error('draft cleanup failed after claim/commit failure', cancelError, { + entryId: draft.id, + }) + } + } + + // The claim guards BOTH links: a concurrent create-invoice between our + // read and this update must lose too (mutual exclusivity, not just + // no-double-booking). + const { data: claimed, error: claimError } = await supabase + .from('webshop_orders') + .update({ journal_entry_id: draft.id }) + .eq('id', id) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .is('invoice_id', null) + .select('id') + if (claimError || !claimed || claimed.length === 0) { + await cancelDraft() + if (claimError) { + log.error('webshop order claim failed', claimError, { orderId: id }) + return NextResponse.json( + { error: getErrorMessage(claimError, { context: 'transaction' }) }, + { status: 500 }, + ) + } + // Zero rows matched: someone else booked it between our read and claim. + return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { requestId }) + } + + let journalEntry + try { + journalEntry = await commitEntry(supabase, companyId, user.id, draft.id) + } catch (err) { + // Unlink so the row does not point at a cancelled draft, then cancel. + // Order matters: the financial-freeze trigger keys on journal_entry_id + // being set, but journal_entry_id itself is not in its protected list, + // so the unlink passes. + await supabase + .from('webshop_orders') + .update({ journal_entry_id: null }) + .eq('id', id) + .eq('company_id', companyId) + .eq('journal_entry_id', draft.id) + await cancelDraft() + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + log.error('failed to commit journal entry for webshop order', err as Error) + return NextResponse.json( + { error: getErrorMessage(err, { context: 'transaction' }) }, + { status: 400 }, + ) + } + + // No extra event here: commitEntry() already emits + // journal_entry.committed from inside the engine. + + return NextResponse.json({ + data: journalEntry, + // commitEntry's post-commit fetch can theoretically return no row; + // the entry still exists under draft.id. + journal_entry_id: journalEntry?.id ?? draft.id, + success: true, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/webshop-orders/[id]/create-invoice/route.ts b/app/api/webshop-orders/[id]/create-invoice/route.ts new file mode 100644 index 00000000..9d7cd1e1 --- /dev/null +++ b/app/api/webshop-orders/[id]/create-invoice/route.ts @@ -0,0 +1,307 @@ +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { CreateInvoiceFromWebshopOrderSchema } from '@/lib/api/schemas' +import { buildInvoiceWriteData, type InvoiceWriteInput } from '@/lib/invoices/build-invoice-write' +import { roundOre } from '@/lib/money' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { Currency, Customer, Invoice, WebshopOrder } from '@/types' + +ensureInitialized() + +/** + * Convert one webshop order into a pre-filled DRAFT kundfaktura. Manual, + * per-order, never automatic (invoice-of-record stays the user's decision): + * the user reviews the draft and sends it through the normal invoice flow, + * which also books it. Follows the invoice-inbox convert pattern: guard + * already-converted, map items, reuse the shared invoice write path, link + * back, roll back the draft if the link-back fails. + * + * Numbering: none here (unnumbered draft, save_as_draft semantics). The + * F-number is allocated at finalize/send, so an abandoned draft can be + * hard-deleted without an F-series gap (ML 17 kap 24§). + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'webshop_order.create_invoice', + async (request, { supabase, user, companyId, log, requestId }, { params }) => { + const { id } = await params + + const validation = await validateBody(request, CreateInvoiceFromWebshopOrderSchema) + if (!validation.success) return validation.response + const { customer_id } = validation.data + + const { data: order, error: fetchError } = await supabase + .from('webshop_orders') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !order) { + return errorResponseFromCode('WEBSHOP_ORDER_NOT_FOUND', log, { requestId }) + } + if (order.invoice_id) { + return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, { + requestId, + details: { invoice_id: order.invoice_id }, + }) + } + if (order.journal_entry_id) { + return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_BOOKED', log, { + requestId, + details: { journal_entry_id: order.journal_entry_id }, + }) + } + // Refund rows never convert (kreditfaktura is created from the invoice). + if (order.row_type === 'refund') { + return errorResponseFromCode('WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE', log, { requestId }) + } + // Same legacy double-booking lock as the book route: an invoice created + // for a sale whose feed row is later booked in the transactions inbox + // would double-count the revenue. An ignored feed row unlocks the path. + if (order.legacy_transaction_id) { + const { data: legacyTxn } = await supabase + .from('transactions') + .select('id, journal_entry_id, is_ignored') + .eq('id', order.legacy_transaction_id) + .eq('company_id', companyId) + .maybeSingle() + if (legacyTxn) { + if (legacyTxn.journal_entry_id) { + return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED', log, { + requestId, + details: { transaction_id: legacyTxn.id }, + }) + } + if (!legacyTxn.is_ignored) { + return errorResponseFromCode('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN', log, { + requestId, + details: { transaction_id: legacyTxn.id }, + }) + } + } + } + + // Resolve the customer: explicit id, else match by email within the + // company, else create from the order's billing snapshot. The orgnr is + // best-effort scraped data and is deliberately NOT written to a customer + // the user did not review; the dialog shows it as a review hint. + let customer: Customer | null = null + if (customer_id) { + const { data } = await supabase + .from('customers') + .select('*') + .eq('id', customer_id) + .eq('company_id', companyId) + .single() + if (!data) { + return errorResponseFromCode('CUSTOMER_NOT_FOUND', log, { + requestId, + details: { customerId: customer_id }, + }) + } + customer = data + } else { + if (order.customer_email) { + const { data } = await supabase + .from('customers') + .select('*') + .eq('company_id', companyId) + .ilike('email', order.customer_email) + .limit(1) + .maybeSingle() + customer = data ?? null + } + if (!customer) { + const name = order.customer_company || order.customer_name + if (!name) { + return errorResponseFromCode('WEBSHOP_ORDER_CREATE_INVOICE_MISSING_CUSTOMER', log, { + requestId, + }) + } + // The scraped orgnr is deliberately NOT written to the customer's + // legal org_number field: it is best-effort store data and must be + // user-confirmed on the customer card before it lands anywhere legal + // (Swedish compliance review, PR #1525). The dialog shows it as a + // review hint. + const { data: created, error: createError } = await supabase + .from('customers') + .insert({ + company_id: companyId, + user_id: user.id, + name, + customer_type: order.customer_company ? 'business' : 'individual', + contact_person: order.customer_company ? order.customer_name : null, + email: order.customer_email, + }) + .select('*') + .single() + if (createError || !created) { + log.error('failed to create customer from webshop order', createError ?? undefined, { + orderId: id, + }) + return errorResponseFromCode('WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED', log, { + requestId, + }) + } + customer = created + try { + await eventBus.emit({ + type: 'customer.created', + payload: { customer, userId: user.id, companyId }, + }) + } catch { + // Non-critical + } + } + } + + // Items from the order's line snapshot (products + shipping + fees; the + // sync stores everything inside order.total). Where the net does not + // divide evenly by the quantity, the line collapses to quantity 1 at its + // exact total: buildInvoiceWriteData recomputes + // line_total = quantity * unit_price, and an öre of division drift would + // make the invoice total diverge from what the customer actually paid. + // Rate fallback only when the order is UNAMBIGUOUS (a single VAT bucket): + // on mixed-rate orders a "dominant" guess could stamp the wrong + // tillämpad skattesats on a line (ML 17 kap 24 §); those lines fall back + // to the customer-type default and the draft review is the gate. + const singleRate = + order.vat_breakdown.length === 1 ? order.vat_breakdown[0].rate : undefined + const items: InvoiceWriteInput['items'] = + order.line_items.length > 0 + ? order.line_items.map((item) => { + const unitPrice = + item.quantity > 0 ? roundOre(item.total / item.quantity) : item.total + const dividesEvenly = + item.quantity > 0 && roundOre(unitPrice * item.quantity) === item.total + return dividesEvenly + ? { + description: item.name, + quantity: item.quantity, + unit: 'st', + unit_price: unitPrice, + vat_rate: item.vat_rate ?? singleRate, + } + : { + description: + item.quantity > 1 ? `${item.name} (${item.quantity} st)` : item.name, + quantity: 1, + unit: 'st', + unit_price: item.total, + vat_rate: item.vat_rate ?? singleRate, + } + }) + : [ + { + description: `Order ${order.order_number}`, + quantity: 1, + unit: 'st', + unit_price: roundOre(order.total - order.total_tax), + vat_rate: singleRate, + }, + ] + + const invoiceDate = new Date().toISOString().split('T')[0] + const paymentTermsDays = customer.default_payment_terms ?? 30 + const due = new Date() + due.setDate(due.getDate() + paymentTermsDays) + const dueDate = due.toISOString().split('T')[0] + + const input: InvoiceWriteInput = { + customer_id: customer.id, + invoice_date: invoiceDate, + due_date: dueDate, + currency: order.currency.toUpperCase() as Currency, + your_reference: order.customer_name ?? undefined, + notes: `Webshoporder ${order.order_number} (${order.store_label || order.store_scope})`, + items, + } + + const build = await buildInvoiceWriteData({ + supabase, + companyId, + customer, + documentType: 'invoice', + input, + }) + if (!build.ok) { + if ('dbError' in build) { + log.error('invoice write build failed on a DB lookup', build.dbError as Error) + return errorResponse(build.dbError, log, { requestId }) + } + return errorResponseFromCode(build.code, log, { requestId, details: build.details }) + } + + const { data: invoice, error: invoiceError } = await supabase + .from('invoices') + .insert({ + user_id: user.id, + company_id: companyId, + invoice_number: null, + ...build.invoiceFields, + }) + .select() + .single() + + if (invoiceError || !invoice) { + log.error('invoice insert from webshop order failed', invoiceError ?? undefined) + return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, { + requestId, + details: { pgCode: invoiceError?.code }, + }) + } + + const itemRows = build.items.map((item) => ({ ...item, invoice_id: invoice.id })) + const { error: itemsError } = await supabase.from('invoice_items').insert(itemRows) + if (itemsError) { + await supabase.from('invoices').delete().eq('id', invoice.id) + log.error('invoice items insert failed; rolled back draft', itemsError, { + invoiceId: invoice.id, + }) + return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, { + requestId, + details: { pgCode: itemsError.code }, + }) + } + + // Link back; roll back the draft on failure so no orphan invoice exists + // without its order marked (unnumbered draft: hard delete leaves no gap). + // Guards BOTH links (mutual exclusivity with a concurrent booking) and + // treats zero matched rows as the conflict it is: another request linked + // first, and answering success would leave a duplicate sendable draft. + const { data: linked, error: linkError } = await supabase + .from('webshop_orders') + .update({ invoice_id: invoice.id }) + .eq('id', id) + .eq('company_id', companyId) + .is('invoice_id', null) + .is('journal_entry_id', null) + .select('id') + if (linkError || !linked || linked.length === 0) { + await supabase.from('invoice_items').delete().eq('invoice_id', invoice.id) + await supabase.from('invoices').delete().eq('id', invoice.id) + log.error('order link-back failed; rolled back draft invoice', linkError ?? undefined, { + orderId: id, + invoiceId: invoice.id, + }) + if (linkError) return errorResponse(linkError, log, { requestId }) + // Zero rows matched: the order was invoiced or booked concurrently. + return errorResponseFromCode('WEBSHOP_ORDER_ALREADY_INVOICED', log, { requestId }) + } + + try { + await eventBus.emit({ + type: 'invoice.created', + payload: { invoice: invoice as Invoice, userId: user.id, companyId }, + }) + } catch { + // Non-critical + } + + return NextResponse.json({ data: invoice, invoice_id: invoice.id, success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/webshop-orders/__tests__/book.test.ts b/app/api/webshop-orders/__tests__/book.test.ts new file mode 100644 index 00000000..48b32a34 --- /dev/null +++ b/app/api/webshop-orders/__tests__/book.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeJournalEntry, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = + createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const mockCreateDraftEntry = vi.fn() +const mockCommitEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createDraftEntry: (...args: unknown[]) => mockCreateDraftEntry(...args), + commitEntry: (...args: unknown[]) => mockCommitEntry(...args), +})) + +const mockFetchExchangeRate = vi.fn() +vi.mock('@/lib/currency/riksbanken', () => ({ + fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args), +})) + +import { POST } from '../[id]/book/route' + +const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000' + +function makeOrderRow(overrides: Record = {}) { + return { + id: 'order-1', + company_id: 'company-1', + row_type: 'order', + parent_order_id: null, + external_id: 'woo_butik.example.se_order_1001', + order_number: '1001', + status: 'processing', + is_paid: true, + order_date: '2026-08-01', + paid_date: '2026-08-01', + currency: 'SEK', + total: 500, + total_tax: 100, + total_sek: 500, + exchange_rate: 1, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + line_items: [], + payment_method: 'swish', + journal_entry_id: null, + invoice_id: null, + legacy_transaction_id: null, + ...overrides, + } +} + +const validBody = { + fiscal_period_id: PERIOD_UUID, + entry_date: '2026-08-01', + description: 'Order 1001 (Swish)', + lines: [ + { account_number: '1930', debit_amount: 500, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 400 }, + { account_number: '2611', debit_amount: 0, credit_amount: 100 }, + ], +} + +function postBook(body: unknown = validBody, id = 'order-1') { + const request = createMockRequest(`/api/webshop-orders/${id}/book`, { + method: 'POST', + body, + }) + return POST(request, createMockRouteParams({ id })) +} + +describe('POST /api/webshop-orders/[id]/book', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + mockCreateDraftEntry.mockResolvedValue(makeJournalEntry({ id: 'draft-1', status: 'draft' })) + mockCommitEntry.mockResolvedValue(makeJournalEntry({ id: 'je-1' })) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const { status } = await parseJsonResponse(await postBook()) + expect(status).toBe(401) + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + }) + + it('returns 403 when the caller is a viewer (requireWrite)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const { status } = await parseJsonResponse(await postBook()) + expect(status).toBe(403) + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + }) + + it('returns 400 on invalid body', async () => { + const { status } = await parseJsonResponse( + await postBook({ fiscal_period_id: PERIOD_UUID }), + ) + expect(status).toBe(400) + }) + + it('returns 404 when the order does not exist for the company', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(404) + expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND') + }) + + it('returns 409 when already booked', async () => { + enqueue({ data: makeOrderRow({ journal_entry_id: 'je-9' }) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED') + }) + + it('returns 409 when linked to an invoice', async () => { + enqueue({ data: makeOrderRow({ invoice_id: 'inv-1' }) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED') + }) + + it('returns 409 for unpaid orders', async () => { + enqueue({ data: makeOrderRow({ is_paid: false, paid_date: null }) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_PAID') + }) + + it('returns 409 when the legacy feed transaction is still open', async () => { + enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) }) + enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: false } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN') + expect(mockCreateDraftEntry).not.toHaveBeenCalled() + }) + + it('returns 409 when the legacy feed transaction is already booked', async () => { + enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) }) + enqueue({ data: { id: 'txn-1', journal_entry_id: 'je-77', is_ignored: false } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED') + }) + + it('books when the legacy feed transaction was IGNORED (the 409 message honored)', async () => { + enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) }) + enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: true } }) // legacy check + enqueue({ data: [{ id: 'order-1' }] }) // claim + const { status } = await parseJsonResponse(await postBook()) + expect(status).toBe(200) + expect(mockCommitEntry).toHaveBeenCalled() + }) + + it('returns 422 when a non-SEK order has no rate and the retry fails', async () => { + enqueue({ data: makeOrderRow({ currency: 'EUR', total_sek: null, exchange_rate: null }) }) + mockFetchExchangeRate.mockResolvedValue(null) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(422) + expect(body.error.code).toBe('WEBSHOP_ORDER_FX_UNRESOLVED') + }) + + it('drafts, claims atomically, then commits with source_type webshop_order', async () => { + enqueue({ data: makeOrderRow() }) // fetch + enqueue({ data: [{ id: 'order-1' }] }) // claim matched one row + const { status, body } = await parseJsonResponse<{ journal_entry_id: string }>( + await postBook(), + ) + expect(status).toBe(200) + expect(body.journal_entry_id).toBe('je-1') + expect(mockCreateDraftEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + source_type: 'webshop_order', + source_id: 'order-1', + fiscal_period_id: PERIOD_UUID, + }), + ) + expect(mockCommitEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'draft-1', + ) + }) + + it('returns 409 and cancels the draft when another request wins the claim', async () => { + enqueue({ data: makeOrderRow() }) // fetch (sees unbooked) + enqueue({ data: [] }) // claim matched ZERO rows: raced + enqueue({ data: null }) // draft cancel update + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED') + expect(mockCommitEntry).not.toHaveBeenCalled() + const cancel = findCall('journal_entries', 'update') + expect(cancel).toBeDefined() + expect((cancel![0] as Record).status).toBe('cancelled') + }) + + it('unlinks and cancels the draft when the commit fails', async () => { + mockCommitEntry.mockRejectedValueOnce(new Error('period locked')) + enqueue({ data: makeOrderRow() }) // fetch + enqueue({ data: [{ id: 'order-1' }] }) // claim + enqueue({ data: null }) // unlink + enqueue({ data: null }) // cancel draft + const { status } = await parseJsonResponse(await postBook()) + expect(status).toBeGreaterThanOrEqual(400) + const orderUpdates = findCalls('webshop_orders', 'update') + expect( + orderUpdates.some( + (args) => (args[0] as Record).journal_entry_id === null, + ), + ).toBe(true) + }) + + it('books a refund row whose parent is not invoiced', async () => { + enqueue({ + data: makeOrderRow({ + row_type: 'refund', + parent_order_id: 'parent-1', + total: -500, + total_sek: -500, + }), + }) + enqueue({ data: { invoice_id: null } }) // parent check + enqueue({ data: [{ id: 'order-1' }] }) // claim + const { status } = await parseJsonResponse(await postBook()) + expect(status).toBe(200) + }) + + it('refuses a refund row whose parent was invoiced', async () => { + enqueue({ + data: makeOrderRow({ + row_type: 'refund', + parent_order_id: 'parent-1', + total: -500, + total_sek: -500, + }), + }) + enqueue({ data: { invoice_id: 'inv-5' } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postBook(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_REFUND_PARENT_INVOICED') + }) +}) diff --git a/app/api/webshop-orders/__tests__/create-invoice.test.ts b/app/api/webshop-orders/__tests__/create-invoice.test.ts new file mode 100644 index 00000000..468c8062 --- /dev/null +++ b/app/api/webshop-orders/__tests__/create-invoice.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const mockBuildInvoiceWriteData = vi.fn() +vi.mock('@/lib/invoices/build-invoice-write', () => ({ + buildInvoiceWriteData: (...args: unknown[]) => mockBuildInvoiceWriteData(...args), +})) + +import { POST } from '../[id]/create-invoice/route' + +function makeOrderRow(overrides: Record = {}) { + return { + id: 'order-1', + company_id: 'company-1', + row_type: 'order', + external_id: 'woo_butik.example.se_order_1001', + order_number: '1001', + status: 'processing', + is_paid: false, + order_date: '2026-08-01', + paid_date: null, + currency: 'SEK', + total: 500, + total_tax: 100, + total_sek: 500, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + line_items: [ + { name: 'Produkt A', quantity: 2, total: 400, total_tax: 100, vat_rate: 25 }, + ], + customer_name: 'Test Person', + customer_company: 'Testbolaget AB', + customer_email: 'kund@example.se', + customer_orgnr: '556677-8899', + payment_method: 'bacs', + payment_method_title: 'Faktura', + journal_entry_id: null, + invoice_id: null, + legacy_transaction_id: null, + store_label: 'Butiken', + store_scope: 'butik.example.se', + ...overrides, + } +} + +const okBuild = { + ok: true, + invoiceFields: { + customer_id: 'cust-1', + invoice_date: '2026-08-10', + due_date: '2026-09-09', + currency: 'SEK', + subtotal: 400, + vat_amount: 100, + total: 500, + }, + items: [ + { + sort_order: 0, + description: 'Produkt A', + quantity: 2, + unit: 'st', + unit_price: 200, + line_total: 400, + vat_rate: 25, + vat_amount: 100, + }, + ], +} + +function postCreate(body: unknown = {}, id = 'order-1') { + const request = createMockRequest(`/api/webshop-orders/${id}/create-invoice`, { + method: 'POST', + body, + }) + return POST(request, createMockRouteParams({ id })) +} + +describe('POST /api/webshop-orders/[id]/create-invoice', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + mockBuildInvoiceWriteData.mockResolvedValue(okBuild) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const { status } = await parseJsonResponse(await postCreate()) + expect(status).toBe(401) + }) + + it('returns 404 when the order is missing', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postCreate(), + ) + expect(status).toBe(404) + expect(body.error.code).toBe('WEBSHOP_ORDER_NOT_FOUND') + }) + + it('returns 409 when already invoiced', async () => { + enqueue({ data: makeOrderRow({ invoice_id: 'inv-1' }) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postCreate(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED') + }) + + it('returns 409 when already booked', async () => { + enqueue({ data: makeOrderRow({ journal_entry_id: 'je-1' }) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postCreate(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_BOOKED') + }) + + it('returns 422 when the order carries no customer data and none is chosen', async () => { + enqueue({ + data: makeOrderRow({ + customer_name: null, + customer_company: null, + customer_email: null, + }), + }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postCreate(), + ) + expect(status).toBe(422) + expect(body.error.code).toBe('WEBSHOP_ORDER_CREATE_INVOICE_MISSING_CUSTOMER') + }) + + it('creates an unnumbered draft from a matched customer and links back', async () => { + enqueue({ data: makeOrderRow() }) // order fetch + enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB', customer_type: 'business' } }) // email match + enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } }) // invoices insert + enqueue({ data: null }) // invoice_items insert + enqueue({ data: [{ id: 'order-1' }] }) // order link-back matched + + const { status, body } = await parseJsonResponse<{ invoice_id: string }>( + await postCreate(), + ) + expect(status).toBe(200) + expect(body.invoice_id).toBe('inv-1') + + const invoiceInsert = findCall('invoices', 'insert') + expect(invoiceInsert).toBeDefined() + expect((invoiceInsert![0] as Record).invoice_number).toBeNull() + expect(mockBuildInvoiceWriteData).toHaveBeenCalledWith( + expect.objectContaining({ documentType: 'invoice' }), + ) + const linkUpdate = findCall('webshop_orders', 'update') + expect(linkUpdate).toBeDefined() + expect((linkUpdate![0] as Record).invoice_id).toBe('inv-1') + }) + + it('creates a customer from the order billing data when none matches', async () => { + enqueue({ data: makeOrderRow() }) // order fetch + enqueue({ data: null }) // email match: none + enqueue({ data: { id: 'cust-new', name: 'Testbolaget AB', customer_type: 'business' } }) // customer insert + enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } }) + enqueue({ data: null }) // items + enqueue({ data: [{ id: 'order-1' }] }) // link-back matched + + const { status } = await parseJsonResponse(await postCreate()) + expect(status).toBe(200) + const customerInsert = findCall('customers', 'insert') + expect(customerInsert).toBeDefined() + expect(customerInsert![0]).toMatchObject({ + name: 'Testbolaget AB', + customer_type: 'business', + contact_person: 'Test Person', + }) + // Scraped orgnr must NOT auto-land on the customer's legal field + // (Swedish compliance review): the dialog shows it for manual review. + expect(customerInsert![0]).not.toHaveProperty('org_number') + }) + + it('rolls back the draft when the order link-back fails', async () => { + enqueue({ data: makeOrderRow() }) + enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } }) + enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } }) + enqueue({ data: null }) // items insert ok + enqueue({ data: null, error: { message: 'link failed' } }) // link-back DB error + enqueue({ data: null }) // items delete + enqueue({ data: null }) // invoice delete + + const { status } = await parseJsonResponse(await postCreate()) + expect(status).toBeGreaterThanOrEqual(500) + const deletes = findCall('invoices', 'delete') + expect(deletes).toBeDefined() + }) + + it('refuses refund rows', async () => { + enqueue({ data: makeOrderRow({ row_type: 'refund' }) }) + const { status } = await parseJsonResponse(await postCreate()) + expect(status).toBe(409) + }) + + it('returns 409 while the legacy feed transaction is open (double-booking lock)', async () => { + enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) }) + enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: false } }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postCreate(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN') + expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled() + }) + + it('creates the draft when the legacy feed transaction was ignored', async () => { + enqueue({ data: makeOrderRow({ legacy_transaction_id: 'txn-1' }) }) + enqueue({ data: { id: 'txn-1', journal_entry_id: null, is_ignored: true } }) + enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } }) // email match + enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } }) + enqueue({ data: null }) // items + enqueue({ data: [{ id: 'order-1' }] }) // link-back matched + const { status } = await parseJsonResponse(await postCreate()) + expect(status).toBe(200) + }) + + it('returns 409 and rolls back when the link-back matches zero rows (raced)', async () => { + enqueue({ data: makeOrderRow() }) + enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } }) + enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } }) + enqueue({ data: null }) // items insert ok + enqueue({ data: [] }) // link-back matched ZERO rows + enqueue({ data: null }) // items delete + enqueue({ data: null }) // invoice delete + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await postCreate(), + ) + expect(status).toBe(409) + expect(body.error.code).toBe('WEBSHOP_ORDER_ALREADY_INVOICED') + expect(findCall('invoices', 'delete')).toBeDefined() + }) + + it('returns 403 when the caller is a viewer (requireWrite)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const { status } = await parseJsonResponse(await postCreate()) + expect(status).toBe(403) + expect(mockBuildInvoiceWriteData).not.toHaveBeenCalled() + }) + + it('collapses non-divisible lines to quantity 1 and applies the single-bucket rate', async () => { + enqueue({ + data: makeOrderRow({ + line_items: [ + { name: 'Produkt B', quantity: 3, total: 100, total_tax: 25, vat_rate: null }, + ], + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + }), + }) + enqueue({ data: { id: 'cust-1', name: 'Testbolaget AB' } }) + enqueue({ data: { id: 'inv-1', status: 'draft', invoice_number: null } }) + enqueue({ data: null }) + enqueue({ data: [{ id: 'order-1' }] }) + const { status } = await parseJsonResponse(await postCreate()) + expect(status).toBe(200) + const input = mockBuildInvoiceWriteData.mock.calls[0][0] as { + input: { items: Array> } + } + // 100/3 does not divide evenly in öre: exact total at quantity 1 instead + // of 3 x 33.33 = 99.99 silently shrinking the invoice. + expect(input.input.items[0]).toMatchObject({ + quantity: 1, + unit_price: 100, + description: 'Produkt B (3 st)', + vat_rate: 25, + }) + }) +}) diff --git a/app/api/webshop-orders/__tests__/list-and-settings.test.ts b/app/api/webshop-orders/__tests__/list-and-settings.test.ts new file mode 100644 index 00000000..4a366aff --- /dev/null +++ b/app/api/webshop-orders/__tests__/list-and-settings.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET as listOrders } from '../route' +import { GET as getSettings, PUT as putSettings } from '../settings/route' + +describe('GET /api/webshop-orders', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await listOrders(createMockRequest('/api/webshop-orders')) + expect(response.status).toBe(401) + }) + + it('returns 400 on an invalid filter value', async () => { + const response = await listOrders( + createMockRequest('/api/webshop-orders?paid=maybe'), + ) + expect(response.status).toBe(400) + }) + + it('lists rows with a deduped store facet', async () => { + enqueue({ + data: [{ id: 'o1' }, { id: 'o2' }], + count: 2, + }) + enqueue({ + data: [ + { platform: 'woocommerce', store_scope: 'a.se', store_label: 'A' }, + { platform: 'woocommerce', store_scope: 'a.se', store_label: 'A' }, + { platform: 'woocommerce', store_scope: 'b.se', store_label: 'B' }, + ], + }) + const { status, body } = await parseJsonResponse<{ + data: unknown[] + count: number + stores: Array<{ store_scope: string }> + }>(await listOrders(createMockRequest('/api/webshop-orders?paid=paid&booked=unbooked'))) + + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + expect(body.stores.map((s) => s.store_scope)).toEqual(['a.se', 'b.se']) + }) +}) + +describe('GET|PUT /api/webshop-orders/settings', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated (GET and PUT)', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const getRes = await getSettings(createMockRequest('/api/webshop-orders/settings')) + expect(getRes.status).toBe(401) + const putRes = await putSettings( + createMockRequest('/api/webshop-orders/settings', { + method: 'PUT', + body: { + platform: 'woocommerce', + store_scope: 'a.se', + payment_method_account_map: {}, + }, + }), + ) + expect(putRes.status).toBe(401) + }) + + it('PUT returns 403 for viewers (requireWrite)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await putSettings( + createMockRequest('/api/webshop-orders/settings', { + method: 'PUT', + body: { + platform: 'woocommerce', + store_scope: 'a.se', + payment_method_account_map: {}, + }, + }), + ) + expect(res.status).toBe(403) + }) + + it('GET returns the stored mappings', async () => { + enqueue({ + data: [ + { + platform: 'woocommerce', + store_scope: 'a.se', + payment_method_account_map: { swish: { mode: 'book', account: '1930' } }, + }, + ], + }) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>( + await getSettings(createMockRequest('/api/webshop-orders/settings?platform=woocommerce')), + ) + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + }) + + it('PUT rejects an invalid account number', async () => { + const response = await putSettings( + createMockRequest('/api/webshop-orders/settings', { + method: 'PUT', + body: { + platform: 'woocommerce', + store_scope: 'a.se', + payment_method_account_map: { swish: { mode: 'book', account: 'not-an-account' } }, + }, + }), + ) + expect(response.status).toBe(400) + }) + + it('PUT upserts on (company, platform, store_scope)', async () => { + enqueue({ + data: { + platform: 'woocommerce', + store_scope: 'a.se', + payment_method_account_map: { swish: { mode: 'book', account: '1930' } }, + }, + }) + const { status } = await parseJsonResponse( + await putSettings( + createMockRequest('/api/webshop-orders/settings', { + method: 'PUT', + body: { + platform: 'woocommerce', + store_scope: 'a.se', + payment_method_account_map: { + swish: { mode: 'book', account: '1930' }, + bacs: { mode: 'invoice' }, + }, + }, + }), + ), + ) + expect(status).toBe(200) + const upsert = findCall('webshop_store_settings', 'upsert') + expect(upsert).toBeDefined() + expect((upsert![0] as Record).company_id).toBe('company-1') + }) +}) diff --git a/app/api/webshop-orders/route.ts b/app/api/webshop-orders/route.ts new file mode 100644 index 00000000..65151011 --- /dev/null +++ b/app/api/webshop-orders/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { WebshopOrdersListQuerySchema } from '@/lib/api/schemas' +import { errorResponse } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +const DEFAULT_LIMIT = 50 + +/** + * List webshop order rows for the Orders page, with a store facet for the + * per-shop filter. Refund rows are returned inline (they are independent + * bookable rows) and grouped client-side under their parent. + */ +export const GET = withRouteContext( + 'webshop_order.list', + async (request, { supabase, companyId, log, requestId }) => { + const validation = validateQuery(request, WebshopOrdersListQuerySchema) + if (!validation.success) return validation.response + const { platform, store_scope, status, row_type, paid, booked } = validation.data + const limit = validation.data.limit ?? DEFAULT_LIMIT + const offset = validation.data.offset ?? 0 + + let query = supabase + .from('webshop_orders') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .order('order_date', { ascending: false }) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) + + if (platform) query = query.eq('platform', platform) + if (store_scope) query = query.eq('store_scope', store_scope) + if (status) query = query.eq('status', status) + if (row_type) query = query.eq('row_type', row_type) + if (paid) query = query.eq('is_paid', paid === 'paid') + if (booked === 'booked') query = query.not('journal_entry_id', 'is', null) + if (booked === 'unbooked') query = query.is('journal_entry_id', null) + + const { data, error, count } = await query + if (error) { + log.error('failed to list webshop orders', error) + return errorResponse(error, log, { requestId }) + } + + // Store facet for the filter dropdown: cheap distinct over the company's + // stores (small cardinality; the select is capped defensively). + const { data: facetRows, error: facetError } = await supabase + .from('webshop_orders') + .select('platform, store_scope, store_label') + .eq('company_id', companyId) + .eq('row_type', 'order') + .order('store_scope') + .limit(5000) + if (facetError) { + log.error('failed to build store facet', facetError) + return errorResponse(facetError, log, { requestId }) + } + const seen = new Set() + const stores: Array<{ platform: string; store_scope: string; store_label: string | null }> = [] + for (const row of facetRows ?? []) { + const key = `${row.platform}:${row.store_scope}` + if (seen.has(key)) continue + seen.add(key) + stores.push(row) + } + + return NextResponse.json({ data: data ?? [], count, stores }) + }, +) diff --git a/app/api/webshop-orders/settings/route.ts b/app/api/webshop-orders/settings/route.ts new file mode 100644 index 00000000..25c0947a --- /dev/null +++ b/app/api/webshop-orders/settings/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { WebshopStoreSettingsUpdateSchema } from '@/lib/api/schemas' +import { errorResponse } from '@/lib/errors/get-structured-error' + +ensureInitialized() + +/** + * Per-store payment-method -> account mapping (webshop_store_settings). + * The mapping only PREFILLS the booking dialog; nothing here auto-books. + */ +export const GET = withRouteContext( + 'webshop_order.settings.read', + async (request, { supabase, companyId, log, requestId }) => { + const { searchParams } = new URL(request.url) + const platform = searchParams.get('platform') + const storeScope = searchParams.get('store_scope') + + let query = supabase + .from('webshop_store_settings') + .select('*') + .eq('company_id', companyId) + if (platform) query = query.eq('platform', platform) + if (storeScope) query = query.eq('store_scope', storeScope) + + const { data, error } = await query + if (error) { + log.error('failed to read webshop store settings', error) + return errorResponse(error, log, { requestId }) + } + return NextResponse.json({ data: data ?? [] }) + }, +) + +export const PUT = withRouteContext( + 'webshop_order.settings.update', + async (request, { supabase, user, companyId, log, requestId }) => { + const validation = await validateBody(request, WebshopStoreSettingsUpdateSchema) + if (!validation.success) return validation.response + const { platform, store_scope, payment_method_account_map } = validation.data + + const { data, error } = await supabase + .from('webshop_store_settings') + .upsert( + { + company_id: companyId, + user_id: user.id, + platform, + store_scope, + payment_method_account_map, + }, + { onConflict: 'company_id,platform,store_scope' }, + ) + .select() + .single() + + if (error) { + log.error('failed to upsert webshop store settings', error) + return errorResponse(error, log, { requestId }) + } + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 0429901c..7a3d5a02 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -45,6 +45,7 @@ import { PanelLeftClose, Library, BookCheck, + ShoppingCart, } from 'lucide-react' import { getBranding } from '@/lib/branding/service' import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' @@ -81,6 +82,10 @@ interface DashboardNavProps { // switched on. Drives visibility of the Kostnadsställen & projekt row: // same mechanism as paysSalaries: fetched by the dashboard layout. dimensionsEnabled?: boolean + // Whether the company has a webshop hooked up (active WooCommerce/Shopify + // connection, or existing webshop_orders rows). Drives visibility of the + // Order row: same mechanism as paysSalaries, fetched by the layout. + hasWebshop?: boolean isSandbox?: boolean extensionNavItems?: ExtensionNavItem[] // Signed-in user's full name + email: drives the bottom-left account @@ -101,6 +106,7 @@ type NavLabelKey = | 'kpi' | 'invoice_inbox' | 'invoices' + | 'sales_orders' | 'customers' | 'articles' | 'supplier_invoices' @@ -162,6 +168,10 @@ interface NavItem { // company_settings.dimensions_enabled (UI-visibility gate only; the pages // and APIs work regardless, dimensions plan §2). requiresDimensions?: boolean + // Webshop surfaces: visible only when the company has an active + // WooCommerce/Shopify connection or already-imported order rows. + // UI-visibility gate only; the page and APIs work regardless. + requiresWebshop?: boolean // Paywall surfaces: hidden unless the active company holds this paid // capability. Cosmetic only, the page and API gates are the real // enforcement; this just keeps the sidebar honest for non-payers. @@ -191,6 +201,11 @@ const navItems: NavItem[] = [ { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' }, { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' }, { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' }, + // Webshop orders: visible only for companies that actually have a webshop + // hooked up (active WooCommerce/Shopify connection or existing order rows). + // Deliberately NOT capability-gated: a company whose entitlement lapsed + // must still reach its already-imported orders (accounting underlag). + { href: '/orders', labelKey: 'sales_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true }, { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' }, { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true }, // Körjournal is deliberately hidden from the nav; the /mileage route stays live. @@ -268,7 +283,7 @@ const groupLabelKey: Record, string> = { skatt: 'group_tax', } -export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { +export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, hasWebshop = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = useRealtimeSupabase() @@ -485,6 +500,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa // Dimension surfaces are hidden until the company opts in via the // bookkeeping settings toggle (company_settings.dimensions_enabled). if (item.requiresDimensions && !dimensionsEnabled) return false + // Webshop surfaces are hidden until a store is connected (or order rows + // already exist from a since-disconnected store). + if (item.requiresWebshop && !hasWebshop) return false // Paywalled surfaces (e.g. the AI-only Dokumentinkorg) are hidden unless // the active company holds the capability. The page + API gates enforce // the paywall; this keeps the sidebar from advertising a dead workspace. diff --git a/components/orders/CreateInvoiceFromOrderDialog.tsx b/components/orders/CreateInvoiceFromOrderDialog.tsx new file mode 100644 index 00000000..29319109 --- /dev/null +++ b/components/orders/CreateInvoiceFromOrderDialog.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { VTD_CLASS, VTH_CLASS } from '@/components/ui/dry-table' +import { cn, formatCurrency } from '@/lib/utils' +import type { WebshopOrder } from '@/types' + +interface CreateInvoiceFromOrderDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + order: WebshopOrder + onCreated: () => void +} + +/** + * Order -> DRAFT kundfaktura (confirm up front, convention 10): the dialog + * states exactly what will happen, the user confirms, and lands in the draft + * to review before sending. The customer is matched by e-mail or created from + * the order's billing snapshot server-side; the scraped orgnr is shown here + * for the user to eyeball since it never auto-lands on legal invoice fields. + */ +export default function CreateInvoiceFromOrderDialog({ + open, + onOpenChange, + order, + onCreated, +}: CreateInvoiceFromOrderDialogProps) { + const t = useTranslations('webshop_orders') + const locale = useLocale() as ErrorLocale + const router = useRouter() + const { toast } = useToast() + const [submitting, setSubmitting] = useState(false) + + const customerLabel = + order.customer_company || order.customer_name || t('invoice_no_customer') + + async function handleCreate() { + setSubmitting(true) + try { + const res = await fetch(`/api/webshop-orders/${order.id}/create-invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const json = (await res.json()) as { invoice_id?: string } + if (!res.ok || !json.invoice_id) { + toast({ + title: t('invoice_create_failed'), + description: getErrorMessage(json, { statusCode: res.status, locale }), + variant: 'destructive', + }) + return + } + toast({ title: t('invoice_created') }) + onCreated() + router.push(`/invoices/${json.invoice_id}`) + } catch { + toast({ title: t('invoice_create_failed'), variant: 'destructive' }) + } finally { + setSubmitting(false) + } + } + + return ( + + + + {t('invoice_title', { number: order.order_number })} + + {t('invoice_description', { customer: customerLabel })} + + + +
+
+
{customerLabel}
+ {order.customer_email && ( +
{order.customer_email}
+ )} + {order.customer_orgnr && ( +
+ {t('invoice_orgnr_hint', { orgnr: order.customer_orgnr })} +
+ )} +
+ + {order.line_items.length > 0 && ( + + + + + + + + + {order.line_items.map((item, index) => ( + + + + + ))} + +
{t('invoice_col_item')}{t('invoice_col_amount')}
+ {item.quantity} × {item.name} + + {formatCurrency(item.total + item.total_tax, order.currency)} +
+ )} + +
+ {t('invoice_total')} + {formatCurrency(order.total, order.currency)} +
+
+ + + + + +
+
+ ) +} diff --git a/components/orders/OrderBookingDialog.tsx b/components/orders/OrderBookingDialog.tsx new file mode 100644 index 00000000..462d329d --- /dev/null +++ b/components/orders/OrderBookingDialog.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm' +import { + buildOrderBookingLines, + resolveBookingWarnings, + resolvePaymentAccount, +} from '@/lib/webshop-orders/booking-lines' +import { formatCurrency, formatDate } from '@/lib/utils' +import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number' +import type { WebshopOrder, WebshopStoreSettings } from '@/types' + +interface OrderBookingDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + order: WebshopOrder + storeSettings: WebshopStoreSettings | null + onBooked: () => void + onSettingsSaved: (settings: WebshopStoreSettings) => void +} + +/** + * Books one order/refund row: prefilled lines from the per-store payment- + * method mapping, fully editable in the shared JournalEntryForm (manual-base + * doctrine: prefill never auto-books). The "remember" opt-in writes the + * chosen counter-account back to the store mapping AFTER a successful + * booking, mirroring the prefill-plus-explicit-override editor pattern. + */ +export default function OrderBookingDialog({ + open, + onOpenChange, + order, + storeSettings, + onBooked, + onSettingsSaved, +}: OrderBookingDialogProps) { + const t = useTranslations('webshop_orders') + const resolved = useMemo( + () => resolvePaymentAccount(order, storeSettings), + [order, storeSettings], + ) + const [paymentAccount, setPaymentAccount] = useState(resolved.account) + const [remember, setRemember] = useState(false) + + const isRefund = order.row_type === 'refund' + const fxUnresolved = order.currency.toUpperCase() !== 'SEK' && order.total_sek === null + const accountValid = ACCOUNT_NUMBER_RE.test(paymentAccount) + + const initialLines = useMemo(() => { + if (fxUnresolved || !accountValid) return null + try { + return buildOrderBookingLines({ + order, + settings: storeSettings, + paymentAccount, + }).map((line) => ({ + account_number: line.account_number, + debit_amount: line.debit_amount ? line.debit_amount.toFixed(2) : '', + credit_amount: line.credit_amount ? line.credit_amount.toFixed(2) : '', + line_description: line.line_description ?? '', + ...(line.currency + ? { + currency: line.currency, + amount_in_currency: line.amount_in_currency, + exchange_rate: line.exchange_rate, + } + : {}), + })) + } catch { + return null + } + }, [order, storeSettings, paymentAccount, fxUnresolved, accountValid]) + + const methodLabel = order.payment_method_title || order.payment_method || '' + + async function persistMapping() { + if (!order.payment_method) return + const map = { + ...(storeSettings?.payment_method_account_map ?? {}), + [order.payment_method]: { mode: 'book' as const, account: paymentAccount }, + } + try { + const res = await fetch('/api/webshop-orders/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + platform: order.platform, + store_scope: order.store_scope, + payment_method_account_map: map, + }), + }) + if (res.ok) { + const json = (await res.json()) as { data: WebshopStoreSettings } + onSettingsSaved(json.data) + } + } catch { + // Remember is best-effort; the booking itself already succeeded. + } + } + + return ( + + + + + {isRefund + ? t('book_refund_title', { number: order.order_number }) + : t('book_title', { number: order.order_number })} + + + {formatDate(order.paid_date ?? order.order_date)} + {' · '} + {formatCurrency(order.total, order.currency)} + {methodLabel ? ` · ${methodLabel}` : ''} + + + + {fxUnresolved ? ( +

{t('fx_unresolved')}

+ ) : ( +
+ {/* One attn line (convention 6), priority order: a VAT compliance + warning always outranks the invoice-mode convenience hint + (Swedish review: the hint must never suppress a real + cross-border VAT advisory). */} + {(() => { + const warnings = resolveBookingWarnings(order) + if (warnings.length > 0) { + return ( +

{t(`warning_${warnings[0]}`)}

+ ) + } + if (resolved.invoiceMode && !isRefund) { + return

{t('invoice_mode_hint')}

+ } + return null + })()} +
+
+ + setPaymentAccount(e.target.value.trim())} + inputMode="numeric" + maxLength={4} + className="w-28 tabular-nums" + aria-invalid={!accountValid} + aria-describedby={!accountValid ? 'order-payment-account-error' : undefined} + /> + {!accountValid && ( + + )} +
+ {order.payment_method && ( + + )} +
+ + {initialLines && ( + { + if (remember) void persistMapping() + onBooked() + }} + /> + )} +
+ )} +
+
+ ) +} diff --git a/components/orders/PaymentMethodMappingForm.tsx b/components/orders/PaymentMethodMappingForm.tsx new file mode 100644 index 00000000..4850f692 --- /dev/null +++ b/components/orders/PaymentMethodMappingForm.tsx @@ -0,0 +1,216 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { + SettingsGroup, + SettingsRow, + SettingsRowEnd, + SettingsRowNote, + SettingsInput, +} from '@/components/settings/SettingsRows' +import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number' +import type { WebshopPaymentMethodPolicy, WebshopPlatform, WebshopStoreSettings } from '@/types' + +interface PaymentMethodMappingFormProps { + platform: WebshopPlatform + storeScope: string + /** + * Payment methods observed on this store's orders. Omitted: derived from a + * sample of the store's synced orders. + */ + methods?: Array<{ method: string; title: string | null }> +} + +type DraftPolicy = { mode: 'book'; account: string } | { mode: 'invoice' } + +/** + * Per-store payment-method -> account mapping (prefill only; never books). + * Fönster language: flat hairline rows, save appears only when dirty. + * "Faktureras" marks a method as invoice-flow: the booking dialog then nudges + * toward Skapa faktura for those orders. + */ +export function PaymentMethodMappingForm({ + platform, + storeScope, + methods, +}: PaymentMethodMappingFormProps) { + const t = useTranslations('webshop_orders') + const { toast } = useToast() + const [saved, setSaved] = useState>({}) + const [draft, setDraft] = useState>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [derivedMethods, setDerivedMethods] = useState< + Array<{ method: string; title: string | null }> + >([]) + + useEffect(() => { + if (methods !== undefined) return + let cancelled = false + fetch( + `/api/webshop-orders?platform=${platform}&store_scope=${encodeURIComponent(storeScope)}&limit=200`, + ) + .then((r) => (r.ok ? r.json() : { data: [] })) + .then( + (json: { + data: Array<{ payment_method: string | null; payment_method_title: string | null }> + }) => { + if (cancelled) return + const seen = new Map() + for (const row of json.data ?? []) { + if (row.payment_method && !seen.has(row.payment_method)) { + seen.set(row.payment_method, row.payment_method_title) + } + } + setDerivedMethods( + Array.from(seen.entries()).map(([method, title]) => ({ method, title })), + ) + }, + ) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [methods, platform, storeScope]) + + useEffect(() => { + let cancelled = false + fetch( + `/api/webshop-orders/settings?platform=${platform}&store_scope=${encodeURIComponent(storeScope)}`, + ) + .then((r) => (r.ok ? r.json() : { data: [] })) + .then((json: { data: WebshopStoreSettings[] }) => { + if (cancelled) return + const map = json.data[0]?.payment_method_account_map ?? {} + setSaved(map) + setDraft(map as Record) + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [platform, storeScope]) + + // Methods seen on orders plus any mapped-but-no-longer-seen leftovers. + const rows = useMemo(() => { + const source = methods ?? derivedMethods + const known = new Map(source.map((m) => [m.method, m.title])) + for (const method of Object.keys(saved)) { + if (!known.has(method)) known.set(method, null) + } + return Array.from(known.entries()).map(([method, title]) => ({ method, title })) + }, [methods, derivedMethods, saved]) + + const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(saved), [draft, saved]) + + const setMode = (method: string, mode: 'book' | 'invoice') => { + setDraft((prev) => { + const current = prev[method] + if (mode === 'invoice') return { ...prev, [method]: { mode: 'invoice' } } + return { + ...prev, + [method]: { + mode: 'book', + account: current?.mode === 'book' ? current.account : '1680', + }, + } + }) + } + + const setAccount = (method: string, account: string) => { + setDraft((prev) => ({ ...prev, [method]: { mode: 'book', account } })) + } + + const invalid = Object.values(draft).some( + (p) => p.mode === 'book' && !ACCOUNT_NUMBER_RE.test(p.account), + ) + + async function save() { + setSaving(true) + try { + const res = await fetch('/api/webshop-orders/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + platform, + store_scope: storeScope, + payment_method_account_map: draft, + }), + }) + if (!res.ok) throw new Error(`save failed: ${res.status}`) + const json = (await res.json()) as { data: WebshopStoreSettings } + setSaved(json.data.payment_method_account_map) + setDraft(json.data.payment_method_account_map as Record) + toast({ title: t('mapping_saved') }) + } catch { + toast({ title: t('mapping_save_failed'), variant: 'destructive' }) + } finally { + setSaving(false) + } + } + + if (loading || rows.length === 0) return null + + return ( + +

+ {t('mapping_intro')} +

+ {rows.map(({ method, title }) => { + const policy = draft[method] + const mode = policy?.mode ?? 'book' + const account = policy?.mode === 'book' ? policy.account : '' + return ( + + + + {policy?.mode === 'book' && ( + setAccount(method, e.target.value.trim())} + inputMode="numeric" + maxLength={4} + className="w-20 text-right tabular-nums" + aria-label={t('mapping_account_aria', { method: title || method })} + /> + )} + + + ) + })} +
+ {t('mapping_note')} + {dirty && ( + + )} +
+
+ ) +} diff --git a/components/settings/VoucherSeriesPerSourceTypeForm.tsx b/components/settings/VoucherSeriesPerSourceTypeForm.tsx index 61db9290..9d3c625f 100644 --- a/components/settings/VoucherSeriesPerSourceTypeForm.tsx +++ b/components/settings/VoucherSeriesPerSourceTypeForm.tsx @@ -34,6 +34,7 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin { key: 'salary_payment', labelKey: 'salary_payment' }, { key: 'bank_transaction', labelKey: 'bank_transaction' }, { key: 'reminder_fee', labelKey: 'reminder_fee' }, + { key: 'webshop_order', labelKey: 'webshop_order' }, { key: 'vat_settlement', labelKey: 'vat_settlement' }, { key: 'opening_balance', labelKey: 'opening_balance' }, { key: 'year_end', labelKey: 'year_end' }, @@ -58,6 +59,7 @@ const SV_LABELS: Record = { salary_payment: 'Lön', bank_transaction: 'Banktransaktioner', reminder_fee: 'Påminnelseavgifter', + webshop_order: 'Webshopordrar', vat_settlement: 'Momsredovisning', opening_balance: 'Ingående balanser', year_end: 'Bokslut', diff --git a/extensions/general/woocommerce/__tests__/api-routes.test.ts b/extensions/general/woocommerce/__tests__/api-routes.test.ts index 1401738c..d12cdc2a 100644 --- a/extensions/general/woocommerce/__tests__/api-routes.test.ts +++ b/extensions/general/woocommerce/__tests__/api-routes.test.ts @@ -261,7 +261,7 @@ describe('woocommerce extension routes', () => { it('returns 404 without an active connection', async () => { const { supabase, enqueue } = createQueuedMockSupabase() supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) - enqueue({ data: null }) + enqueue({ data: [] }) const res = await findRoute('POST', '/sync').handler( makeRequest('POST'), makeContext(supabase), @@ -272,12 +272,15 @@ describe('woocommerce extension routes', () => { 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' } }) + enqueue({ data: [{ id: 'conn-1', status: 'active' }] }) vi.mocked(syncWooCommerceOrders).mockResolvedValue({ fetched: 3, refundsFetched: 1, - imported: 4, - duplicates: 0, + inserted: 4, + updated: 0, + unchanged: 0, + frozenFlagged: 0, + crossMarked: 0, errors: 0, }) const res = await findRoute('POST', '/sync').handler( @@ -286,7 +289,7 @@ describe('woocommerce extension routes', () => { ) expect(res.status).toBe(200) const body = await res.json() - expect(body.transactions.imported).toBe(4) + expect(body.transactions.inserted).toBe(4) expect(vi.mocked(syncWooCommerceOrders).mock.calls[0][0]).toEqual({ service: true }) }) }) diff --git a/extensions/general/woocommerce/__tests__/order-sync.test.ts b/extensions/general/woocommerce/__tests__/order-sync.test.ts index 5a8437d8..c0dd4114 100644 --- a/extensions/general/woocommerce/__tests__/order-sync.test.ts +++ b/extensions/general/woocommerce/__tests__/order-sync.test.ts @@ -12,28 +12,22 @@ vi.mock('../lib/api-client', () => ({ WC_PAGE_SIZE: 100, })) -vi.mock('@/lib/transactions/ingest', () => ({ - ingestTransactions: vi.fn(), +vi.mock('@/lib/webshop-orders/ingest', () => ({ + upsertWebshopOrders: 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 { upsertWebshopOrders } from '@/lib/webshop-orders/ingest' +import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types' import { encryptCredential } from '../lib/credentials' import { WOOCOMMERCE_IMPORT_SOURCE, - WOOCOMMERCE_LEDGER_ACCOUNT, - mapOrder, - mapRefund, - orderQualifies, - rowBehindLock, + buildRefundVatBreakdown, + buildVatBreakdown, + extractOrgnr, + mapOrderToWebshopRow, + mapRefundToWebshopRow, + orderImports, + orderIsPaid, syncWooCommerceOrders, wooOrderExternalId, wooRefundExternalId, @@ -43,6 +37,15 @@ import type { WooCommerceConnection, WooOrder, WooRefund } from '../types' process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key' +const emptyUpsertResult = { + inserted: 0, + updated: 0, + unchanged: 0, + frozenFlagged: 0, + crossMarked: 0, + errors: 0, +} + function makeConnection(overrides: Partial = {}): WooCommerceConnection { return { id: 'conn-1', @@ -85,25 +88,40 @@ function makeOrder(overrides: Partial = {}): WooOrder { payment_method_title: 'Kortbetalning', transaction_id: 'pi_abc123', refunds: [], + billing: { + first_name: 'Test', + last_name: 'Person', + company: 'Testbolaget AB', + email: 'kund@example.se', + }, + line_items: [ + { + id: 1, + name: 'Produkt A', + quantity: 2, + total: '1000.00', + total_tax: '250.00', + taxes: [{ id: 3, total: '250.00' }], + }, + ], + tax_lines: [ + { rate_id: 3, rate_percent: 25, label: 'Moms 25%', tax_total: '250.00', shipping_tax_total: '0.00' }, + ], + shipping_lines: [], + meta_data: [], ...overrides, } } /** Minimal chainable supabase mock covering the sync's query patterns. */ -function makeSupabaseMock(options: { lockThrough?: string | null } = {}) { +function makeSupabaseMock() { 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, - }), + maybeSingle: async () => ({ data: null, error: null }), update: (values: Record) => { updates.push({ table, values }) return builder @@ -127,17 +145,15 @@ beforeEach(() => { // enqueues its pages with mockResolvedValueOnce. listOrdersPage.mockResolvedValue([]) listOrderRefunds.mockResolvedValue([]) - vi.mocked(ingestTransactions).mockResolvedValue({ - imported: 0, - duplicates: 0, - errors: 0, - } as Awaited>) + vi.mocked(upsertWebshopOrders).mockResolvedValue({ ...emptyUpsertResult }) }) 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). + // ⚠️ These assert the exact persisted formats, now shared between + // webshop_orders.external_id and the legacy transactions rows the + // cross-mark joins against. If this test fails, you are about to orphan + // every previously imported WooCommerce row AND break the legacy overlap + // join: do not update the expectation without a coordinated backfill. it('order id format is frozen', () => { expect(wooOrderExternalId('shop.example.se', 1042)).toBe( 'woo_shop.example.se_order_1042', @@ -155,51 +171,123 @@ describe('frozen external_id formats', () => { expect(wooStoreScope('https://example.se/butik')).toBe('example.se/butik') }) - it('import source and ledger account are frozen', () => { + it('legacy import source constant is 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('orderImports / orderIsPaid', () => { + it('every non-trash status imports, paid or not', () => { + expect(orderImports(makeOrder())).toBe(true) + expect(orderImports(makeOrder({ status: 'pending', date_paid_gmt: null }))).toBe(true) + expect(orderImports(makeOrder({ status: 'refunded' }))).toBe(true) + expect(orderImports(makeOrder({ status: 'trash' }))).toBe(false) + }) + + it('is_paid follows date_paid', () => { + expect(orderIsPaid(makeOrder())).toBe(true) + expect(orderIsPaid(makeOrder({ date_paid_gmt: null }))).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', - }, +describe('mapOrderToWebshopRow', () => { + const connection = { id: 'conn-1', store_name: 'Testbutiken' } + + it('maps the full booking underlag', () => { + const rows = mapOrderToWebshopRow(connection, 'shop.example.se', makeOrder()) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + platform: 'woocommerce', + store_scope: 'shop.example.se', + store_label: 'Testbutiken', + connection_id: 'conn-1', + row_type: 'order', + external_id: 'woo_shop.example.se_order_1042', + platform_order_id: '1042', + order_number: '1042', + status: 'processing', + is_paid: true, + order_date: '2026-08-01', + paid_date: '2026-08-01', + currency: 'SEK', + total: 1250, + total_tax: 250, + customer_name: 'Test Person', + customer_company: 'Testbolaget AB', + customer_email: 'kund@example.se', + payment_method: 'stripe', + payment_method_title: 'Kortbetalning', + gateway_reference: 'pi_abc123', + }) + expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 1000, tax: 250 }]) + expect(rows[0].line_items).toEqual([ + { name: 'Produkt A', quantity: 2, total: 1000, total_tax: 250, vat_rate: 25 }, ]) }) - it('rounds string money to two decimals', () => { - const rows = mapOrder('s', makeOrder({ total: '99.995' })) - expect(rows[0].amount).toBe(100) + it('imports unpaid orders with is_paid false and no paid_date', () => { + const rows = mapOrderToWebshopRow( + connection, + 's', + makeOrder({ status: 'pending', date_paid_gmt: null }), + ) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ is_paid: false, paid_date: null, order_date: '2026-08-01' }) }) - 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([]) + it('skips trashed, unparseable-total and zero-total orders', () => { + expect(mapOrderToWebshopRow(connection, 's', makeOrder({ status: 'trash' }))).toEqual([]) + expect( + mapOrderToWebshopRow(connection, 's', makeOrder({ total: 'not-a-number' })), + ).toEqual([]) + // 100% coupon order: paid but zero money; importing it would strand an + // unbookable row (the engine refuses zero-sum entries). + expect(mapOrderToWebshopRow(connection, 's', makeOrder({ total: '0.00' }))).toEqual([]) + }) + + it('captures the billing country uppercased', () => { + const rows = mapOrderToWebshopRow( + connection, + 's', + makeOrder({ billing: { first_name: 'A', last_name: 'B', country: 'dk' } }), + ) + expect(rows[0].customer_country).toBe('DK') + }) + + it('snapshots shipping and fee lines so the invoice conversion covers order.total', () => { + const rows = mapOrderToWebshopRow( + connection, + 's', + makeOrder({ + shipping_lines: [ + { method_title: 'Postnord', total: '80.00', total_tax: '20.00', taxes: [{ id: 3, total: '20.00' }] }, + ], + fee_lines: [ + { name: 'Fakturaavgift', total: '25.00', total_tax: '6.25', taxes: [{ id: 3, total: '6.25' }] }, + ], + }), + ) + expect(rows[0].line_items.map((i) => i.name)).toEqual([ + 'Produkt A', + 'Postnord', + 'Fakturaavgift', + ]) + expect(rows[0].line_items[1]).toMatchObject({ total: 80, total_tax: 20, vat_rate: 25 }) + expect(rows[0].line_items[2]).toMatchObject({ total: 25, total_tax: 6.25, vat_rate: 25 }) + }) + + it('sums inline refund totals into refunded_total', () => { + const rows = mapOrderToWebshopRow( + connection, + 's', + makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] }), + ) + expect(rows[0].refunded_total).toBe(250) }) }) -describe('mapRefund', () => { +describe('mapRefundToWebshopRow', () => { + const connection = { id: 'conn-1', store_name: 'Testbutiken' } const refund: WooRefund = { id: 77, amount: '250.00', @@ -207,37 +295,220 @@ describe('mapRefund', () => { 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('maps a refund to a negative row parented by external id, with PRORATED VAT', () => { + const rows = mapRefundToWebshopRow(connection, 'shop.example.se', makeOrder(), refund) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + row_type: 'refund', + parent_external_id: 'woo_shop.example.se_order_1042', + external_id: 'woo_shop.example.se_refund_77', + order_number: '1042', + order_date: '2026-08-03', + total: -250, + // 250 of a 1250 order (net 1000 + moms 250): the reversal carries the + // sale's mix. A zero here would leave ruta 10 over-declared (the + // skeptic's counterexample ledger). + total_tax: -50, + is_paid: true, + }) + expect(rows[0].vat_breakdown).toEqual([{ rate: 25, net: 200, tax: 50 }]) }) it('skips zero-amount refunds', () => { - expect(mapRefund('s', makeOrder(), { ...refund, amount: '0' })).toEqual([]) + expect( + mapRefundToWebshopRow(connection, '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('buildRefundVatBreakdown', () => { + it('prefers the refund\'s own line allocation over proration', () => { + const refundWithLines: WooRefund = { + id: 78, + amount: '125.00', + reason: '', + date_created_gmt: '2026-08-03T10:00:00', + line_items: [ + { id: 9, total: '-100.00', total_tax: '-25.00', taxes: [{ id: 3, total: '-25.00' }] }, + ], + } + expect(buildRefundVatBreakdown(makeOrder(), refundWithLines)).toEqual({ + breakdown: [{ rate: 25, net: 100, tax: 25 }], + totalTax: 25, + }) + }) + + it('prorates a mixed-rate order for amount-only refunds', () => { + const order = makeOrder({ + total: '1000.00', + total_tax: '160.00', + line_items: [ + { id: 1, name: 'A', quantity: 1, total: '500.00', total_tax: '125.00', taxes: [{ id: 3, total: '125.00' }] }, + { id: 2, name: 'B', quantity: 1, total: '340.00', total_tax: '35.00', taxes: [{ id: 4, total: '35.00' }] }, + ], + tax_lines: [ + { rate_id: 3, rate_percent: 25, tax_total: '125.00', shipping_tax_total: '0.00' }, + { rate_id: 4, rate_percent: 12, tax_total: '35.00', shipping_tax_total: '0.00' }, + ], + }) + const halfRefund: WooRefund = { + id: 79, + amount: '500.00', + reason: '', + date_created_gmt: '2026-08-03T10:00:00', + } + const { breakdown, totalTax } = buildRefundVatBreakdown(order, halfRefund) + expect(breakdown).toEqual([ + { rate: 25, net: 250, tax: 62.5 }, + { rate: 12, net: 170, tax: 17.5 }, + ]) + expect(totalTax).toBe(80) + }) + + it('returns empty when the order itself has no breakdown', () => { + const order = makeOrder({ line_items: [], shipping_lines: [], tax_lines: [] }) + const amountOnly: WooRefund = { + id: 80, + amount: '100.00', + reason: '', + date_created_gmt: '2026-08-03T10:00:00', + } + expect(buildRefundVatBreakdown(order, amountOnly)).toEqual({ + breakdown: [], + totalTax: 0, + }) + }) +}) + +describe('buildVatBreakdown', () => { + it('groups line and shipping taxes per rate', () => { + const order = makeOrder({ + line_items: [ + { + id: 1, + name: 'A', + quantity: 1, + total: '400.00', + total_tax: '100.00', + taxes: [{ id: 3, total: '100.00' }], + }, + { + id: 2, + name: 'B', + quantity: 1, + total: '100.00', + total_tax: '12.00', + taxes: [{ id: 4, total: '12.00' }], + }, + ], + shipping_lines: [ + { total: '49.00', total_tax: '12.25', taxes: [{ id: 3, total: '12.25' }] }, + ], + tax_lines: [ + { rate_id: 3, rate_percent: 25, tax_total: '112.25', shipping_tax_total: '12.25' }, + { rate_id: 4, rate_percent: 12, tax_total: '12.00', shipping_tax_total: '0.00' }, + ], + }) + expect(buildVatBreakdown(order)).toEqual([ + { rate: 25, net: 449, tax: 112.25 }, + { rate: 12, net: 100, tax: 12 }, + ]) + }) + + it('lands untaxed lines in the 0% bucket', () => { + const order = makeOrder({ + line_items: [ + { id: 1, name: 'A', quantity: 1, total: '300.00', total_tax: '0.00', taxes: [] }, + ], + tax_lines: [], + total_tax: '0.00', + }) + expect(buildVatBreakdown(order)).toEqual([{ rate: 0, net: 300, tax: 0 }]) + }) + + it('infers the rate from the ratio when tax_lines are missing', () => { + const order = makeOrder({ + line_items: [ + { id: 1, name: 'A', quantity: 1, total: '400.00', total_tax: '100.00' }, + ], + tax_lines: [], + }) + expect(buildVatBreakdown(order)).toEqual([{ rate: 25, net: 400, tax: 100 }]) + }) + + it('returns [] when the payload has no line data (dialog falls back)', () => { + const order = makeOrder({ line_items: [], shipping_lines: [], tax_lines: [] }) + expect(buildVatBreakdown(order)).toEqual([]) + }) + + it('returns [] for tax-stripped stores: empty taxes arrays are NOT tax data', () => { + // Hardened hosts serialize `taxes: []` on every line while hiding the + // amounts; treating that as tax data booked the whole VAT into 3740. + const order = makeOrder({ + total: '500.00', + total_tax: '100.00', + line_items: [ + { id: 1, name: 'A', quantity: 1, total: '400.00', total_tax: '', taxes: [] }, + ], + tax_lines: [], + shipping_lines: [], + }) + expect(buildVatBreakdown(order)).toEqual([]) + }) + + it('includes fee lines so their VAT reaches 2611 instead of the 3740 residual', () => { + const order = makeOrder({ + total: '562.50', + total_tax: '112.50', + fee_lines: [ + { name: 'Fakturaavgift', total: '50.00', total_tax: '12.50', taxes: [{ id: 3, total: '12.50' }] }, + ], + }) + expect(buildVatBreakdown(order)).toEqual([{ rate: 25, net: 1050, tax: 262.5 }]) + }) + + it('keeps discount lines SIGNED (negative net stays negative)', () => { + const order = makeOrder({ + total: '525.00', + total_tax: '125.00', + line_items: [ + { id: 1, name: 'Produkt', quantity: 1, total: '500.00', total_tax: '125.00', taxes: [{ id: 3, total: '125.00' }] }, + { id: 2, name: 'Presentkort', quantity: 1, total: '-100.00', total_tax: '0.00', taxes: [] }, + ], + }) + expect(buildVatBreakdown(order)).toEqual([ + { rate: 25, net: 500, tax: 125 }, + { rate: 0, net: -100, tax: 0 }, + ]) + }) +}) + +describe('extractOrgnr', () => { + it('reads an orgnr embedded in the billing company field', () => { + expect( + extractOrgnr(makeOrder({ billing: { company: 'Testbolaget AB 556677-8899' } })), + ).toBe('556677-8899') + }) + + it('scans meta_data for orgnr-ish keys', () => { + expect( + extractOrgnr( + makeOrder({ + billing: { company: 'Testbolaget AB' }, + meta_data: [{ key: '_billing_org_nr', value: '5566778899' }], + }), + ), + ).toBe('556677-8899') + }) + + it('returns null when nothing matches', () => { + expect(extractOrgnr(makeOrder())).toBeNull() + expect(extractOrgnr(makeOrder({ billing: undefined, meta_data: undefined }))).toBeNull() }) }) describe('syncWooCommerceOrders', () => { - it('ingests order and refund rows against the 1680 cash account and advances the cursor', async () => { + it('upserts order and refund rows and advances the cursor', async () => { const { client, updates } = makeSupabaseMock() const order = makeOrder({ refunds: [{ id: 77, reason: 'Retur', total: '-250.00' }], @@ -246,32 +517,22 @@ describe('syncWooCommerceOrders', () => { 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>) + vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({ + ...emptyUpsertResult, + inserted: 2, + }) 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(summary).toMatchObject({ fetched: 1, refundsFetched: 1, inserted: 2, errors: 0 }) + expect(upsertWebshopOrders).toHaveBeenCalledTimes(1) + const [, companyId, userId, rows] = vi.mocked(upsertWebshopOrders).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([ + expect((rows as WebshopOrderUpsert[]).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. @@ -289,24 +550,17 @@ describe('syncWooCommerceOrders', () => { }) }) - 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' }, + it('imports unpaid orders without fetching refunds for them', async () => { + const { client } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([ + makeOrder({ status: 'pending', date_paid_gmt: null, refunds: [] }), ]) - const summary = await syncWooCommerceOrders(client, makeConnection()) + 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) + expect(listOrderRefunds).not.toHaveBeenCalled() + const [, , , rows] = vi.mocked(upsertWebshopOrders).mock.calls[0] + expect((rows as WebshopOrderUpsert[])[0]).toMatchObject({ is_paid: false }) }) it('holds the cursor below an order whose refund fetch failed', async () => { @@ -324,6 +578,22 @@ describe('syncWooCommerceOrders', () => { expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z') }) + it('holds the cursor below a page whose upsert reported errors', async () => { + const { client, updates } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([makeOrder()]) + vi.mocked(upsertWebshopOrders).mockResolvedValueOnce({ + ...emptyUpsertResult, + errors: 1, + }) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary.errors).toBe(1) + 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) => @@ -347,37 +617,6 @@ describe('syncWooCommerceOrders', () => { 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' })]) @@ -385,7 +624,7 @@ describe('syncWooCommerceOrders', () => { const summary = await syncWooCommerceOrders(client, makeConnection()) expect(summary.errors).toBe(1) - expect(ingestTransactions).not.toHaveBeenCalled() + expect(upsertWebshopOrders).not.toHaveBeenCalled() // Deliberate: a permanently corrupt total must not stall the feed. expect(cursorUpdates(updates)).toHaveLength(1) }) diff --git a/extensions/general/woocommerce/__tests__/settings-actions.test.ts b/extensions/general/woocommerce/__tests__/settings-actions.test.ts index 82d46a02..9ffd5dd7 100644 --- a/extensions/general/woocommerce/__tests__/settings-actions.test.ts +++ b/extensions/general/woocommerce/__tests__/settings-actions.test.ts @@ -16,19 +16,19 @@ describe('syncSummary', () => { it('reports a deadline-truncated run as partial, never as complete', () => { expect( - syncSummary({ transactions: { deadlineReached: true, fetched: 120, imported: 80 } }), + syncSummary({ transactions: { deadlineReached: true, fetched: 120, inserted: 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 } }), + syncSummary({ transactions: { deadlineReached: true, fetched: 0, inserted: 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 }, + transactions: { deadlineReached: true, fetched: 50, inserted: 40, errors: 3 }, }), ).toEqual({ reason: 'partial', values: { fetched: 50, imported: 40, errors: 3 } }) }) @@ -36,9 +36,9 @@ describe('syncSummary', () => { it('distinguishes empty, errors and feed outcomes', () => { expect(syncSummary({ transactions: { fetched: 0 } })).toEqual({ reason: 'empty' }) expect( - syncSummary({ transactions: { fetched: 3, imported: 2, errors: 1 } }), + syncSummary({ transactions: { fetched: 3, inserted: 2, errors: 1 } }), ).toEqual({ reason: 'errors', values: { fetched: 3, imported: 2, errors: 1 } }) - expect(syncSummary({ transactions: { fetched: 3, imported: 3 } })).toEqual({ + expect(syncSummary({ transactions: { fetched: 3, inserted: 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 index ecfad298..b573b5d1 100644 --- a/extensions/general/woocommerce/api-routes.ts +++ b/extensions/general/woocommerce/api-routes.ts @@ -69,21 +69,27 @@ async function guardConnectPreconditions(auth: AuthedContext): Promise { +async function blockOrSupersedeExisting( + auth: AuthedContext, + storeUrl: string, +): Promise { const { data: existing } = await auth.supabase .from('woocommerce_connections') .select('id, status, created_at') .eq('company_id', auth.companyId) + .eq('store_url', storeUrl) .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.' }, + { error: 'Butiken är redan ansluten. Koppla från den först om du vill ansluta om den.' }, { status: 409 }, ) } @@ -106,6 +112,7 @@ async function blockOrSupersedeExisting(auth: AuthedContext): Promise r.status === 'active') ?? rows?.[0] ?? null + const active = (rows ?? []).filter((r) => r.status === 'active') + const connections = active.length > 0 ? active : rows?.[0] ? [rows[0]] : [] const payload: WooCommerceStatusResponse = { configured: isWooCommerceConfigured(), - connection, + connection: connections[0] ?? null, + connections, } return NextResponse.json(payload) }, @@ -168,7 +179,7 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [ ) } - const conflict = await blockOrSupersedeExisting(auth) + const conflict = await blockOrSupersedeExisting(auth, storeUrl) if (conflict) return conflict // Persist the CSRF state BEFORE handing the user to the store: the @@ -252,7 +263,7 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [ ) } - const conflict = await blockOrSupersedeExisting(auth) + const conflict = await blockOrSupersedeExisting(auth, storeUrl) if (conflict) return conflict // Verify before storing: a typo'd key must fail here, not at 03:45. @@ -335,7 +346,7 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [ { method: 'POST', path: '/sync', - handler: async (_request: Request, ctx?: ExtensionContext) => { + handler: async (request: Request, ctx?: ExtensionContext) => { const log = ctx?.log ?? console const auth = await requireUserAndCompany(ctx) if (auth instanceof NextResponse) return auth @@ -355,17 +366,23 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [ 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 + // the service client (cursor updates and upserts are service paths). + // The manual button ignores transaction_sync_enabled (that flag gates + // the nightly cron): pressing it IS the opt-in. connection_id targets + // one store; omitted, every active store syncs within one time budget. + const body = (await request.json().catch(() => ({}))) as { connection_id?: string } + let query = auth.supabase .from('woocommerce_connections') .select('*') .eq('company_id', auth.companyId) .eq('status', 'active') - .maybeSingle() + if (body.connection_id) query = query.eq('id', body.connection_id) + const { data: connections } = await query.order('last_order_synced_at', { + ascending: true, + nullsFirst: true, + }) - if (!connection) { + if (!connections || connections.length === 0) { return NextResponse.json( { error: 'Ingen ansluten WooCommerce-butik.' }, { status: 404 }, @@ -378,17 +395,44 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [ // 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 }) + const deadlineMs = Date.now() + 240_000 + // One entry per processed store, plus an explicit skipped count: + // returning only the last summary hid per-store failures and reported + // success for zero work when the deadline expired early. + const results: Array<{ + connection_id: string + store_url: string + summary: Awaited> + }> = [] + let skipped = 0 + for (const connection of connections as WooCommerceConnection[]) { + if (Date.now() >= deadlineMs) { + skipped += 1 + continue + } + const summary = await syncWooCommerceOrders( + serviceClient, + connection, + undefined, + deadlineMs, + ) + results.push({ + connection_id: connection.id, + store_url: connection.store_url, + summary, + }) + } + return NextResponse.json({ + success: true, + results, + skipped, + // Single-store shape kept for the panel's toast summary: the panel + // always syncs one connection_id, so this IS that store's summary. + transactions: results[results.length - 1]?.summary ?? null, + }) } 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.' }, @@ -418,17 +462,21 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [ }) if (!rl.ok) return rl.response! - const body = (await request.json().catch(() => ({}))) as { enabled?: unknown } + const body = (await request.json().catch(() => ({}))) as { + enabled?: unknown + connection_id?: string + } if (typeof body.enabled !== 'boolean') { return NextResponse.json({ error: 'enabled (boolean) krävs.' }, { status: 400 }) } - const { data: updated, error: updateError } = await auth.supabase + let updateQuery = auth.supabase .from('woocommerce_connections') .update({ transaction_sync_enabled: body.enabled }) .eq('company_id', auth.companyId) .eq('status', 'active') - .select('id') + if (body.connection_id) updateQuery = updateQuery.eq('id', body.connection_id) + const { data: updated, error: updateError } = await updateQuery.select('id') if (updateError) { return NextResponse.json( diff --git a/extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx b/extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx index 6e5a9bb2..1605be91 100644 --- a/extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx +++ b/extensions/general/woocommerce/components/WooCommerceSettingsPanel.tsx @@ -15,6 +15,7 @@ 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 { PaymentMethodMappingForm } from '@/components/orders/PaymentMethodMappingForm' import { wooRequest, syncSummary, @@ -22,17 +23,23 @@ import { WOO_SYNC_TIMEOUT_MS, type WooSyncPayload, } from '../lib/settings-actions' -import type { WooCommerceStatusResponse } from '../types' +import type { WooCommerceConnectionStatus, WooCommerceStatusResponse } from '../types' -type ConnectionInfo = NonNullable - -const STATUS_VARIANT: Record = { +const STATUS_VARIANT: Record< + WooCommerceConnectionStatus['status'], + 'success' | 'secondary' | 'destructive' | 'warning' +> = { active: 'success', pending: 'secondary', revoked: 'warning', error: 'destructive', } +/** Client twin of wooStoreScope(): store identity used by the mapping table. */ +function storeScopeOf(storeUrl: string): string { + return storeUrl.replace(/^https:\/\//, '') +} + export default function WooCommerceSettingsPanel() { const t = useTranslations('woocommerce') const tCommon = useTranslations('common') @@ -45,16 +52,15 @@ export default function WooCommerceSettingsPanel() { const [loading, setLoading] = useState(true) const [loadFailed, setLoadFailed] = useState(false) const [configured, setConfigured] = useState(false) - const [connection, setConnection] = useState(null) + const [connections, setConnections] = useState([]) 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) + // Per-store busy/confirm states, keyed by connection id. + const [busyId, setBusyId] = useState(null) + const [confirmDisconnectId, setConfirmDisconnectId] = useState(null) const failureCopy = { timeout: t('action_timeout'), network: t('action_network') } @@ -73,7 +79,10 @@ export default function WooCommerceSettingsPanel() { } setLoadFailed(false) setConfigured(result.data.configured) - setConnection(result.data.connection) + // Older payload shape fallback: a single `connection`. + setConnections( + result.data.connections ?? (result.data.connection ? [result.data.connection] : []), + ) }, [locale]) useEffect(() => { @@ -155,6 +164,7 @@ export default function WooCommerceSettingsPanel() { toast({ title: t('connected_toast_title'), description: t('connected_toast_description') }) setConsumerKey('') setConsumerSecret('') + setStoreUrl('') setManualMode(false) await loadStatus() } finally { @@ -162,12 +172,13 @@ export default function WooCommerceSettingsPanel() { } } - async function handleSyncNow() { - if (syncing) return - setSyncing(true) + async function handleSyncNow(connectionId: string) { + if (busyId) return + setBusyId(connectionId) try { const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/sync', + body: { connection_id: connectionId }, locale, timeoutMs: WOO_SYNC_TIMEOUT_MS, }) @@ -199,17 +210,17 @@ export default function WooCommerceSettingsPanel() { } await loadStatus() } finally { - setSyncing(false) + setBusyId(null) } } - async function handleToggleTransactionSync(enabled: boolean) { - if (togglingTransactionSync) return - setTogglingTransactionSync(true) + async function handleToggleTransactionSync(connectionId: string, enabled: boolean) { + if (busyId) return + setBusyId(connectionId) try { const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/transaction-sync', - body: { enabled }, + body: { enabled, connection_id: connectionId }, locale, }) if (!result.ok) { @@ -227,18 +238,18 @@ export default function WooCommerceSettingsPanel() { }) await loadStatus() } finally { - setTogglingTransactionSync(false) + setBusyId(null) } } - async function handleDisconnect() { - if (!connection || disconnecting) return - setDisconnecting(true) + async function handleDisconnect(connectionId: string) { + if (busyId) return + setBusyId(connectionId) try { const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/disconnect', method: 'DELETE', - body: { connection_id: connection.id }, + body: { connection_id: connectionId }, locale, }) if (!result.ok) { @@ -252,10 +263,10 @@ export default function WooCommerceSettingsPanel() { // 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) + setConfirmDisconnectId(null) await loadStatus() } finally { - setDisconnecting(false) + setBusyId(null) } } @@ -301,8 +312,7 @@ export default function WooCommerceSettingsPanel() { ) } - const isActive = connection?.status === 'active' - const showConnectForm = !connection || !isActive + const hasActive = connections.some((c) => c.status === 'active') return ( @@ -312,194 +322,224 @@ export default function WooCommerceSettingsPanel() {

{t('description')}

- {connection && ( -
-
- -
-
- - {connection.store_name || connection.store_url || t('unnamed_store')} - - - {t(`status_${connection.status}`)} - + {connections.map((connection) => { + const isActive = connection.status === 'active' + const busy = busyId === connection.id + // Handlers early-return while ANY request runs (shared busyId), so + // every card's controls disable; the spinner stays on the busy one. + const blocked = busyId !== null + return ( +
+
+
+ +
+
+ + {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 must not hide behind a healthy "Ansluten" badge. +

{connection.error_message}

+ )} +
- {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 && ( + confirmDisconnectId === connection.id ? ( +
+ + +
+ ) : ( +
+ + +
+ ) )}
-
- {isActive && ( - confirmDisconnect ? ( -
- - -
- ) : ( -
- - -
- ) - )} -
- )} - - {showConnectForm && ( -
-
- - setStoreUrl(e.target.value)} - disabled={connecting} - /> -
- - {manualMode ? ( -
-

{t('manual_hint')}

-
- - setConsumerKey(e.target.value)} - disabled={connecting} +
+ + handleToggleTransactionSync(connection.id, enabled) + } + disabled={blocked} + aria-label={t('transaction_sync_title')} />
-
- - 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')} -

+ {isActive && connection.store_url && ( + )}
- + {hasActive && ( +

{t('add_store')}

+ )} +
+ + 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')}

+
+ )} +
) diff --git a/extensions/general/woocommerce/lib/order-sync.ts b/extensions/general/woocommerce/lib/order-sync.ts index d48b3cff..e2d16a6a 100644 --- a/extensions/general/woocommerce/lib/order-sync.ts +++ b/extensions/general/woocommerce/lib/order-sync.ts @@ -1,9 +1,9 @@ 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 { upsertWebshopOrders } from '@/lib/webshop-orders/ingest' +import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types' import { createLogger, type Logger } from '@/lib/logger' -import type { RawTransaction } from '@/types' +import { roundOre as round } from '@/lib/money' +import type { WebshopOrderLineItem, WebshopVatBreakdownLine } from '@/types' import { listOrdersPage, listOrderRefunds, @@ -17,23 +17,24 @@ 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. + * WooCommerce order sync: the store's orders and refunds as rich rows in + * public.webshop_orders (the Orders page), replacing the earlier + * transactions-inbox 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. + * Every non-trash order imports (including unpaid ones: the Orders page shows + * order state and the invoice flow needs pre-payment orders), carrying the + * full booking underlag the wc/v3 payload already contains: customer billing + * snapshot, payment method, per-rate VAT breakdown and line items. Refunds + * are separate negative rows parented to their order. Nothing here books + * anything: booking is a manual per-order act on the Orders page (feed-only + * doctrine, same as before, one table over). * - * 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. + * The write path is upsertWebshopOrders() (lib/webshop-orders/ingest), which + * owns FX enrichment, the frozen-row rules for booked orders and the + * cross-mark against rows the retired transactions feed already imported. + * Rows already imported as transactions stay bookable there; the external_id + * scheme below is shared with that feed precisely so the overlap is a string + * join. * * Pagination is CURSOR-based, not offset-based: each request asks for the * oldest orders with date_modified strictly after the current cursor @@ -50,32 +51,24 @@ const defaultLog = createLogger('woocommerce/order-sync') * 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. + * overlap. Overlap re-polls are how status changes, late date_paid and new + * refunds land: they are real upserts now, not dedup no-ops. The cursor + * never advances past failed work: a page with refund-fetch failures, upsert + * 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. 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. + * Rows behind company_settings.bookkeeping_locked_through import too (the + * page is an order overview, not just a booking queue); the booking route + * and the period-lock triggers refuse to BOOK them, and the UI explains why. */ -/** 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. */ +/** transactions.import_source the retired feed used; kept for reference. */ 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. */ +/** Cursor re-poll overlap; upsert-on-external_id makes overlaps idempotent. */ const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000 /** * Safety cap on orders per run (matches the Stripe feed's MAX_TXNS_PER_RUN). @@ -86,11 +79,12 @@ const CURSOR_OVERLAP_MS = 24 * 60 * 60 * 1000 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. + * ⚠️ STORED-KEY FORMATS. These are persisted to webshop_orders.external_id + * (and historically to transactions.external_id by the retired feed; the + * cross-mark join depends on the schemes staying byte-identical). 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 @@ -113,12 +107,16 @@ export interface WooCommerceSyncSummary { 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 + /** New webshop_orders rows inserted. */ + inserted: number + /** Existing rows refreshed (status, refunds, billing, FX). */ + updated: number + /** Re-polled rows with nothing new. */ + unchanged: number + /** Booked rows whose financials drifted remotely (flagged, not touched). */ + frozenFlagged: number + /** Rows linked to a row the retired transactions feed already imported. */ + crossMarked: number errors: number /** Set when the caller's time budget ran out before all pages processed. */ deadlineReached?: boolean @@ -126,19 +124,16 @@ export interface WooCommerceSyncSummary { 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). + * can tell a corrupt total (counted + logged) from a legitimate zero. */ 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. */ +/** Whether an order's total cannot be read as money. */ export function orderAmountUnparseable(order: Pick): boolean { return parseAmount(order.total) === null } @@ -157,172 +152,321 @@ 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' +/** Whether the order has been paid (drives is_paid and refund fetching). */ +export function orderIsPaid(order: Pick): boolean { + return Boolean(order.date_paid_gmt) +} + +/** Every non-trash order imports; trash is the store's recycle bin. */ +export function orderImports(order: Pick): boolean { + return 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. + * Best-effort Swedish orgnr from the billing company field or B2B-plugin + * meta. NEVER trusted for legal invoice fields without user confirmation: + * plugins vary and customers typo. */ -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() +export function extractOrgnr(order: Pick): string | null { + const ORGNR = /(\d{6})[-\s]?(\d{4})/ + const fromCompany = order.billing?.company?.match(ORGNR) + if (fromCompany) return `${fromCompany[1]}-${fromCompany[2]}` + for (const meta of order.meta_data ?? []) { + if (typeof meta.value !== 'string') continue + if (!/org(anisations)?[._\s-]*n(umme)?r/i.test(meta.key)) continue + const match = meta.value.match(ORGNR) + if (match) return `${match[1]}-${match[2]}` } - return new Date(Date.now() - BACKFILL_DAYS * 86_400_000).toISOString() + return null } -/** - * 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, - }) +/** A money-bearing part of an order: product line, shipping or fee. */ +interface WooTaxablePart { + total: string + total_tax: string + taxes?: Array<{ id: number; total: string }> +} + +function rateMapOf(order: Pick): Map { + const rateByTaxId = new Map() + for (const taxLine of order.tax_lines ?? []) { + if (typeof taxLine.rate_percent === 'number') { + rateByTaxId.set(taxLine.rate_id, taxLine.rate_percent) } } + return rateByTaxId +} + +/** Resolve one part's VAT rate: tax_lines join first, own ratio second. */ +function rateOfPart(part: WooTaxablePart, rateByTaxId: Map): number { + const net = parseAmount(part.total) ?? 0 + const tax = parseAmount(part.total_tax) ?? 0 + const taxId = part.taxes?.find((t) => parseAmount(t.total) !== null)?.id + const joined = taxId !== undefined ? rateByTaxId.get(taxId) : undefined + if (joined !== undefined) return joined + // Signed ratio: a negative discount line at 25% has tax/net > 0 too. + return tax !== 0 && net !== 0 + ? ([25, 12, 6].find((r) => Math.abs(tax / net - r / 100) < 0.01) ?? 25) + : 0 +} + +/** + * Group the order's line, shipping and fee taxes into per-rate buckets. + * Buckets carry SIGNED amounts: a discount/gift-card line contributes a + * negative net so the booking split books it as a revenue reduction, not + * flipped revenue. When the payload carries no usable tax data at all but + * the order total says tax was charged (hardened stores stripping tax + * detail), returns [] and the booking dialog falls back to ratio inference. + */ +export function buildVatBreakdown(order: WooOrder): WebshopVatBreakdownLine[] { + const rateByTaxId = rateMapOf(order) + + const buckets = new Map() + const add = (rate: number, net: number, tax: number) => { + const bucket = buckets.get(rate) ?? { net: 0, tax: 0 } + bucket.net = round(bucket.net + net) + bucket.tax = round(bucket.tax + tax) + buckets.set(rate, bucket) + } + + const parts: WooTaxablePart[] = [ + ...(order.line_items ?? []), + ...(order.shipping_lines ?? []), + ...(order.fee_lines ?? []), + ] + if (parts.length === 0) return [] + + // "Tax data" means an actual per-line tax allocation. wc/v3 serializes + // `taxes: []` on every line even when the store hides tax detail, so an + // empty array proves nothing; a non-empty taxes array or a non-zero + // total_tax does. + let sawTaxData = false + for (const part of parts) { + const net = parseAmount(part.total) ?? 0 + const tax = parseAmount(part.total_tax) ?? 0 + if ((part.taxes && part.taxes.length > 0) || tax !== 0) sawTaxData = true + add(rateOfPart(part, rateByTaxId), net, tax) + } + + const orderTax = parseAmount(order.total_tax) ?? 0 + if (!sawTaxData && orderTax > 0) return [] + + return Array.from(buckets.entries()) + .filter(([, { net, tax }]) => net !== 0 || tax !== 0) + .map(([rate, { net, tax }]) => ({ rate, net, tax })) + .sort((a, b) => b.rate - a.rate) +} + +/** + * VAT buckets for one refund. Preference order: + * 1. The refund's own line allocation (line_items with negative totals): + * grouped exactly like the order's parts; magnitudes are returned + * positive (the refund row's row_type carries the direction). + * 2. Amount-only refunds: prorate the PARENT order's breakdown by + * refund/order ratio, so the VAT reversal follows the sale's actual mix. + * Never returns a confident 0%-bucket for a sale that carried VAT: that + * would book a refund with no moms reversal (skeptic finding). + */ +export function buildRefundVatBreakdown( + order: WooOrder, + refund: WooRefund, +): { breakdown: WebshopVatBreakdownLine[]; totalTax: number } { + const rateByTaxId = rateMapOf(order) + const refundParts = (refund.line_items ?? []).filter( + (part) => (parseAmount(part.total) ?? 0) !== 0 || (parseAmount(part.total_tax) ?? 0) !== 0, + ) + + if (refundParts.length > 0) { + const buckets = new Map() + for (const part of refundParts) { + const net = Math.abs(parseAmount(part.total) ?? 0) + const tax = Math.abs(parseAmount(part.total_tax) ?? 0) + const rate = rateOfPart(part, rateByTaxId) + const bucket = buckets.get(rate) ?? { net: 0, tax: 0 } + bucket.net = round(bucket.net + net) + bucket.tax = round(bucket.tax + tax) + buckets.set(rate, bucket) + } + const breakdown = Array.from(buckets.entries()) + .map(([rate, { net, tax }]) => ({ rate, net, tax })) + .sort((a, b) => b.rate - a.rate) + const totalTax = round(breakdown.reduce((sum, b) => sum + b.tax, 0)) + return { breakdown, totalTax } + } + + // Amount-only refund: prorate the order's mix. Per-bucket rounding drift + // lands on the booking's 3740 residual line. + const orderBreakdown = buildVatBreakdown(order) + const orderTotal = Math.abs(parseAmount(order.total) ?? 0) + const refundAmount = Math.abs(parseAmount(refund.amount) ?? 0) + if (orderBreakdown.length === 0 || orderTotal === 0 || refundAmount === 0) { + return { breakdown: [], totalTax: 0 } + } + const ratio = refundAmount / orderTotal + const breakdown = orderBreakdown + .map(({ rate, net, tax }) => ({ + rate, + net: round(net * ratio), + tax: round(tax * ratio), + })) + .filter(({ net, tax }) => net !== 0 || tax !== 0) + const totalTax = round(breakdown.reduce((sum, b) => sum + b.tax, 0)) + return { breakdown, totalTax } +} + +/** + * The stored line snapshot covers EVERYTHING inside order.total: product + * lines, shipping and fees. The invoice conversion builds its rows from + * this snapshot, so an omitted shipping line would silently shrink the + * customer's invoice (skeptic finding). + */ +function mapLineItems(order: WooOrder): WebshopOrderLineItem[] { + const rateByTaxId = rateMapOf(order) + const rateOrNull = (part: WooTaxablePart): number | null => { + const taxId = part.taxes?.find((t) => parseAmount(t.total) !== null)?.id + return taxId !== undefined ? (rateByTaxId.get(taxId) ?? null) : null + } + const products = (order.line_items ?? []).map((item) => ({ + name: item.name, + quantity: item.quantity, + total: parseAmount(item.total) ?? 0, + total_tax: parseAmount(item.total_tax) ?? 0, + vat_rate: rateOrNull(item), + })) + const shipping = (order.shipping_lines ?? []) + .filter((line) => (parseAmount(line.total) ?? 0) !== 0) + .map((line) => ({ + name: line.method_title || 'Frakt', + quantity: 1, + total: parseAmount(line.total) ?? 0, + total_tax: parseAmount(line.total_tax) ?? 0, + vat_rate: rateOrNull(line), + })) + const fees = (order.fee_lines ?? []) + .filter((line) => (parseAmount(line.total) ?? 0) !== 0) + .map((line) => ({ + name: line.name || 'Avgift', + quantity: 1, + total: parseAmount(line.total) ?? 0, + total_tax: parseAmount(line.total_tax) ?? 0, + vat_rate: rateOrNull(line), + })) + return [...products, ...shipping, ...fees] +} + +function customerName(order: WooOrder): string | null { + const name = [order.billing?.first_name, order.billing?.last_name] + .filter(Boolean) + .join(' ') + .trim() + return name || null +} + +/** Sum of refund totals (positive) reported inline on the order. */ +function refundedTotal(order: WooOrder): number { + let sum = 0 + for (const refund of order.refunds ?? []) { + const amount = parseAmount(refund.total) + if (amount !== null) sum = round(sum + Math.abs(amount)) + } + return sum +} + +/** Map one order to its webshop_orders upsert row. */ +export function mapOrderToWebshopRow( + connection: Pick, + storeScope: string, + order: WooOrder, +): WebshopOrderUpsert[] { + if (!orderImports(order)) return [] + const total = parseAmount(order.total) + // Zero-total orders (100% coupon) carry no bookable money event; importing + // them would strand an unbookable "Att bokföra" row (the engine refuses + // zero-sum entries and feed rows are undeletable). + if (total === null || total === 0) return [] + return [ + { + platform: 'woocommerce', + store_scope: storeScope, + store_label: connection.store_name, + connection_id: connection.id, + row_type: 'order', + parent_external_id: null, + external_id: wooOrderExternalId(storeScope, order.id), + platform_order_id: String(order.id), + order_number: order.number, + status: order.status, + is_paid: orderIsPaid(order), + order_date: isoDateOfGmt(order.date_created_gmt), + paid_date: order.date_paid_gmt ? isoDateOfGmt(order.date_paid_gmt) : null, + currency: order.currency.toUpperCase(), + total, + total_tax: parseAmount(order.total_tax) ?? 0, + vat_breakdown: buildVatBreakdown(order), + line_items: mapLineItems(order), + customer_name: customerName(order), + customer_company: order.billing?.company || null, + customer_email: order.billing?.email || null, + customer_orgnr: extractOrgnr(order), + customer_country: order.billing?.country?.toUpperCase() || null, + payment_method: order.payment_method || null, + payment_method_title: order.payment_method_title || null, + gateway_reference: order.transaction_id || null, + refunded_total: refundedTotal(order), + }, + ] +} + +/** Map one refund of a paid order to its negative upsert row. */ +export function mapRefundToWebshopRow( + connection: Pick, + storeScope: string, + order: WooOrder, + refund: WooRefund, +): WebshopOrderUpsert[] { + const amount = parseAmount(refund.amount) + if (amount === null || amount === 0) return [] + // The refund's VAT reversal: from its own line allocation, else prorated + // from the parent order's mix. Without this the refund books with zero + // moms and ruta 10 stays over-declared (skeptic finding). Buckets hold + // positive magnitudes; row_type 'refund' carries the direction, and + // total/total_tax are negative like the money movement. + const { breakdown, totalTax } = buildRefundVatBreakdown(order, refund) + return [ + { + platform: 'woocommerce', + store_scope: storeScope, + store_label: connection.store_name, + connection_id: connection.id, + row_type: 'refund', + parent_external_id: wooOrderExternalId(storeScope, order.id), + external_id: wooRefundExternalId(storeScope, refund.id), + platform_order_id: String(refund.id), + order_number: order.number, + status: 'refund', + is_paid: true, + order_date: isoDateOfGmt(refund.date_created_gmt), + paid_date: isoDateOfGmt(refund.date_created_gmt), + currency: order.currency.toUpperCase(), + total: -Math.abs(amount), + total_tax: -totalTax, + vat_breakdown: breakdown, + line_items: [], + customer_name: customerName(order), + customer_company: order.billing?.company || null, + customer_email: order.billing?.email || null, + customer_orgnr: extractOrgnr(order), + customer_country: order.billing?.country?.toUpperCase() || null, + payment_method: order.payment_method || null, + payment_method_title: order.payment_method_title || null, + gateway_reference: null, + refunded_total: 0, + }, + ] } interface PageRowsOutcome { - rows: RawTransaction[] + rows: WebshopOrderUpsert[] /** * date_modified (ms) of every order whose refund rows are incomplete this * run (fetch failed or skipped on deadline). The cursor must not advance @@ -332,44 +476,34 @@ interface PageRowsOutcome { hitDeadline: boolean } -/** Rows for one page of orders: gross rows plus refund rows where present. */ +/** Upsert rows for one page of orders: order rows plus refund rows. */ async function buildPageRows( creds: WooCredentials, + connection: WooCommerceConnection, 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)) { + if (orderImports(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 + outcome.rows.push(...mapOrderToWebshopRow(connection, storeScope, order)) + // Refunds only exist for paid orders; a refund row without its parent + // would be an unexplainable negative. + if (!orderIsPaid(order) || (order.refunds?.length ?? 0) === 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 @@ -392,7 +526,7 @@ async function buildPageRows( amount: refund.amount, }) } - push(mapRefund(storeScope, order, refund)) + outcome.rows.push(...mapRefundToWebshopRow(connection, storeScope, order, refund)) } } catch (refundError) { // The order row still imports; the cursor is capped below this order's @@ -408,6 +542,18 @@ async function buildPageRows( return outcome } +/** + * Window start (ISO, UTC) for the first modified_after list call. With a + * cursor: cursor minus the 24h overlap. First run: BACKFILL_DAYS back. + */ +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() +} + export async function syncWooCommerceOrders( supabase: SupabaseClient, connection: WooCommerceConnection, @@ -423,9 +569,11 @@ export async function syncWooCommerceOrders( const summary: WooCommerceSyncSummary = { fetched: 0, refundsFetched: 0, - imported: 0, - duplicates: 0, - skippedLocked: 0, + inserted: 0, + updated: 0, + unchanged: 0, + frozenFlagged: 0, + crossMarked: 0, errors: 0, } if ( @@ -438,8 +586,6 @@ export async function syncWooCommerceOrders( 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. @@ -449,7 +595,6 @@ export async function syncWooCommerceOrders( : 0 // Earliest incomplete work this run; the persisted cursor never passes it. let failureFloorMs = Number.POSITIVE_INFINITY - let accountEnsured = false try { for (;;) { @@ -457,7 +602,7 @@ export async function syncWooCommerceOrders( summary.deadlineReached = true log.info('time budget exhausted; stopping order sync', { connectionId: connection.id, - processed: summary.imported + summary.duplicates, + processed: summary.inserted + summary.updated + summary.unchanged, }) break } @@ -469,19 +614,11 @@ export async function syncWooCommerceOrders( 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, + connection, storeScope, orders, - lockThrough, summary, log, deadlineMs, @@ -492,24 +629,22 @@ export async function syncWooCommerceOrders( 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( + const result = await upsertWebshopOrders( supabase, connection.company_id, connection.user_id, page.rows, - { settlementAccount: WOOCOMMERCE_LEDGER_ACCOUNT, skipAutoCategorization: true }, ) - summary.imported += result.imported - summary.duplicates += result.duplicates + summary.inserted += result.inserted + summary.updated += result.updated + summary.unchanged += result.unchanged + summary.frozenFlagged += result.frozenFlagged + summary.crossMarked += result.crossMarked summary.errors += result.errors if (result.errors > 0) { - // 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. + // Failed upserts are dropped inside the service; 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) } } @@ -579,12 +714,6 @@ export async function syncWooCommerceOrders( 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, diff --git a/extensions/general/woocommerce/lib/settings-actions.ts b/extensions/general/woocommerce/lib/settings-actions.ts index 09753102..ba68a809 100644 --- a/extensions/general/woocommerce/lib/settings-actions.ts +++ b/extensions/general/woocommerce/lib/settings-actions.ts @@ -107,12 +107,13 @@ export interface WooSyncPayload { transactions?: { fetched?: number refundsFetched?: number - imported?: number - duplicates?: number + inserted?: number + updated?: number + unchanged?: number errors?: number revoked?: boolean deadlineReached?: boolean - } + } | null } type SyncCounts = { @@ -148,7 +149,8 @@ export function syncSummary(payload: WooSyncPayload | null): WooSyncSummary { if (typeof summary.fetched !== 'number') return { reason: 'unknown' } const fetched = summary.fetched - const imported = typeof summary.imported === 'number' ? summary.imported : 0 + // "imported" in the user-facing sentence = new rows this run (inserts). + const imported = typeof summary.inserted === 'number' ? summary.inserted : 0 const errors = typeof summary.errors === 'number' ? summary.errors : 0 if (summary.deadlineReached === true) { diff --git a/extensions/general/woocommerce/types.ts b/extensions/general/woocommerce/types.ts index 2c075f17..ca46cab9 100644 --- a/extensions/general/woocommerce/types.ts +++ b/extensions/general/woocommerce/types.ts @@ -25,21 +25,27 @@ export interface WooCommerceConnection { updated_at: string } +/** Connection fields safe for the browser (never encrypted credentials). */ +export type WooCommerceConnectionStatus = Pick< + WooCommerceConnection, + | 'id' + | 'status' + | 'store_url' + | 'store_name' + | 'currency' + | 'error_message' + | 'connected_at' + | 'transaction_sync_enabled' + | 'last_order_synced_at' +> + /** 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 + /** First entry of `connections`; kept for the old single-store shape. */ + connection: WooCommerceConnectionStatus | null + /** Multi-store: every active connection (or the latest inactive row). */ + connections: WooCommerceConnectionStatus[] } /** @@ -59,7 +65,7 @@ export interface WooOrder { prices_include_tax: boolean date_created_gmt: string date_modified_gmt: string - /** Set when payment completed; the feed's inclusion criterion and row date. */ + /** Set when payment completed; drives is_paid and the paid date. */ date_paid_gmt: string | null payment_method: string payment_method_title: string @@ -67,15 +73,73 @@ export interface WooOrder { transaction_id: string /** Summary of refunds against this order; totals are negative strings. */ refunds: Array<{ id: number; reason: string; total: string }> + /** + * The fields below always ride along in the wc/v3 order payload; the feed + * historically discarded them at map time. The Orders page persists them + * (customer, per-rate VAT, line snapshot), so the type now keeps them. + * All are optional-tolerant at runtime: hardened stores and old WC + * versions can serve partial payloads. + */ + billing?: { + first_name?: string + last_name?: string + company?: string + email?: string + /** ISO 3166-1 alpha-2 (e.g. 'SE'); drives the 0%-sale export/EU hint. */ + country?: string + } + line_items?: Array<{ + id: number + name: string + quantity: number + /** Line net total after discounts, excl. tax. */ + total: string + total_tax: string + taxes?: Array<{ id: number; total: string }> + }> + tax_lines?: Array<{ + rate_id: number + rate_percent?: number + label?: string + tax_total: string + shipping_tax_total: string + }> + shipping_lines?: Array<{ + method_title?: string + total: string + total_tax: string + taxes?: Array<{ id: number; total: string }> + }> + /** Payment surcharges / plugin fees; part of order.total like shipping. */ + fee_lines?: Array<{ + name?: string + total: string + total_tax: string + taxes?: Array<{ id: number; total: string }> + }> + meta_data?: Array<{ key: string; value: unknown }> } -/** Minimal wc/v3 order-refund shape (GET /orders/{id}/refunds). */ +/** 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 + /** + * Per-line refund allocation with NEGATIVE totals, present when the + * merchant refunded specific lines. Amount-only refunds ship an empty + * array; the sync then prorates VAT from the parent order's breakdown. + */ + line_items?: Array<{ + id: number + name?: string + quantity?: number + total: string + total_tax: string + taxes?: Array<{ id: number; total: string }> + }> } /** Store metadata read at connect time. */ diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index c3249585..2dc8cc89 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -274,6 +274,7 @@ export const JournalEntrySourceTypeSchema = z.enum([ 'rot_rut_payout', 'vat_settlement', 'stripe_payout', + 'webshop_order', ]) /** Query params for GET /api/bookkeeping/voucher-sequences/next. */ @@ -1411,6 +1412,51 @@ export const BookTransactionSchema = z path: ['expected_duplicate_journal_entry_id'], }) +// ── Webshop orders (Orders page) ────────────────────────────── + +export const WebshopPlatformSchema = z.enum(['woocommerce', 'shopify']) + +export const WebshopOrdersListQuerySchema = z.object({ + platform: WebshopPlatformSchema.optional(), + store_scope: z.string().max(255).optional(), + status: z.string().max(64).optional(), + row_type: z.enum(['order', 'refund']).optional(), + paid: z.enum(['paid', 'unpaid']).optional(), + booked: z.enum(['booked', 'unbooked']).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + offset: z.coerce.number().int().min(0).optional(), +}) + +export const BookWebshopOrderSchema = z.object({ + fiscal_period_id: uuid, + entry_date: isoDate, + description: z.string().min(1, 'Description is required').max(500), + lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required'), + voucher_series: z + .string() + .regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A-Z') + .optional(), + notes: z.string().max(2000).optional(), +}) + +export const CreateInvoiceFromWebshopOrderSchema = z.object({ + /** Omitted: match by email/orgnr within the company, else create. */ + customer_id: uuid.optional(), +}) + +/** {"": {mode:'book', account:'1930'} | {mode:'invoice'}} */ +export const WebshopStoreSettingsUpdateSchema = z.object({ + platform: WebshopPlatformSchema, + store_scope: z.string().min(1).max(255), + payment_method_account_map: z.record( + z.string().min(1).max(64), + z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('book'), account: accountNumber }), + z.object({ mode: z.literal('invoice') }), + ]), + ), +}) + /** * Edit a bank transaction's title (description). Only the working label: * gated server-side to unbooked, unmatched rows. Trimmed; whitespace-only is diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 51f92782..0e9b34ec 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3343,6 +3343,80 @@ const NETWORK_TRANSIENT_ENTRY: StructuredErrorEntry = { retryable: true, } +const WEBSHOP_ORDERS: Record = { + WEBSHOP_ORDER_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Ordern hittades inte.', + message_en: 'The order was not found.', + }, + WEBSHOP_ORDER_ALREADY_BOOKED: { + httpStatus: 409, + message_sv: 'Ordern är redan bokförd.', + message_en: 'The order is already booked.', + }, + WEBSHOP_ORDER_ALREADY_INVOICED: { + httpStatus: 409, + message_sv: + 'Ordern är kopplad till en kundfaktura. Bokföringen sker via fakturaflödet, inte direkt från ordern.', + message_en: + 'The order is linked to a customer invoice. Bookkeeping happens through the invoice flow, not directly from the order.', + }, + WEBSHOP_ORDER_NOT_PAID: { + httpStatus: 409, + message_sv: + 'Ordern är inte betald ännu. Obetalda ordrar bokförs när betalningen kommer, eller faktureras via Skapa faktura.', + message_en: + 'The order is not paid yet. Unpaid orders are booked when payment arrives, or invoiced via Create invoice.', + }, + WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN: { + httpStatus: 409, + message_sv: + 'Samma order ligger redan som en obokförd transaktion under Transaktioner (importerad av det tidigare orderflödet). Bokför eller ignorera den transaktionen först, så att samma affärshändelse inte bokförs två gånger.', + message_en: + 'The same order already exists as an unbooked transaction under Transactions (imported by the previous order feed). Book or ignore that transaction first so the same business event is not booked twice.', + }, + WEBSHOP_ORDER_LEGACY_TRANSACTION_BOOKED: { + httpStatus: 409, + message_sv: + 'Ordern är redan bokförd via en transaktion under Transaktioner (importerad av det tidigare orderflödet).', + message_en: + 'The order is already booked via a transaction under Transactions (imported by the previous order feed).', + }, + WEBSHOP_ORDER_FX_UNRESOLVED: { + httpStatus: 422, + message_sv: + 'Växelkursen för orderns valuta kunde inte hämtas ännu. Försök igen om en stund; ordern kan inte bokföras i SEK utan kurs.', + message_en: + 'The exchange rate for the order currency could not be fetched yet. Try again shortly; the order cannot be booked in SEK without a rate.', + }, + WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE: { + httpStatus: 409, + message_sv: + 'Återbetalningar kan inte omvandlas till fakturor. Hantera återbetalningen med en kreditfaktura från kundfakturan, eller bokför återbetalningsraden direkt.', + message_en: + 'Refunds cannot be converted to invoices. Handle the refund with a credit note from the customer invoice, or book the refund row directly.', + }, + WEBSHOP_ORDER_REFUND_PARENT_INVOICED: { + httpStatus: 409, + message_sv: + 'Ordern fakturerades via en kundfaktura. Återbetalningen hanteras med en kreditfaktura, inte genom att bokföra återbetalningsraden direkt.', + message_en: + 'The order was invoiced through a customer invoice. Handle the refund with a credit note instead of booking the refund row directly.', + }, + WEBSHOP_ORDER_CREATE_INVOICE_CUSTOMER_FAILED: { + httpStatus: 500, + message_sv: 'Kunden kunde inte skapas från orderns uppgifter.', + message_en: 'The customer could not be created from the order data.', + }, + WEBSHOP_ORDER_CREATE_INVOICE_MISSING_CUSTOMER: { + httpStatus: 422, + message_sv: + 'Ordern saknar kunduppgifter. Välj en befintlig kund att fakturera.', + message_en: + 'The order has no customer data. Choose an existing customer to invoice.', + }, +} + const NODE_SYSTEM: Record = { ECONNREFUSED: NETWORK_TRANSIENT_ENTRY, ECONNRESET: NETWORK_TRANSIENT_ENTRY, @@ -3396,6 +3470,7 @@ const REGISTRY: Record = { ...BOLAGSVERKET, ...ASSETS, ...DIMENSION, + ...WEBSHOP_ORDERS, ...NODE_SYSTEM, } diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 46bf0159..5e87121f 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -899,6 +899,10 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [ // NOTE: the date column on transactions is `date` (a previous spec said // booking_date, which does not exist: every backup got an error stub). { name: 'transactions', file: 'transactions.json', orderBy: 'date' }, + // Webshop order rows are booking underlag (and carry customer personal + // data), so they belong in the archive like transactions do. + { name: 'webshop_orders', file: 'webshop_orders.json', orderBy: 'order_date' }, + { name: 'webshop_store_settings', file: 'webshop_store_settings.json' }, { name: 'transaction_voucher_links', file: 'transaction_voucher_links.json' }, { name: 'bank_file_imports', file: 'bank_file_imports.json', orderBy: 'created_at' }, { name: 'cash_accounts', file: 'cash_accounts.json' }, diff --git a/lib/webshop-orders/__tests__/booking-lines.test.ts b/lib/webshop-orders/__tests__/booking-lines.test.ts new file mode 100644 index 00000000..b7b6f716 --- /dev/null +++ b/lib/webshop-orders/__tests__/booking-lines.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from 'vitest' +import { + buildOrderBookingLines, + fallbackVatBreakdown, + resolveBookingWarnings, + resolvePaymentAccount, + DEFAULT_PAYMENT_ACCOUNT, +} from '../booking-lines' +import type { CreateJournalEntryLineInput, WebshopStoreSettings } from '@/types' + +import { roundOre as round } from '@/lib/money' + +function sumDebits(lines: CreateJournalEntryLineInput[]): number { + return round(lines.reduce((sum, l) => sum + l.debit_amount, 0)) +} +function sumCredits(lines: CreateJournalEntryLineInput[]): number { + return round(lines.reduce((sum, l) => sum + l.credit_amount, 0)) +} +function lineFor(lines: CreateJournalEntryLineInput[], account: string) { + return lines.find((l) => l.account_number === account) +} + +const settings: WebshopStoreSettings = { + id: 's1', + company_id: 'c1', + user_id: 'u1', + platform: 'woocommerce', + store_scope: 'butik.example.se', + payment_method_account_map: { + swish: { mode: 'book', account: '1930' }, + klarna_payments: { mode: 'book', account: '1580' }, + bacs: { mode: 'invoice' }, + }, + created_at: '', + updated_at: '', +} + +function makeOrder(overrides: Record = {}) { + return { + row_type: 'order' as const, + order_number: '1042', + payment_method: 'swish', + payment_method_title: 'Swish', + currency: 'SEK', + total: 500, + total_tax: 100, + total_sek: 500, + exchange_rate: 1, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + ...overrides, + } +} + +describe('resolvePaymentAccount', () => { + it('resolves a mapped book-mode account', () => { + expect(resolvePaymentAccount(makeOrder(), settings)).toEqual({ + account: '1930', + invoiceMode: false, + mapped: true, + }) + }) + + it('flags invoice-mode methods', () => { + const result = resolvePaymentAccount(makeOrder({ payment_method: 'bacs' }), settings) + expect(result.invoiceMode).toBe(true) + }) + + it('falls back to 1680 when unmapped or without settings', () => { + expect(resolvePaymentAccount(makeOrder({ payment_method: 'stripe' }), settings).account).toBe( + DEFAULT_PAYMENT_ACCOUNT, + ) + expect(resolvePaymentAccount(makeOrder(), null).account).toBe(DEFAULT_PAYMENT_ACCOUNT) + expect(resolvePaymentAccount(makeOrder({ payment_method: null }), settings).mapped).toBe(false) + }) +}) + +describe('fallbackVatBreakdown', () => { + it('infers 25/12/6 from the tax-to-net ratio', () => { + expect(fallbackVatBreakdown(500, 100)).toEqual([{ rate: 25, net: 400, tax: 100 }]) + expect(fallbackVatBreakdown(112, 12)).toEqual([{ rate: 12, net: 100, tax: 12 }]) + expect(fallbackVatBreakdown(106, 6)).toEqual([{ rate: 6, net: 100, tax: 6 }]) + }) + + it('returns a 0% bucket for tax-free totals', () => { + expect(fallbackVatBreakdown(300, 0)).toEqual([{ rate: 0, net: 300, tax: 0 }]) + }) + + it('falls back to a 25% bucket for unrecognizable mixes', () => { + expect(fallbackVatBreakdown(500, 50)).toEqual([{ rate: 25, net: 450, tax: 50 }]) + }) +}) + +describe('buildOrderBookingLines', () => { + it('books a SEK 25% order: gross to mapped account, net to 3001, VAT to 2611', () => { + const lines = buildOrderBookingLines({ order: makeOrder(), settings }) + expect(lines).toHaveLength(3) + expect(lineFor(lines, '1930')).toMatchObject({ debit_amount: 500, credit_amount: 0 }) + expect(lineFor(lines, '3001')).toMatchObject({ debit_amount: 0, credit_amount: 400 }) + expect(lineFor(lines, '2611')).toMatchObject({ debit_amount: 0, credit_amount: 100 }) + expect(sumDebits(lines)).toBe(sumCredits(lines)) + }) + + it('splits mixed VAT rates across 3001/3002/3003 + 2611/2621/2631', () => { + const lines = buildOrderBookingLines({ + order: makeOrder({ + total: 723, + total_tax: 87, + total_sek: 723, + vat_breakdown: [ + { rate: 25, net: 300, tax: 75 }, + { rate: 12, net: 100, tax: 12 }, + { rate: 6, net: 200, tax: 12 }, + { rate: 0, net: 24, tax: 0 }, + ], + }), + settings, + }) + expect(lineFor(lines, '3001')?.credit_amount).toBe(300) + expect(lineFor(lines, '3002')?.credit_amount).toBe(100) + expect(lineFor(lines, '3003')?.credit_amount).toBe(200) + expect(lineFor(lines, '3004')?.credit_amount).toBe(24) + expect(lineFor(lines, '2611')?.credit_amount).toBe(75) + expect(lineFor(lines, '2621')?.credit_amount).toBe(12) + expect(lineFor(lines, '2631')?.credit_amount).toBe(12) + expect(sumDebits(lines)).toBe(sumCredits(lines)) + }) + + it('sends rounding residual to 3740 and stays balanced', () => { + const lines = buildOrderBookingLines({ + order: makeOrder({ + total: 500.05, + total_sek: 500.05, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + }), + settings, + }) + expect(lineFor(lines, '3740')?.credit_amount).toBe(0.05) + expect(sumDebits(lines)).toBe(sumCredits(lines)) + }) + + it('mirrors refund rows: credit the payment account, debit revenue and VAT', () => { + const lines = buildOrderBookingLines({ + order: makeOrder({ + row_type: 'refund', + total: -500, + total_tax: -100, + total_sek: -500, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + }), + settings, + }) + expect(lineFor(lines, '1930')).toMatchObject({ debit_amount: 0, credit_amount: 500 }) + expect(lineFor(lines, '3001')).toMatchObject({ debit_amount: 400, credit_amount: 0 }) + expect(lineFor(lines, '2611')).toMatchObject({ debit_amount: 100, credit_amount: 0 }) + expect(sumDebits(lines)).toBe(sumCredits(lines)) + }) + + it('books non-SEK orders in SEK with currency metadata on money lines only', () => { + const lines = buildOrderBookingLines({ + order: makeOrder({ + currency: 'EUR', + total: 100, + total_tax: 20, + total_sek: 1153, + exchange_rate: 11.53, + vat_breakdown: [{ rate: 25, net: 80, tax: 20 }], + }), + settings, + }) + const gross = lineFor(lines, '1930') + expect(gross).toMatchObject({ + debit_amount: 1153, + currency: 'EUR', + amount_in_currency: 100, + exchange_rate: 11.53, + }) + expect(lineFor(lines, '3001')).toMatchObject({ + credit_amount: round(80 * 11.53), + amount_in_currency: 80, + }) + const rounding = lineFor(lines, '3740') + if (rounding) expect(rounding.currency).toBeUndefined() + expect(sumDebits(lines)).toBe(sumCredits(lines)) + }) + + it('honors an explicit payment-account override from the dialog', () => { + const lines = buildOrderBookingLines({ + order: makeOrder(), + settings, + paymentAccount: '1686', + }) + expect(lineFor(lines, '1686')?.debit_amount).toBe(500) + }) + + it('uses the fallback breakdown when the sync produced none', () => { + const lines = buildOrderBookingLines({ + order: makeOrder({ vat_breakdown: [] }), + settings, + }) + expect(lineFor(lines, '3001')?.credit_amount).toBe(400) + expect(lineFor(lines, '2611')?.credit_amount).toBe(100) + expect(sumDebits(lines)).toBe(sumCredits(lines)) + }) + + it('books a NEGATIVE discount bucket on the opposite side, never abs-flipped', () => { + // Gift-card/discount plugin: 25% product 500+125, 0% discount -100. + // The old abs() logic booked C 3004 100 + D 3740 200: balanced, wrong. + const lines = buildOrderBookingLines({ + order: makeOrder({ + total: 525, + total_tax: 125, + total_sek: 525, + vat_breakdown: [ + { rate: 25, net: 500, tax: 125 }, + { rate: 0, net: -100, tax: 0 }, + ], + }), + settings, + }) + expect(lineFor(lines, '1930')).toMatchObject({ debit_amount: 525 }) + expect(lineFor(lines, '3001')).toMatchObject({ credit_amount: 500 }) + expect(lineFor(lines, '2611')).toMatchObject({ credit_amount: 125 }) + expect(lineFor(lines, '3004')).toMatchObject({ debit_amount: 100, credit_amount: 0 }) + expect(lineFor(lines, '3740')).toBeUndefined() + expect(sumDebits(lines)).toBe(sumCredits(lines)) + }) + + it('throws when a non-SEK order has no resolved SEK amount', () => { + expect(() => + buildOrderBookingLines({ + order: makeOrder({ currency: 'EUR', total_sek: null, exchange_rate: null }), + settings, + }), + ).toThrow(/exchange rate/i) + }) +}) + +describe('resolveBookingWarnings', () => { + it('flags a 0%-amount to a known non-SE country', () => { + expect( + resolveBookingWarnings({ + currency: 'SEK', + total_tax: 0, + vat_breakdown: [{ rate: 0, net: 2000, tax: 0 }], + customer_country: 'NO', + }), + ).toEqual(['zero_rate_foreign']) + }) + + it('stays quiet for domestic momsfri and unknown countries', () => { + expect( + resolveBookingWarnings({ + currency: 'SEK', + total_tax: 0, + vat_breakdown: [{ rate: 0, net: 2000, tax: 0 }], + customer_country: 'SE', + }), + ).toEqual([]) + expect( + resolveBookingWarnings({ + currency: 'SEK', + total_tax: 0, + vat_breakdown: [{ rate: 0, net: 2000, tax: 0 }], + customer_country: null, + }), + ).toEqual([]) + }) + + it('flags VAT charged on a non-SEK order (OSS check)', () => { + expect( + resolveBookingWarnings({ + currency: 'EUR', + total_tax: 20, + vat_breakdown: [{ rate: 25, net: 80, tax: 20 }], + customer_country: 'DK', + }), + ).toEqual(['foreign_vat']) + }) +}) diff --git a/lib/webshop-orders/__tests__/ingest.test.ts b/lib/webshop-orders/__tests__/ingest.test.ts new file mode 100644 index 00000000..d0adac01 --- /dev/null +++ b/lib/webshop-orders/__tests__/ingest.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { upsertWebshopOrders } from '../ingest' +import type { WebshopOrderUpsert } from '../types' +import type { SupabaseClient } from '@supabase/supabase-js' + +vi.mock('@/lib/currency/riksbanken', () => ({ + fetchExchangeRate: vi.fn(async (currency: string) => + currency === 'SEK' + ? { currency, rate: 1, date: '2026-08-01' } + : currency === 'EUR' + ? { currency, rate: 11.5, date: '2026-08-01' } + : null, + ), +})) + +const COMPANY = 'company-1' +const USER = 'user-1' + +function makeUpsert(overrides: Partial = {}): WebshopOrderUpsert { + return { + platform: 'woocommerce', + store_scope: 'butik.example.se', + store_label: 'Butiken', + connection_id: 'conn-1', + row_type: 'order', + parent_external_id: null, + external_id: 'woo_butik.example.se_order_1001', + platform_order_id: '1001', + order_number: '1001', + status: 'processing', + is_paid: true, + order_date: '2026-08-01', + paid_date: '2026-08-01', + currency: 'SEK', + total: 500, + total_tax: 100, + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + line_items: [], + customer_name: 'Test Person', + customer_company: null, + customer_email: 'test@example.se', + customer_orgnr: null, + customer_country: 'SE', + payment_method: 'swish', + payment_method_title: 'Swish', + gateway_reference: null, + refunded_total: 0, + ...overrides, + } +} + +function existingRow(overrides: Record = {}) { + return { + id: 'row-1', + external_id: 'woo_butik.example.se_order_1001', + journal_entry_id: null, + invoice_id: null, + legacy_transaction_id: null, + remote_changed_after_freeze: false, + total: 500, + total_tax: 100, + total_sek: 500, + exchange_rate: 1, + currency: 'SEK', + order_date: '2026-08-01', + paid_date: '2026-08-01', + is_paid: true, + payment_method: 'swish', + payment_method_title: 'Swish', + gateway_reference: null, + order_number: '1001', + status: 'processing', + refunded_total: 0, + store_label: 'Butiken', + connection_id: 'conn-1', + customer_name: 'Test Person', + customer_company: null, + customer_email: 'test@example.se', + customer_orgnr: null, + customer_country: 'SE', + vat_breakdown: [{ rate: 25, net: 400, tax: 100 }], + line_items: [], + ...overrides, + } +} + +describe('upsertWebshopOrders', () => { + let mock: ReturnType + const supabase = () => mock.supabase as unknown as SupabaseClient + + beforeEach(() => { + vi.clearAllMocks() + mock = createQueuedMockSupabase() + }) + + it('inserts a new SEK order with resolved FX and no legacy link', async () => { + mock.enqueueMany([ + { data: [] }, // existing webshop_orders + { data: [] }, // legacy transactions + { data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] }, // insert + ]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()]) + + expect(result).toMatchObject({ inserted: 1, updated: 0, errors: 0, crossMarked: 0 }) + const insertArgs = mock.findCall('webshop_orders', 'insert') + expect(insertArgs).toBeDefined() + const payload = (insertArgs![0] as Record[])[0] + expect(payload).toMatchObject({ + company_id: COMPANY, + user_id: USER, + external_id: 'woo_butik.example.se_order_1001', + total_sek: 500, + exchange_rate: 1, + legacy_transaction_id: null, + }) + }) + + it('parents a refund to an order inserted in the same call', async () => { + mock.enqueueMany([ + { data: [] }, // existing (order phase) + { data: [] }, // legacy (order phase) + { data: [{ id: 'order-id-1', external_id: 'woo_butik.example.se_order_1001' }] }, + { data: [] }, // existing (refund phase; parent already known via knownIds) + { data: [] }, // legacy (refund phase) + { data: [{ id: 'refund-id-1', external_id: 'woo_butik.example.se_refund_77' }] }, + ]) + + const refund = makeUpsert({ + row_type: 'refund', + parent_external_id: 'woo_butik.example.se_order_1001', + external_id: 'woo_butik.example.se_refund_77', + platform_order_id: '77', + total: -500, + total_tax: -100, + refunded_total: 0, + }) + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [ + refund, + makeUpsert(), + ]) + + expect(result.inserted).toBe(2) + const inserts = mock.findCalls('webshop_orders', 'insert') + expect(inserts).toHaveLength(2) + const refundPayload = (inserts[1][0] as Record[])[0] + expect(refundPayload).toMatchObject({ + row_type: 'refund', + parent_order_id: 'order-id-1', + }) + }) + + it('cross-marks rows whose external_id already exists in the transactions feed', async () => { + mock.enqueueMany([ + { data: [] }, + { data: [{ id: 'txn-9', external_id: 'woo_butik.example.se_order_1001' }] }, + { data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] }, + ]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()]) + + expect(result.crossMarked).toBe(1) + const payload = (mock.findCall('webshop_orders', 'insert')![0] as Record[])[0] + expect(payload.legacy_transaction_id).toBe('txn-9') + }) + + it('updates an unfrozen existing row when status moves', async () => { + mock.enqueueMany([ + { data: [existingRow()] }, + { data: [] }, + { data: null }, // update + ]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [ + makeUpsert({ status: 'completed' }), + ]) + + expect(result).toMatchObject({ inserted: 0, updated: 1, unchanged: 0 }) + const updateArgs = mock.findCall('webshop_orders', 'update') + expect(updateArgs).toBeDefined() + expect((updateArgs![0] as Record).status).toBe('completed') + }) + + it('counts an identical re-poll as unchanged without writing', async () => { + mock.enqueueMany([{ data: [existingRow()] }, { data: [] }]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()]) + + expect(result).toMatchObject({ inserted: 0, updated: 0, unchanged: 1 }) + expect(mock.findCall('webshop_orders', 'update')).toBeUndefined() + expect(mock.findCall('webshop_orders', 'insert')).toBeUndefined() + }) + + it('treats jsonb-reordered keys as unchanged (Postgres does not preserve key order)', async () => { + // What PostgREST returns: same VALUES, different object key order than + // what the sync inserts. JSON.stringify comparison falsely flagged every + // such row as changed (and every booked row as remote-drifted). + mock.enqueueMany([ + { + data: [ + existingRow({ + vat_breakdown: [{ net: 400, tax: 100, rate: 25 }], + line_items: [], + }), + ], + }, + { data: [] }, + ]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()]) + + expect(result).toMatchObject({ unchanged: 1, updated: 0, frozenFlagged: 0 }) + expect(mock.findCall('webshop_orders', 'update')).toBeUndefined() + }) + + it('does not flag a booked row when only key order differs', async () => { + mock.enqueueMany([ + { + data: [ + existingRow({ + journal_entry_id: 'je-1', + vat_breakdown: [{ tax: 100, rate: 25, net: 400 }], + }), + ], + }, + { data: [] }, + ]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()]) + + expect(result.frozenFlagged).toBe(0) + expect(mock.findCall('webshop_orders', 'update')).toBeUndefined() + }) + + it('flags a booked row whose financials drifted instead of updating them', async () => { + mock.enqueueMany([ + { data: [existingRow({ journal_entry_id: 'je-1' })] }, + { data: [] }, + { data: null }, // safe-field update + ]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [ + makeUpsert({ total: 600, status: 'completed' }), + ]) + + expect(result.frozenFlagged).toBe(1) + const update = mock.findCall('webshop_orders', 'update')![0] as Record + expect(update.remote_changed_after_freeze).toBe(true) + expect(update.status).toBe('completed') + expect(update).not.toHaveProperty('total') + expect(update).not.toHaveProperty('total_sek') + expect(update).not.toHaveProperty('paid_date') + }) + + it('leaves total_sek null when the exchange rate cannot resolve', async () => { + mock.enqueueMany([ + { data: [] }, + { data: [] }, + { data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] }, + ]) + + await upsertWebshopOrders(supabase(), COMPANY, USER, [ + makeUpsert({ currency: 'ISK', total: 900 }), + ]) + + const payload = (mock.findCall('webshop_orders', 'insert')![0] as Record[])[0] + expect(payload.total_sek).toBeNull() + expect(payload.exchange_rate).toBeNull() + }) + + it('converts non-SEK totals with the fetched rate', async () => { + mock.enqueueMany([ + { data: [] }, + { data: [] }, + { data: [{ id: 'new-1', external_id: 'woo_butik.example.se_order_1001' }] }, + ]) + + await upsertWebshopOrders(supabase(), COMPANY, USER, [ + makeUpsert({ currency: 'EUR', total: 100, total_tax: 20 }), + ]) + + const payload = (mock.findCall('webshop_orders', 'insert')![0] as Record[])[0] + expect(payload.total_sek).toBe(1150) + expect(payload.exchange_rate).toBe(11.5) + }) + + it('surfaces select errors without throwing', async () => { + mock.enqueueMany([{ data: null, error: { message: 'boom', code: '500' } }]) + + const result = await upsertWebshopOrders(supabase(), COMPANY, USER, [makeUpsert()]) + + expect(result.errors).toBe(1) + expect(result.firstError?.message).toBe('boom') + }) +}) diff --git a/lib/webshop-orders/booking-lines.ts b/lib/webshop-orders/booking-lines.ts new file mode 100644 index 00000000..96021b1e --- /dev/null +++ b/lib/webshop-orders/booking-lines.ts @@ -0,0 +1,251 @@ +import { roundOre as round } from '@/lib/money' +import type { + CreateJournalEntryLineInput, + WebshopOrder, + WebshopStoreSettings, + WebshopVatBreakdownLine, +} from '@/types' + +/** + * Pure builder for the journal lines that PREFILL the order booking dialog. + * Never books anything on its own: the user reviews and can override every + * line (manual-base doctrine), and the server only validates what comes back + * through the engine. + * + * Shape for an order row (Swish 500 kr incl. 25%): + * Debit 1930 (mapped by payment_method) 500.00 gross + * Credit 3001 400.00 net per rate + * Credit 2611 100.00 VAT per rate + * Refund rows mirror (debit revenue/VAT, credit the payment account). + * A rounding residual goes to 3740 Öresavrundning so the entry balances. + * + * Non-SEK orders book in SEK via the row's stored exchange_rate (rate date = + * paid date, captured at sync); every line carries the currency metadata trio + * exactly like the transaction booking dialog does. Callers must not invoke + * this while total_sek is null (booking is blocked until FX resolves). + */ + +/** Fallback counter-account when no mapping exists: the old feed's ledger. */ +export const DEFAULT_PAYMENT_ACCOUNT = '1680' + +/** Revenue account per Swedish VAT rate (BAS 2026). */ +const REVENUE_ACCOUNT_BY_RATE: Record = { + 25: '3001', + 12: '3002', + 6: '3003', + 0: '3004', +} + +/** Output VAT account per rate. */ +const VAT_ACCOUNT_BY_RATE: Record = { + 25: '2611', + 12: '2621', + 6: '2631', +} + +/** Öresavrundning. */ +const ROUNDING_ACCOUNT = '3740' + +/** + * Resolve the prefilled payment counter-account for an order from the + * per-store mapping. Returns the account plus whether the store marked this + * payment method as invoice-flow (the dialog then nudges toward Skapa + * faktura instead). + */ +export function resolvePaymentAccount( + order: Pick, + settings: WebshopStoreSettings | null | undefined, +): { account: string; invoiceMode: boolean; mapped: boolean } { + const method = order.payment_method + const policy = method ? settings?.payment_method_account_map?.[method] : undefined + if (!policy) return { account: DEFAULT_PAYMENT_ACCOUNT, invoiceMode: false, mapped: false } + if (policy.mode === 'invoice') { + return { account: DEFAULT_PAYMENT_ACCOUNT, invoiceMode: true, mapped: true } + } + return { account: policy.account, invoiceMode: false, mapped: true } +} + +/** + * When the sync could not build a per-rate breakdown (blocked tax endpoints, + * plugin-mangled orders), fall back to one bucket whose rate is inferred + * from the tax/net ratio; null rate when nothing matches, so the dialog + * shows an editable guess instead of silently wrong accounts. + */ +export function fallbackVatBreakdown( + total: number, + totalTax: number, +): WebshopVatBreakdownLine[] { + const net = round(Math.abs(total) - Math.abs(totalTax)) + const tax = round(Math.abs(totalTax)) + if (net <= 0) return [{ rate: 0, net: round(Math.abs(total)), tax: 0 }] + if (tax === 0) return [{ rate: 0, net, tax: 0 }] + const ratio = tax / net + for (const rate of [25, 12, 6]) { + if (Math.abs(ratio - rate / 100) < 0.005) return [{ rate, net, tax }] + } + // Unknown mix: present as 25% bucket for the user to correct. + return [{ rate: 25, net, tax }] +} + +export type BookingWarning = 'zero_rate_foreign' | 'foreign_vat' + +/** + * Advisory (never blocking, per the soft-guard rule) compliance hints for + * the booking dialog: + * - zero_rate_foreign: a 0%-rate amount on an order with a known non-SE + * billing country. The prefill's 3004 (ruta 42) is only right for + * domestic momsfri sales; export/EU sales belong on 31xx/33xx accounts. + * - foreign_vat: VAT charged on a non-SEK order. Swedish 2611-series output + * VAT may be wrong if the merchant is over the EU distance-selling + * threshold (OSS) — the store's tax setup decides, the user must check. + */ +export function resolveBookingWarnings( + order: Pick< + WebshopOrder, + 'currency' | 'total_tax' | 'vat_breakdown' | 'customer_country' + >, +): BookingWarning[] { + const warnings: BookingWarning[] = [] + const hasZeroRateAmount = order.vat_breakdown.some( + (b) => b.rate === 0 && b.net !== 0, + ) + if ( + hasZeroRateAmount && + order.customer_country && + order.customer_country.toUpperCase() !== 'SE' + ) { + warnings.push('zero_rate_foreign') + } + if (order.currency.toUpperCase() !== 'SEK' && order.total_tax !== 0) { + warnings.push('foreign_vat') + } + return warnings +} + +export interface OrderBookingLinesInput { + order: Pick< + WebshopOrder, + | 'row_type' + | 'order_number' + | 'payment_method' + | 'payment_method_title' + | 'currency' + | 'total' + | 'total_tax' + | 'total_sek' + | 'exchange_rate' + | 'vat_breakdown' + > + settings?: WebshopStoreSettings | null + /** Explicit override of the payment counter-account (dialog edit). */ + paymentAccount?: string +} + +/** + * Build balanced prefill lines for one order/refund row. Throws if total_sek + * is required but unresolved: callers gate on it first. + */ +export function buildOrderBookingLines({ + order, + settings, + paymentAccount, +}: OrderBookingLinesInput): CreateJournalEntryLineInput[] { + const isSek = order.currency.toUpperCase() === 'SEK' + const rate = isSek ? 1 : order.exchange_rate + const grossSek = isSek ? round(order.total) : order.total_sek + if (grossSek === null || grossSek === undefined || !rate) { + throw new Error('Order is missing a resolved SEK amount; booking is blocked until the exchange rate resolves') + } + + const account = paymentAccount ?? resolvePaymentAccount(order, settings).account + // Refund rows carry negative totals; build everything from magnitudes and + // apply direction at the end so debit/credit never go negative. + const isRefund = order.row_type === 'refund' + const grossAbs = round(Math.abs(grossSek)) + + const breakdown = + order.vat_breakdown.length > 0 + ? order.vat_breakdown + : fallbackVatBreakdown(order.total, order.total_tax) + + const toSek = (amount: number) => round(Math.abs(amount) * rate) + + const methodLabel = order.payment_method_title || order.payment_method || '' + const baseDescription = methodLabel + ? `Order ${order.order_number} (${methodLabel})` + : `Order ${order.order_number}` + const description = isRefund + ? `Återbetalning order ${order.order_number}` + : baseDescription + + const currencyMeta = (amountAbs: number): Partial => + isSek + ? {} + : { + currency: order.currency.toUpperCase(), + amount_in_currency: amountAbs, + exchange_rate: rate, + } + + const line = ( + accountNumber: string, + sekAbs: number, + side: 'debit' | 'credit', + originalAbs: number, + ): CreateJournalEntryLineInput => ({ + account_number: accountNumber, + debit_amount: side === 'debit' ? sekAbs : 0, + credit_amount: side === 'credit' ? sekAbs : 0, + line_description: description, + ...currencyMeta(originalAbs), + }) + + // Order: money in (debit payment account); refund: money out (credit). + const grossSide: 'debit' | 'credit' = isRefund ? 'credit' : 'debit' + const counterSide: 'debit' | 'credit' = isRefund ? 'debit' : 'credit' + + const lines: CreateJournalEntryLineInput[] = [ + line(account, grossAbs, grossSide, round(Math.abs(order.total))), + ] + + // Buckets are SIGNED: a discount/gift-card bucket carries a negative net + // and must book on the OPPOSITE side (a revenue reduction), never as + // abs-flipped extra revenue with the difference dumped on 3740 (skeptic + // finding). counterSum accumulates the signed counter-direction total so + // the residual stays a pure öre artifact. + let counterSum = 0 + const pushSigned = (account: string, signedOriginal: number) => { + if (signedOriginal === 0) return + const amountAbs = round(Math.abs(signedOriginal)) + const sekAbs = toSek(amountAbs) + if (sekAbs === 0) return + const side = signedOriginal > 0 ? counterSide : grossSide + lines.push(line(account, sekAbs, side, amountAbs)) + counterSum = round(counterSum + (signedOriginal > 0 ? sekAbs : -sekAbs)) + } + for (const bucket of breakdown) { + const revenueAccount = + REVENUE_ACCOUNT_BY_RATE[bucket.rate] ?? REVENUE_ACCOUNT_BY_RATE[25] + const vatAccount = VAT_ACCOUNT_BY_RATE[bucket.rate] ?? VAT_ACCOUNT_BY_RATE[25] + pushSigned(revenueAccount, round(bucket.net)) + pushSigned(vatAccount, round(bucket.tax)) + } + + // Balance residual (per-line rounding, FX drift) to öresavrundning. The + // residual can fall on either side. No currency metadata: the residual is + // an SEK-conversion artifact, not an amount that exists in the order + // currency. + const residual = round(grossAbs - counterSum) + if (residual !== 0) { + const side: 'debit' | 'credit' = residual > 0 ? counterSide : grossSide + const residualAbs = round(Math.abs(residual)) + lines.push({ + account_number: ROUNDING_ACCOUNT, + debit_amount: side === 'debit' ? residualAbs : 0, + credit_amount: side === 'credit' ? residualAbs : 0, + line_description: description, + }) + } + + return lines +} diff --git a/lib/webshop-orders/ingest.ts b/lib/webshop-orders/ingest.ts new file mode 100644 index 00000000..499bf445 --- /dev/null +++ b/lib/webshop-orders/ingest.ts @@ -0,0 +1,439 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import { roundOre as round } from '@/lib/money' +import { createLogger } from '@/lib/logger' +import type { Currency, WebshopOrder } from '@/types' +import type { WebshopOrderUpsert, WebshopOrderUpsertResult } from './types' + +const log = createLogger('webshop-orders/ingest') + +/** + * Upsert service for webshop order rows: the single write path the platform + * syncs (woocommerce/shopify extensions) use, mirroring how the transactions + * feed goes through ingestTransactions(). + * + * Unlike the append-only transactions feed, order rows are living mirrors: + * status changes, date_paid arriving later, growing refund totals and billing + * corrections all land as updates on (company_id, external_id). The boundary + * is the financial freeze: once a row is booked (journal_entry_id) or + * invoiced (invoice_id), financial fields are immutable (DB trigger). This + * service respects that application-side: a frozen row whose incoming + * financials differ gets remote_changed_after_freeze = true and only its + * safe fields updated, so the sync never trips the trigger and divergence is + * surfaced instead of silently dropped. + * + * Also owns: + * - FX enrichment: non-SEK rows get exchange_rate/total_sek via Riksbanken + * (rate date = paid date, falling back to order date). Unresolved rates + * leave total_sek null; booking is blocked until a later sync resolves it. + * - Legacy cross-marking: rows whose external_id already exists in the + * transactions feed (imported before the Orders switch-over) get + * legacy_transaction_id set, and the booking route refuses to double-book. + * - Refund parenting: refund rows resolve parent_external_id to + * parent_order_id (parent in the same batch or already in the table). + */ + +/** Batch size for selects/inserts; keeps PostgREST URLs and payloads sane. */ +const CHUNK_SIZE = 200 + +/** Financial fields the freeze protects; compared to detect remote drift. */ +const FINANCIAL_FIELDS = [ + 'total', + 'total_tax', + 'currency', + 'order_date', + 'paid_date', + 'is_paid', + 'payment_method', +] as const + +type ExistingRow = Pick< + WebshopOrder, + | 'id' + | 'external_id' + | 'journal_entry_id' + | 'invoice_id' + | 'legacy_transaction_id' + | 'remote_changed_after_freeze' + | 'total' + | 'total_tax' + | 'total_sek' + | 'exchange_rate' + | 'currency' + | 'order_date' + | 'paid_date' + | 'is_paid' + | 'payment_method' + | 'payment_method_title' + | 'gateway_reference' + | 'order_number' + | 'status' + | 'refunded_total' + | 'store_label' + | 'connection_id' + | 'customer_name' + | 'customer_company' + | 'customer_email' + | 'customer_orgnr' + | 'customer_country' + | 'vat_breakdown' + | 'line_items' +> + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = [] + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)) + return out +} + +function isFrozen(row: Pick): boolean { + return row.journal_entry_id !== null || row.invoice_id !== null +} + +/** + * Field-wise jsonb array comparison. NEVER JSON.stringify: Postgres jsonb + * does not preserve object key order, so a stringify of what PostgREST + * returns differs from a stringify of what was inserted even when the + * values are identical (that bug falsely flagged every booked row as + * remote-changed on the first re-poll). Array ORDER is preserved by jsonb, + * so element-by-element field comparison is exact. + */ +function sameVatBreakdown( + a: ExistingRow['vat_breakdown'], + b: WebshopOrderUpsert['vat_breakdown'], +): boolean { + if (a.length !== b.length) return false + return a.every( + (bucket, i) => + bucket.rate === b[i].rate && bucket.net === b[i].net && bucket.tax === b[i].tax, + ) +} + +function sameLineItems( + a: ExistingRow['line_items'], + b: WebshopOrderUpsert['line_items'], +): boolean { + if (a.length !== b.length) return false + return a.every( + (item, i) => + item.name === b[i].name && + item.quantity === b[i].quantity && + item.total === b[i].total && + item.total_tax === b[i].total_tax && + (item.vat_rate ?? null) === (b[i].vat_rate ?? null), + ) +} + +function financialsDiffer(existing: ExistingRow, incoming: WebshopOrderUpsert): boolean { + for (const field of FINANCIAL_FIELDS) { + if ((existing[field] ?? null) !== (incoming[field] ?? null)) return true + } + return !sameVatBreakdown(existing.vat_breakdown, incoming.vat_breakdown) +} + +/** Non-financial fields that keep syncing on every row, frozen or not. */ +function safeFields(incoming: WebshopOrderUpsert) { + return { + status: incoming.status, + refunded_total: incoming.refunded_total, + store_label: incoming.store_label, + connection_id: incoming.connection_id, + } +} + +interface FxResolution { + total_sek: number | null + exchange_rate: number | null +} + +/** + * Resolve SEK amount for one row, with a per-run (currency, date) cache. + * Unknown currencies and Riksbanken failures resolve to nulls: booking stays + * blocked, never a fake 1:1 rate. + */ +async function resolveFx( + supabase: SupabaseClient, + row: WebshopOrderUpsert, + cache: Map, +): Promise { + const currency = row.currency.toUpperCase() + if (currency === 'SEK') return { total_sek: round(row.total), exchange_rate: 1 } + const rateDate = row.paid_date ?? row.order_date + const cacheKey = `${currency}:${rateDate}` + let rate = cache.get(cacheKey) + if (rate === undefined) { + try { + const result = await fetchExchangeRate( + currency as Currency, + new Date(`${rateDate}T00:00:00Z`), + supabase, + ) + rate = result?.rate ?? null + } catch (err) { + log.warn('exchange rate fetch failed; row stays unbookable until resolved', { + currency, + rateDate, + message: err instanceof Error ? err.message : String(err), + }) + rate = null + } + cache.set(cacheKey, rate) + } + if (rate === null) return { total_sek: null, exchange_rate: null } + return { total_sek: round(row.total * rate), exchange_rate: rate } +} + +export async function upsertWebshopOrders( + supabase: SupabaseClient, + companyId: string, + userId: string, + rows: WebshopOrderUpsert[], +): Promise { + const result: WebshopOrderUpsertResult = { + inserted: 0, + updated: 0, + unchanged: 0, + frozenFlagged: 0, + crossMarked: 0, + errors: 0, + } + if (rows.length === 0) return result + + const recordError = (err: unknown) => { + result.errors += 1 + if (!result.firstError) { + const anyErr = err as { message?: string; code?: string | null } + result.firstError = { + message: anyErr?.message ?? String(err), + code: anyErr?.code ?? null, + } + } + } + + // Two phases: ALL order rows first, then refund rows. A refund's parent is + // resolved from knownIds, which is only populated once the parent's insert + // has actually executed; mixing both row types in one batch would leave + // same-batch refunds unparented. + const orderRows = rows.filter((r) => r.row_type === 'order') + const refundRows = rows.filter((r) => r.row_type === 'refund') + + const fxCache = new Map() + // external_id -> row id, for refund parenting across chunks. + const knownIds = new Map() + + for (const batch of [...chunk(orderRows, CHUNK_SIZE), ...chunk(refundRows, CHUNK_SIZE)]) { + const externalIds = batch.map((r) => r.external_id) + const parentIds = batch + .map((r) => r.parent_external_id) + .filter((id): id is string => id !== null && !knownIds.has(id)) + const lookupIds = Array.from(new Set([...externalIds, ...parentIds])) + + const { data: existingData, error: existingError } = await supabase + .from('webshop_orders') + .select( + 'id, external_id, journal_entry_id, invoice_id, legacy_transaction_id, remote_changed_after_freeze, total, total_tax, total_sek, exchange_rate, currency, order_date, paid_date, is_paid, payment_method, payment_method_title, gateway_reference, order_number, status, refunded_total, store_label, connection_id, customer_name, customer_company, customer_email, customer_orgnr, customer_country, vat_breakdown, line_items', + ) + .eq('company_id', companyId) + .in('external_id', lookupIds) + if (existingError) { + recordError(existingError) + continue + } + const existingByExternalId = new Map() + for (const row of (existingData ?? []) as ExistingRow[]) { + existingByExternalId.set(row.external_id, row) + knownIds.set(row.external_id, row.id) + } + + // Legacy feed overlap for this batch, one query. + const { data: legacyData, error: legacyError } = await supabase + .from('transactions') + .select('id, external_id') + .eq('company_id', companyId) + .in('external_id', externalIds) + if (legacyError) { + recordError(legacyError) + continue + } + const legacyByExternalId = new Map() + for (const row of (legacyData ?? []) as Array<{ id: string; external_id: string }>) { + legacyByExternalId.set(row.external_id, row.id) + } + + const inserts: Array> = [] + + for (const incoming of batch) { + const existing = existingByExternalId.get(incoming.external_id) + const legacyTransactionId = legacyByExternalId.get(incoming.external_id) ?? null + const parentOrderId = incoming.parent_external_id + ? (knownIds.get(incoming.parent_external_id) ?? null) + : null + + if (!existing) { + const fx = await resolveFx(supabase, incoming, fxCache) + inserts.push({ + company_id: companyId, + user_id: userId, + platform: incoming.platform, + store_scope: incoming.store_scope, + store_label: incoming.store_label, + connection_id: incoming.connection_id, + row_type: incoming.row_type, + parent_order_id: parentOrderId, + external_id: incoming.external_id, + platform_order_id: incoming.platform_order_id, + order_number: incoming.order_number, + status: incoming.status, + is_paid: incoming.is_paid, + order_date: incoming.order_date, + paid_date: incoming.paid_date, + currency: incoming.currency.toUpperCase(), + total: incoming.total, + total_tax: incoming.total_tax, + total_sek: fx.total_sek, + exchange_rate: fx.exchange_rate, + vat_breakdown: incoming.vat_breakdown, + line_items: incoming.line_items, + customer_name: incoming.customer_name, + customer_company: incoming.customer_company, + customer_email: incoming.customer_email, + customer_orgnr: incoming.customer_orgnr, + customer_country: incoming.customer_country, + payment_method: incoming.payment_method, + payment_method_title: incoming.payment_method_title, + gateway_reference: incoming.gateway_reference, + refunded_total: incoming.refunded_total, + legacy_transaction_id: legacyTransactionId, + }) + if (legacyTransactionId) result.crossMarked += 1 + continue + } + + if (isFrozen(existing)) { + const drifted = financialsDiffer(existing, incoming) + const update: Record = { ...safeFields(incoming) } + if (drifted && !existing.remote_changed_after_freeze) { + update.remote_changed_after_freeze = true + } + const changedSafe = + existing.status !== incoming.status || + existing.refunded_total !== incoming.refunded_total || + existing.store_label !== incoming.store_label || + existing.connection_id !== incoming.connection_id || + update.remote_changed_after_freeze === true + if (!changedSafe) { + result.unchanged += 1 + continue + } + const { error } = await supabase + .from('webshop_orders') + .update(update) + .eq('id', existing.id) + .eq('company_id', companyId) + if (error) { + recordError(error) + } else { + result.updated += 1 + if (drifted) result.frozenFlagged += 1 + } + continue + } + + // Unfrozen existing row: full refresh. Recompute FX only when the + // money or dates moved; otherwise keep the stored resolution (or + // retry a previously unresolved rate). + const moneyMoved = financialsDiffer(existing, incoming) + const needsFx = moneyMoved || existing.total_sek === null + const fx = needsFx + ? await resolveFx(supabase, incoming, fxCache) + : { total_sek: existing.total_sek, exchange_rate: existing.exchange_rate } + + const update: Record = { + ...safeFields(incoming), + order_number: incoming.order_number, + is_paid: incoming.is_paid, + order_date: incoming.order_date, + paid_date: incoming.paid_date, + currency: incoming.currency.toUpperCase(), + total: incoming.total, + total_tax: incoming.total_tax, + total_sek: fx.total_sek, + exchange_rate: fx.exchange_rate, + vat_breakdown: incoming.vat_breakdown, + line_items: incoming.line_items, + customer_name: incoming.customer_name, + customer_company: incoming.customer_company, + customer_email: incoming.customer_email, + customer_orgnr: incoming.customer_orgnr, + customer_country: incoming.customer_country, + payment_method: incoming.payment_method, + payment_method_title: incoming.payment_method_title, + gateway_reference: incoming.gateway_reference, + } + // Never null-out an already-resolved parent link (the parent may just + // be absent from this batch's lookup). + if (parentOrderId !== null) update.parent_order_id = parentOrderId + const newLegacyLink = + existing.legacy_transaction_id === null && legacyTransactionId !== null + if (newLegacyLink) { + update.legacy_transaction_id = legacyTransactionId + result.crossMarked += 1 + } + + // Every field the update payload writes must be compared here: a field + // written but not compared makes its corrections silently drop as + // "unchanged" (review finding: billing corrections). + const unchanged = + !moneyMoved && + !needsFx && + !newLegacyLink && + existing.status === incoming.status && + existing.refunded_total === incoming.refunded_total && + existing.store_label === incoming.store_label && + existing.connection_id === incoming.connection_id && + existing.order_number === incoming.order_number && + (existing.payment_method_title ?? null) === (incoming.payment_method_title ?? null) && + (existing.gateway_reference ?? null) === (incoming.gateway_reference ?? null) && + (existing.customer_name ?? null) === (incoming.customer_name ?? null) && + (existing.customer_company ?? null) === (incoming.customer_company ?? null) && + (existing.customer_email ?? null) === (incoming.customer_email ?? null) && + (existing.customer_orgnr ?? null) === (incoming.customer_orgnr ?? null) && + (existing.customer_country ?? null) === (incoming.customer_country ?? null) && + sameLineItems(existing.line_items, incoming.line_items) + if (unchanged) { + result.unchanged += 1 + continue + } + + const { error } = await supabase + .from('webshop_orders') + .update(update) + .eq('id', existing.id) + .eq('company_id', companyId) + if (error) recordError(error) + else result.updated += 1 + } + + if (inserts.length > 0) { + const { data: insertedRows, error: insertError } = await supabase + .from('webshop_orders') + .insert(inserts) + .select('id, external_id') + if (insertError) { + recordError(insertError) + log.warn('webshop order insert batch failed', { + companyId, + batchSize: inserts.length, + code: (insertError as { code?: string }).code, + }) + } else { + result.inserted += insertedRows?.length ?? 0 + for (const row of (insertedRows ?? []) as Array<{ id: string; external_id: string }>) { + knownIds.set(row.external_id, row.id) + } + } + } + } + + return result +} diff --git a/lib/webshop-orders/types.ts b/lib/webshop-orders/types.ts new file mode 100644 index 00000000..c6ddf6f9 --- /dev/null +++ b/lib/webshop-orders/types.ts @@ -0,0 +1,61 @@ +import type { + WebshopOrderLineItem, + WebshopOrderRowType, + WebshopPlatform, + WebshopVatBreakdownLine, +} from '@/types' + +/** + * Row shape the platform syncs (woocommerce/shopify extensions) hand to + * upsertWebshopOrders(). Everything the sync knows about one bookable money + * event; the service owns FX enrichment, legacy cross-marking and the + * frozen-row rules, so none of those fields appear here. + */ +export interface WebshopOrderUpsert { + platform: WebshopPlatform + store_scope: string + store_label: string | null + connection_id: string | null + row_type: WebshopOrderRowType + /** + * external_id of the parent order for refund rows; resolved to + * parent_order_id by the service (the parent may be in the same batch or + * already in the table from an earlier run). Null on order rows. + */ + parent_external_id: string | null + external_id: string + platform_order_id: string + order_number: string + status: string + is_paid: boolean + order_date: string + paid_date: string | null + currency: string + total: number + total_tax: number + vat_breakdown: WebshopVatBreakdownLine[] + line_items: WebshopOrderLineItem[] + customer_name: string | null + customer_company: string | null + customer_email: string | null + customer_orgnr: string | null + customer_country: string | null + payment_method: string | null + payment_method_title: string | null + gateway_reference: string | null + refunded_total: number +} + +export interface WebshopOrderUpsertResult { + inserted: number + updated: number + /** Existing rows whose incoming payload carried no change. */ + unchanged: number + /** Booked/invoiced rows whose financials drifted remotely; flagged, not updated. */ + frozenFlagged: number + /** Rows linked to an already-imported legacy transactions feed row. */ + crossMarked: number + errors: number + /** First error encountered, surfaced for the sync summary/logs. */ + firstError?: { message: string; code?: string | null } +} diff --git a/messages/en.json b/messages/en.json index fc560ab4..41ef4c03 100644 --- a/messages/en.json +++ b/messages/en.json @@ -451,6 +451,7 @@ "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.", + "add_store": "Connect another store", "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", @@ -5557,6 +5558,73 @@ "col_default_price": "Default price", "col_unit": "Unit" }, + "webshop_orders": { + "title": "Orders", + "all_stores": "All stores", + "store_picker_aria": "Choose store", + "tab_all": "All", + "tab_unpaid": "Unpaid", + "tab_to_book": "To book", + "tab_refunds": "Refunds", + "tab_booked": "Booked", + "col_date": "Date", + "col_order": "Order", + "col_store": "Store", + "col_customer": "Customer", + "col_payment_method": "Payment method", + "col_total": "Amount", + "col_status": "Status", + "empty_title": "No orders yet", + "empty_description": "Connect your webshop and orders arrive here nightly, with customer details, payment method and VAT.", + "empty_action": "Connect webshop", + "load_failed": "The orders could not be fetched. Check your connection and try again.", + "retry": "Try again", + "invalid_account": "Enter a four-digit account number, e.g. 1930.", + "pagination": "{from}–{to} of {total}", + "prev": "Previous", + "next": "Next", + "refund_prefix": "Refund", + "status_remote_changed": "Changed after booking", + "status_booked": "Booked", + "status_invoiced": "Invoiced", + "status_in_transactions": "In transactions", + "status_unpaid": "Unpaid", + "status_to_book": "Not booked", + "action_book": "Book", + "action_create_invoice": "Create invoice", + "book_title": "Book order {number}", + "book_refund_title": "Book refund of order {number}", + "fx_unresolved": "The exchange rate for the order currency could not be fetched yet. Try again shortly.", + "invoice_mode_hint": "This payment method is marked as invoice flow for the store. Consider Create invoice instead of booking directly.", + "payment_account_label": "Payment account", + "remember_account": "Remember the account for {method}", + "warning_zero_rate_foreign": "The order has VAT-free sales to a customer outside Sweden. The suggestion uses 3004 (other VAT-free); export or EU sales normally belong on 31xx/33xx accounts. Adjust the lines if needed.", + "warning_foreign_vat": "The order is in a foreign currency with VAT. Verify that Swedish output VAT is correct: for EU sales above the threshold (OSS), VAT should not be reported through the 2611 series.", + "invoice_title": "Create invoice from order {number}", + "invoice_description": "A draft invoice is created for {customer}. You review the draft before sending; the invoice number is assigned when it is sent.", + "invoice_no_customer": "Unknown customer", + "invoice_orgnr_hint": "Org. no. from the store: {orgnr}. Verify before sending the invoice.", + "invoice_col_item": "Line", + "invoice_col_amount": "Amount", + "invoice_total": "Total", + "cancel": "Cancel", + "invoice_confirm": "Create draft", + "invoice_creating": "Creating…", + "invoice_created": "Draft invoice created", + "invoice_create_failed": "Could not create the invoice", + "mapping_title": "Booking per payment method", + "mapping_intro": "Chooses which account is suggested when an order is booked. The suggestion can always be changed before booking; nothing is booked automatically.", + "mapping_saved": "Account mapping saved", + "mapping_save_failed": "Could not save the account mapping", + "mapping_mode_aria": "Handling for {method}", + "mapping_mode_unmapped": "Default (1680)", + "mapping_mode_book": "Book to account", + "mapping_mode_invoice": "Invoiced", + "mapping_account_aria": "Account for {method}", + "mapping_note": "Swish is usually mapped to 1930, card and Klarna to a 15xx account. Stores using Stripe as the gateway should map to 1686 so Stripe payouts reconcile against the same account.", + "mapping_save": "Save", + "mapping_saving": "Saving…" + }, "sales_orders": { "title": "Sales orders", "back_to_list": "Back to orders", diff --git a/messages/sv.json b/messages/sv.json index ae577235..370ecd53 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -451,6 +451,7 @@ "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.", + "add_store": "Anslut ytterligare en butik", "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)", @@ -5557,6 +5558,73 @@ "col_default_price": "Standardpris", "col_unit": "Enhet" }, + "webshop_orders": { + "title": "Order", + "all_stores": "Alla butiker", + "store_picker_aria": "Välj butik", + "tab_all": "Alla", + "tab_unpaid": "Obetalda", + "tab_to_book": "Att bokföra", + "tab_refunds": "Återbetalningar", + "tab_booked": "Bokförda", + "col_date": "Datum", + "col_order": "Order", + "col_store": "Butik", + "col_customer": "Kund", + "col_payment_method": "Betalsätt", + "col_total": "Belopp", + "col_status": "Status", + "empty_title": "Inga ordrar ännu", + "empty_description": "Anslut din webshop så hämtas ordrarna hit varje natt, med kunduppgifter, betalsätt och moms.", + "empty_action": "Anslut webshop", + "load_failed": "Ordrarna kunde inte hämtas. Kontrollera din anslutning och försök igen.", + "retry": "Försök igen", + "invalid_account": "Ange ett fyrsiffrigt kontonummer, t.ex. 1930.", + "pagination": "{from}–{to} av {total}", + "prev": "Föregående", + "next": "Nästa", + "refund_prefix": "Återbetalning", + "status_remote_changed": "Ändrad efter bokföring", + "status_booked": "Bokförd", + "status_invoiced": "Fakturerad", + "status_in_transactions": "Finns i transaktioner", + "status_unpaid": "Obetald", + "status_to_book": "Ej bokförd", + "action_book": "Bokför", + "action_create_invoice": "Skapa faktura", + "book_title": "Bokför order {number}", + "book_refund_title": "Bokför återbetalning av order {number}", + "fx_unresolved": "Växelkursen för orderns valuta har inte kunnat hämtas ännu. Försök igen om en stund.", + "invoice_mode_hint": "Betalsättet är markerat som fakturaflöde för butiken. Överväg Skapa faktura i stället för att bokföra direkt.", + "payment_account_label": "Betalkonto", + "remember_account": "Kom ihåg kontot för {method}", + "warning_zero_rate_foreign": "Ordern har momsfri försäljning till kund utanför Sverige. Förslaget använder 3004 (övrig momsfri); export- eller EU-försäljning ska normalt bokas på 31xx/33xx-konton. Justera raderna vid behov.", + "warning_foreign_vat": "Ordern är i utländsk valuta med moms. Kontrollera att svensk utgående moms är rätt: vid EU-försäljning över tröskelvärdet (OSS) ska momsen inte redovisas via 2611-serien.", + "invoice_title": "Skapa faktura från order {number}", + "invoice_description": "Ett fakturautkast skapas för {customer}. Du granskar utkastet innan det skickas; fakturanummer sätts först när fakturan skickas.", + "invoice_no_customer": "Okänd kund", + "invoice_orgnr_hint": "Org.nr från butiken: {orgnr}. Kontrollera innan fakturan skickas.", + "invoice_col_item": "Rad", + "invoice_col_amount": "Belopp", + "invoice_total": "Totalt", + "cancel": "Avbryt", + "invoice_confirm": "Skapa utkast", + "invoice_creating": "Skapar…", + "invoice_created": "Fakturautkast skapat", + "invoice_create_failed": "Kunde inte skapa fakturan", + "mapping_title": "Bokföring per betalsätt", + "mapping_intro": "Väljer vilket konto som föreslås när en order bokförs. Förslaget kan alltid ändras innan bokföring; inget bokförs automatiskt.", + "mapping_saved": "Kontomappning sparad", + "mapping_save_failed": "Kunde inte spara kontomappningen", + "mapping_mode_aria": "Hantering för {method}", + "mapping_mode_unmapped": "Standard (1680)", + "mapping_mode_book": "Bokför mot konto", + "mapping_mode_invoice": "Faktureras", + "mapping_account_aria": "Konto för {method}", + "mapping_note": "Swish brukar mappas till 1930, kort och Klarna till ett 15xx-konto. Butiker med Stripe som betalväxel bör mappa till 1686 så att Stripe-utbetalningarna stämmer av mot samma konto.", + "mapping_save": "Spara", + "mapping_saving": "Sparar…" + }, "sales_orders": { "title": "Order", "back_to_list": "Tillbaka till order", diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 9c2f537b..58241674 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -7,7 +7,7 @@ ] }, "naiveOreRound": { - "count": 638 + "count": 637 }, "handRolledInvariants": { "count": 115 diff --git a/skills/accounted-api/references/journal-entries.md b/skills/accounted-api/references/journal-entries.md index a43e43bf..34ab43cd 100644 --- a/skills/accounted-api/references/journal-entries.md +++ b/skills/accounted-api/references/journal-entries.md @@ -71,7 +71,7 @@ Request body: fiscal_period_id: string, entry_date: string, description: string, - source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout", + source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order", source_id?: string, voucher_series?: string, notes?: string, @@ -320,7 +320,7 @@ Bulk-create endpoint mirroring /invoices/bulk-create and /suppliers/bulk-create. Request body: ```ts { - journal_entries: { fiscal_period_id: string, entry_date: string, description: string, source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout", source_id?: string, voucher_series?: string, notes?: string, lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record, cost_center?: string, project?: string }[] }[], + journal_entries: { fiscal_period_id: string, entry_date: string, description: string, source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order", source_id?: string, voucher_series?: string, notes?: string, lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record, cost_center?: string, project?: string }[] }[], all_or_nothing?: boolean } ``` diff --git a/supabase/migrations/20260811073315_webshop_orders.sql b/supabase/migrations/20260811073315_webshop_orders.sql new file mode 100644 index 00000000..3ae32eb2 --- /dev/null +++ b/supabase/migrations/20260811073315_webshop_orders.sql @@ -0,0 +1,166 @@ +-- Webshop orders: platform-agnostic order/refund rows from connected webshops +-- (WooCommerce first; Shopify plugs into the same table via platform). +-- +-- Replaces the WooCommerce transactions-inbox feed as the landing surface for +-- store orders: the sync upserts rich order rows here (customer, payment +-- method, VAT breakdown, line items) instead of anonymous feed rows in +-- public.transactions. Each row is one bookable money event: row_type 'order' +-- carries the gross sale, each refund is its own 'refund' row with a negative +-- total and a parent_order_id self-reference, so refunds arriving in later +-- periods book independently. +-- +-- Unlike the append-only transactions feed, rows are UPSERTED on +-- (company_id, external_id): order status changes, date_paid arriving later, +-- growing refund totals and billing corrections all land on re-sync. The +-- boundary is the financial-freeze trigger below: once a row is booked +-- (journal_entry_id) or invoiced (invoice_id) its financial fields are +-- immutable; corrections go through the sanctioned storno/rattelse paths. +-- The sync service respects the freeze application-side and flags divergence +-- via remote_changed_after_freeze instead of failing. +-- +-- external_id reuses the FROZEN feed scheme (woo_{storeScope}_order_{id} / +-- woo_{storeScope}_refund_{id}), which makes the overlap with rows already +-- imported into public.transactions a pure string join: such rows carry +-- legacy_transaction_id and the booking route refuses to double-book them. +-- +-- No write_audit_log trigger: rows are a nightly-refreshed mirror of store +-- data and auditing every upsert would flood audit_log. The accounting- +-- relevant events (booking, invoicing) are audited on journal_entries / +-- invoices themselves. + +create table public.webshop_orders ( + 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, + platform text not null check (platform in ('woocommerce', 'shopify')), + -- Normalized store host(+path), same value frozen into external_id by + -- wooStoreScope(); the store identity that survives disconnect/reconnect. + store_scope text not null, + -- Display snapshot (store title), refreshed on sync. + store_label text, + -- Soft pointer to the platform's *_connections row. Deliberately no FK: + -- order rows are accounting underlag and must survive disconnect/reconnect. + connection_id uuid, + row_type text not null default 'order' check (row_type in ('order', 'refund')), + parent_order_id uuid references public.webshop_orders(id) on delete cascade, + -- FROZEN feed scheme: woo_{scope}_order_{id} / woo_{scope}_refund_{id}. + external_id text not null, + -- Raw remote order/refund id. + platform_order_id text not null, + -- Display number (refund rows carry the parent order's number). + order_number text not null, + -- Raw platform status (pending/processing/completed/refunded/cancelled/...). + status text not null, + is_paid boolean not null default false, + -- Order rows: date_created; refund rows: refund date_created. + order_date date not null, + paid_date date, + currency text not null, + -- Gross incl. tax and shipping; NEGATIVE on refund rows. + total numeric(14,2) not null, + total_tax numeric(14,2) not null default 0, + -- Null until the Riksbanken rate resolves; booking is blocked while null. + total_sek numeric(14,2), + exchange_rate numeric(12,6), + -- [{"rate": 25, "net": 400.00, "tax": 100.00}] in order currency. + vat_breakdown jsonb not null default '[]'::jsonb, + -- [{"name", "quantity", "total", "total_tax", "vat_rate"}] + line_items jsonb not null default '[]'::jsonb, + customer_name text, + customer_company text, + customer_email text, + -- Best effort (billing.company pattern + meta_data scan); must be user- + -- confirmed before use in invoice legal fields. + customer_orgnr text, + -- Gateway id ('swish', 'klarna_payments', 'stripe', 'bacs', ...). + payment_method text, + payment_method_title text, + -- order.transaction_id; join key for gateway-side reconciliation. + gateway_reference text, + -- Order rows: informational sum of refunds seen so far. + refunded_total numeric(14,2) not null default 0, + journal_entry_id uuid references public.journal_entries(id), + invoice_id uuid references public.invoices(id), + -- Same money event already imported by the legacy transactions feed. + legacy_transaction_id uuid references public.transactions(id), + -- A financial delta arrived from the store after booking/invoicing froze + -- this row; surfaced in the UI, resolved via storno, never silently applied. + remote_changed_after_freeze boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index webshop_orders_company_external_uniq + on public.webshop_orders (company_id, external_id); +create index idx_webshop_orders_company_date + on public.webshop_orders (company_id, order_date desc); +create index idx_webshop_orders_company_store + on public.webshop_orders (company_id, platform, store_scope); +create index idx_webshop_orders_parent + on public.webshop_orders (parent_order_id) where (parent_order_id is not null); +create index idx_webshop_orders_journal_entry + on public.webshop_orders (journal_entry_id) where (journal_entry_id is not null); +create index idx_webshop_orders_invoice + on public.webshop_orders (invoice_id) where (invoice_id is not null); + +alter table public.webshop_orders enable row level security; + +-- Members read and update (the book/create-invoice routes run on the cookie +-- session and write back journal_entry_id / invoice_id). INSERT is service- +-- role only (the sync cron); no member INSERT policy on purpose. No DELETE +-- policy: order rows are accounting underlag; booked rows fall under BFL +-- 7-year retention via their journal entries. +create policy "members read webshop_orders" + on public.webshop_orders for select + using (company_id in (select public.user_company_ids())); + +create policy "members update webshop_orders" + on public.webshop_orders 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_webshop_orders + before update on public.webshop_orders + for each row execute function public.update_updated_at_column(); + +-- Financial freeze: once a row is booked or invoiced, the fields that fed the +-- verifikat/invoice are immutable. Status, refund summary, labels, links and +-- the divergence flag stay mutable so sync keeps working. Mirrors the spirit +-- of enforce_journal_entry_immutability one layer up: the underlag a posted +-- entry was built from must not drift underneath it. +create or replace function public.enforce_webshop_order_financial_freeze() +returns trigger +language plpgsql +as $$ +begin + if old.journal_entry_id is not null or old.invoice_id is not null then + if new.total is distinct from old.total + or new.total_tax is distinct from old.total_tax + or new.total_sek is distinct from old.total_sek + or new.exchange_rate is distinct from old.exchange_rate + or new.currency is distinct from old.currency + or new.vat_breakdown is distinct from old.vat_breakdown + or new.line_items is distinct from old.line_items + or new.order_date is distinct from old.order_date + or new.paid_date is distinct from old.paid_date + or new.is_paid is distinct from old.is_paid + or new.payment_method is distinct from old.payment_method + or new.external_id is distinct from old.external_id + or new.platform_order_id is distinct from old.platform_order_id + then + raise exception 'webshop_orders row % is booked/invoiced; financial fields are frozen (use storno)', old.id + using errcode = 'P0001'; + end if; + end if; + return new; +end; +$$; + +create trigger enforce_webshop_order_financial_freeze + before update on public.webshop_orders + for each row execute function public.enforce_webshop_order_financial_freeze(); + +comment on table public.webshop_orders is + 'Order/refund rows synced from connected webshops (WooCommerce, Shopify). One row per bookable money event; upserted on (company_id, external_id); financial fields freeze once booked or invoiced.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260811073333_webshop_store_settings.sql b/supabase/migrations/20260811073333_webshop_store_settings.sql new file mode 100644 index 00000000..b98d8d22 --- /dev/null +++ b/supabase/migrations/20260811073333_webshop_store_settings.sql @@ -0,0 +1,60 @@ +-- Per-store webshop booking settings: payment-method -> account mapping that +-- prefills the order booking dialog (prefill only; the user always confirms, +-- and per-booking overrides persist only via the explicit "remember" opt-in). +-- +-- Keyed by (company_id, platform, store_scope) rather than hung off +-- woocommerce_connections: the core booking route must read it without +-- touching extension-owned connection state, the mapping must survive +-- disconnect/reconnect (connections are revoked, never deleted, and can be +-- re-created), and Shopify reuses it with zero schema change. + +create table public.webshop_store_settings ( + 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, + platform text not null check (platform in ('woocommerce', 'shopify')), + store_scope text not null, + -- { "": {"mode": "book", "account": "1930"} + -- | {"mode": "invoice"} } + -- Account numbers are strings (identifiers, not quantities). + payment_method_account_map jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (company_id, platform, store_scope) +); + +create index idx_webshop_store_settings_company_id + on public.webshop_store_settings (company_id); + +alter table public.webshop_store_settings enable row level security; + +-- Member-scoped config with upsert semantics; no DELETE policy needed (a +-- cleared mapping is an empty map, not a deleted row). +create policy "members read webshop_store_settings" + on public.webshop_store_settings for select + using (company_id in (select public.user_company_ids())); + +create policy "members insert webshop_store_settings" + on public.webshop_store_settings for insert + with check ( + company_id in (select public.user_company_ids()) + and user_id = auth.uid() + ); + +create policy "members update webshop_store_settings" + on public.webshop_store_settings 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_webshop_store_settings + before update on public.webshop_store_settings + for each row execute function public.update_updated_at_column(); + +create trigger audit_webshop_store_settings + after insert or update or delete on public.webshop_store_settings + for each row execute function public.write_audit_log(); + +comment on table public.webshop_store_settings is + 'Per-store payment-method -> BAS account mapping for webshop order booking. Prefill only; never auto-books.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260811073416_journal_source_type_webshop_order.sql b/supabase/migrations/20260811073416_journal_source_type_webshop_order.sql new file mode 100644 index 00000000..bc333544 --- /dev/null +++ b/supabase/migrations/20260811073416_journal_source_type_webshop_order.sql @@ -0,0 +1,44 @@ +-- Migration: add 'webshop_order' to journal_entries.source_type CHECK +-- +-- Webshop order booking (Dr mapped payment account 1930/15xx gross / +-- Cr 30xx net + 26xx VAT per rate) is created from the Orders page and tags +-- its entries source_type='webshop_order' so they are distinguishable from +-- manual entries and routable to their own voucher series. The insert is +-- rejected with PG 23514 unless the value is in the DB allowlist; this +-- migration appends it. The TS type (JournalEntrySourceType) and the Zod +-- schema (JournalEntrySourceTypeSchema) are updated in the same change. +-- +-- See 20260712100500 for the previous expansion pattern. We preserve all +-- pre-existing source_type values and append the new one. The voucher-series +-- default JSONB is deliberately not touched (same as previous expansions): +-- lib/bookkeeping/voucher-series-resolver.ts falls back to 'A' for keys +-- missing from default_voucher_series_per_source_type. + +ALTER TABLE public.journal_entries + DROP CONSTRAINT IF EXISTS journal_entries_source_type_check; + +ALTER TABLE public.journal_entries + ADD CONSTRAINT journal_entries_source_type_check + CHECK (source_type IN ( + 'manual', 'bank_transaction', 'invoice_created', + 'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment', + 'opening_balance', 'year_end', + 'storno', 'correction', 'import', 'system', + 'inbox_item', + 'supplier_invoice_registered', 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', 'supplier_credit_note', + 'currency_revaluation', + 'supplier_invoice_privately_paid', + 'reminder_fee', + 'accrual', + 'result_appropriation', + 'rot_rut_payout', + 'vat_settlement', + 'stripe_payout', + 'webshop_order' + )) NOT VALID; + +ALTER TABLE public.journal_entries + VALIDATE CONSTRAINT journal_entries_source_type_check; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260811073422_woocommerce_multi_store.sql b/supabase/migrations/20260811073422_woocommerce_multi_store.sql new file mode 100644 index 00000000..0d20d6cf --- /dev/null +++ b/supabase/migrations/20260811073422_woocommerce_multi_store.sql @@ -0,0 +1,10 @@ +-- Multi-store WooCommerce: a company may connect several stores. +-- +-- The Orders page is built around users running multiple webshops, so the +-- one-active-connection-per-company limit falls. The other uniqueness stays: +-- a store may still be actively connected to at most ONE company (two +-- companies importing the same order stream would double-book it), and the +-- (company_id, external_id) unique index on webshop_orders / transactions +-- keeps per-company dedup intact regardless of connection count. + +drop index if exists public.woocommerce_connections_one_active_per_company; diff --git a/supabase/migrations/20260811211335_webshop_orders_customer_country.sql b/supabase/migrations/20260811211335_webshop_orders_customer_country.sql new file mode 100644 index 00000000..eff8b9c9 --- /dev/null +++ b/supabase/migrations/20260811211335_webshop_orders_customer_country.sql @@ -0,0 +1,14 @@ +-- Billing country on webshop order rows (ISO 3166-1 alpha-2, e.g. 'SE'). +-- +-- The booking dialog needs it for the export/EU advisory: a 0%-rate amount +-- on an order with a known non-SE billing country should not silently book +-- to 3004 (ruta 42, domestic momsfri); export/EU sales belong on 31xx/33xx +-- accounts. Advisory only: the user always confirms the lines. + +alter table public.webshop_orders + add column customer_country text; + +comment on column public.webshop_orders.customer_country is + 'Billing country (ISO 3166-1 alpha-2) from the store; drives the export/EU booking hint.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260811211418_woocommerce_cursor_reset_for_orders_backfill.sql b/supabase/migrations/20260811211418_woocommerce_cursor_reset_for_orders_backfill.sql new file mode 100644 index 00000000..e1cd3414 --- /dev/null +++ b/supabase/migrations/20260811211418_woocommerce_cursor_reset_for_orders_backfill.sql @@ -0,0 +1,26 @@ +-- One-time cursor reset for active WooCommerce connections. +-- +-- The Orders switch-over replaced the transactions feed with webshop_orders +-- rows, and the overlap with already-imported feed rows is handled by +-- cross-marking (legacy_transaction_id). But the sync only lists orders +-- modified after cursor - 24h, and the 90-day backfill only runs when the +-- cursor is NULL: connections that synced under the old feed would never +-- re-list their existing orders, so those orders would never become +-- webshop_orders rows and the promised cross-marks would never happen. +-- +-- Resetting the cursor makes the next run re-fetch the last 90 days as a +-- backfill. Idempotent by design: upsert on (company_id, external_id) turns +-- re-seen orders into no-ops, and rows that also exist in the transactions +-- feed get cross-marked instead of double-imported. +-- +-- Guarded: environments that never received the WooCommerce extension's +-- table (e.g. drifted dev databases) skip the reset. + +do $$ +begin + if to_regclass('public.woocommerce_connections') is not null then + update public.woocommerce_connections + set last_order_synced_at = null + where status = 'active'; + end if; +end $$; diff --git a/supabase/migrations/20260812124858_webshop_orders_freeze_link_columns.sql b/supabase/migrations/20260812124858_webshop_orders_freeze_link_columns.sql new file mode 100644 index 00000000..488f050d --- /dev/null +++ b/supabase/migrations/20260812124858_webshop_orders_freeze_link_columns.sql @@ -0,0 +1,66 @@ +-- Freeze v2: protect the booking/invoice LINK columns themselves. +-- +-- v1 (20260811073315) froze the financial fields once journal_entry_id or +-- invoice_id was set, but the link columns were not in the protected list: +-- a member could clear the link in one statement and mutate the financials +-- in the next (review finding, PR #1525). Now: +-- +-- - invoice_id: immutable once set. No flow legitimately unlinks an invoice +-- (the create-invoice rollback deletes a draft it never managed to link). +-- - journal_entry_id: may change only while the referenced entry is NOT +-- posted. The booking route's failure path unlinks its cancelled draft +-- (status stays 'draft'); once the entry is posted the link is underlag +-- and only storno may follow. +-- +-- CREATE OR REPLACE keeps the trigger binding from v1 intact. + +create or replace function public.enforce_webshop_order_financial_freeze() +returns trigger +language plpgsql +as $$ +declare + v_entry_status text; +begin + -- Link-column protection runs FIRST: it applies even when the row was + -- frozen by the other link. + if old.invoice_id is not null + and new.invoice_id is distinct from old.invoice_id + then + raise exception 'webshop_orders row % is linked to an invoice; the link is immutable', old.id + using errcode = 'P0001'; + end if; + + if old.journal_entry_id is not null + and new.journal_entry_id is distinct from old.journal_entry_id + then + select status into v_entry_status + from public.journal_entries + where id = old.journal_entry_id; + if v_entry_status is null or v_entry_status = 'posted' then + raise exception 'webshop_orders row % is booked; the journal link is immutable (use storno)', old.id + using errcode = 'P0001'; + end if; + end if; + + if old.journal_entry_id is not null or old.invoice_id is not null then + if new.total is distinct from old.total + or new.total_tax is distinct from old.total_tax + or new.total_sek is distinct from old.total_sek + or new.exchange_rate is distinct from old.exchange_rate + or new.currency is distinct from old.currency + or new.vat_breakdown is distinct from old.vat_breakdown + or new.line_items is distinct from old.line_items + or new.order_date is distinct from old.order_date + or new.paid_date is distinct from old.paid_date + or new.is_paid is distinct from old.is_paid + or new.payment_method is distinct from old.payment_method + or new.external_id is distinct from old.external_id + or new.platform_order_id is distinct from old.platform_order_id + then + raise exception 'webshop_orders row % is booked/invoiced; financial fields are frozen (use storno)', old.id + using errcode = 'P0001'; + end if; + end if; + return new; +end; +$$; diff --git a/tests/pg/webshop-orders.pg.test.ts b/tests/pg/webshop-orders.pg.test.ts new file mode 100644 index 00000000..e1cac0a8 --- /dev/null +++ b/tests/pg/webshop-orders.pg.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect } from 'vitest' +import { getPool, withUserContext } from './setup' +import { randomUUID } from 'crypto' +import { seedCompany, insertDraftJournalEntry } from './fixtures' + +/** + * Covers migrations 20260811073315_webshop_orders, + * 20260811073333_webshop_store_settings and + * 20260811073416_journal_source_type_webshop_order: + * 1. RLS: members read/update their company's rows, cannot INSERT + * (sync is service-role only) and cannot DELETE; outsiders see nothing. + * 2. (company_id, external_id) unique index. + * 3. Financial freeze trigger: booked rows reject money-field updates but + * accept status/refund-summary updates. + * 4. journal_entries.source_type accepts 'webshop_order'. + * 5. webshop_store_settings RLS + upsert key. + */ + +// Rows persist across pg-real runs; unique external ids per run. +const uniqueExternalId = (label: string) => + `woo_test-${label}-${randomUUID()}.example.se_order_1001` + +async function insertOrderRow(params: { + companyId: string + userId: string + externalId?: string + journalEntryId?: string | null +}): Promise { + const { rows } = await getPool().query( + `INSERT INTO public.webshop_orders + (company_id, user_id, platform, store_scope, row_type, external_id, + platform_order_id, order_number, status, is_paid, order_date, paid_date, + currency, total, total_tax, total_sek, exchange_rate, vat_breakdown, + payment_method, journal_entry_id) + VALUES ($1, $2, 'woocommerce', 'butik.example.se', 'order', $3, + '1001', '1001', 'processing', true, '2026-08-01', '2026-08-01', + 'SEK', 500.00, 100.00, 500.00, 1, '[{"rate":25,"net":400,"tax":100}]'::jsonb, + 'swish', $4) + RETURNING id`, + [ + params.companyId, + params.userId, + params.externalId ?? uniqueExternalId('order'), + params.journalEntryId ?? null, + ], + ) + return rows[0].id as string +} + +describe('webshop_orders RLS', () => { + it('a member reads and updates own-company rows but cannot delete', async () => { + const { userId, companyId } = await seedCompany() + const rowId = await insertOrderRow({ companyId, userId }) + + await withUserContext(userId, async (client) => { + const read = await client.query( + `SELECT status, payment_method FROM public.webshop_orders WHERE company_id = $1`, + [companyId], + ) + expect(read.rows).toEqual([{ status: 'processing', payment_method: 'swish' }]) + + // Member UPDATE works (the booking route writes back journal_entry_id). + const updated = await client.query( + `UPDATE public.webshop_orders SET status = 'completed' WHERE id = $1`, + [rowId], + ) + expect(updated.rowCount).toBe(1) + + // No DELETE policy: order rows are accounting underlag. + const del = await client.query( + `DELETE FROM public.webshop_orders WHERE id = $1`, + [rowId], + ) + expect(del.rowCount).toBe(0) + }) + }) + + it('a member cannot INSERT (the sync path is service-role only)', async () => { + // Own withUserContext block: an RLS rejection aborts the transaction, so + // the failing statement must be the block's last. + const { userId, companyId } = await seedCompany() + await withUserContext(userId, async (client) => { + await expect( + client.query( + `INSERT INTO public.webshop_orders + (company_id, user_id, platform, store_scope, external_id, + platform_order_id, order_number, status, order_date, currency, total) + VALUES ($1, $2, 'woocommerce', 'butik.example.se', $3, + '9', '9', 'pending', '2026-08-01', 'SEK', 100.00)`, + [companyId, userId, uniqueExternalId('member-insert')], + ), + ).rejects.toThrow(/row-level security/i) + }) + }) + + it('a non-member sees nothing and cannot update foreign rows', async () => { + const { userId: ownerId, companyId } = await seedCompany() + const rowId = await insertOrderRow({ companyId, userId: ownerId }) + const { userId: outsiderId } = await seedCompany() + + await withUserContext(outsiderId, async (client) => { + const read = await client.query( + `SELECT id FROM public.webshop_orders WHERE company_id = $1`, + [companyId], + ) + expect(read.rows).toHaveLength(0) + + const update = await client.query( + `UPDATE public.webshop_orders SET status = 'hacked' WHERE id = $1`, + [rowId], + ) + expect(update.rowCount).toBe(0) + }) + }) + + it('(company_id, external_id) is unique per company but not across companies', async () => { + const { userId: userA, companyId: companyA } = await seedCompany() + const { userId: userB, companyId: companyB } = await seedCompany() + const externalId = uniqueExternalId('dedup') + + await insertOrderRow({ companyId: companyA, userId: userA, externalId }) + await expect( + insertOrderRow({ companyId: companyA, userId: userA, externalId }), + ).rejects.toMatchObject({ code: '23505' }) + + // Another company may carry the same external id (scope is per company). + const other = await insertOrderRow({ companyId: companyB, userId: userB, externalId }) + expect(other).toBeTruthy() + }) +}) + +describe('webshop_orders financial freeze', () => { + it('rejects money-field updates once booked, allows status updates', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + sourceType: 'webshop_order', + }) + const rowId = await insertOrderRow({ companyId, userId, journalEntryId: entryId }) + + await expect( + getPool().query( + `UPDATE public.webshop_orders SET total = 600.00 WHERE id = $1`, + [rowId], + ), + ).rejects.toThrow(/financial fields are frozen/i) + + await expect( + getPool().query( + `UPDATE public.webshop_orders SET paid_date = '2026-08-02' WHERE id = $1`, + [rowId], + ), + ).rejects.toThrow(/financial fields are frozen/i) + + // Non-financial fields keep syncing on frozen rows. + const ok = await getPool().query( + `UPDATE public.webshop_orders + SET status = 'refunded', refunded_total = 500.00, + remote_changed_after_freeze = true + WHERE id = $1`, + [rowId], + ) + expect(ok.rowCount).toBe(1) + }) + + it('leaves unbooked rows fully mutable', async () => { + const { userId, companyId } = await seedCompany() + const rowId = await insertOrderRow({ companyId, userId }) + const ok = await getPool().query( + `UPDATE public.webshop_orders SET total = 750.00, total_sek = 750.00 WHERE id = $1`, + [rowId], + ) + expect(ok.rowCount).toBe(1) + }) + + it('allows unlinking while the entry is still a draft (booking rollback path)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const draftId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + sourceType: 'webshop_order', + }) + const rowId = await insertOrderRow({ companyId, userId, journalEntryId: draftId }) + const ok = await getPool().query( + `UPDATE public.webshop_orders SET journal_entry_id = NULL WHERE id = $1`, + [rowId], + ) + expect(ok.rowCount).toBe(1) + }) + + it('rejects clearing the journal link once the entry is posted', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const postedId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + sourceType: 'webshop_order', + status: 'posted', + voucherNumber: 4711, + }) + const rowId = await insertOrderRow({ companyId, userId, journalEntryId: postedId }) + await expect( + getPool().query( + `UPDATE public.webshop_orders SET journal_entry_id = NULL WHERE id = $1`, + [rowId], + ), + ).rejects.toThrow(/journal link is immutable/i) + }) +}) + +describe('journal_entries source_type webshop_order', () => { + it('accepts webshop_order (CHECK constraint expanded)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + sourceType: 'webshop_order', + }) + const { rows } = await getPool().query( + `SELECT source_type FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(rows).toEqual([{ source_type: 'webshop_order' }]) + }) +}) + +describe('webshop_store_settings', () => { + it('members insert/read/update their mapping; outsiders see nothing', async () => { + const { userId, companyId } = await seedCompany() + const storeScope = `butik-${randomUUID()}.example.se` + + // withUserContext ROLLS BACK, so the duplicate-key assertion below needs + // a persistent seed row inserted via the pool. + await getPool().query( + `INSERT INTO public.webshop_store_settings + (company_id, user_id, platform, store_scope, payment_method_account_map) + VALUES ($1, $2, 'woocommerce', $3, '{"swish":{"mode":"book","account":"1930"}}'::jsonb)`, + [companyId, userId, storeScope], + ) + + await withUserContext(userId, async (client) => { + const memberScope = `butik-member-${randomUUID()}.example.se` + const inserted = await client.query( + `INSERT INTO public.webshop_store_settings + (company_id, user_id, platform, store_scope, payment_method_account_map) + VALUES ($1, $2, 'woocommerce', $3, '{"swish":{"mode":"book","account":"1930"}}'::jsonb) + RETURNING id`, + [companyId, userId, memberScope], + ) + expect(inserted.rows).toHaveLength(1) + + const updated = await client.query( + `UPDATE public.webshop_store_settings + SET payment_method_account_map = '{"swish":{"mode":"book","account":"1580"}}'::jsonb + WHERE company_id = $1 AND store_scope = $2`, + [companyId, storeScope], + ) + expect(updated.rowCount).toBe(1) + }) + + // Duplicate (company, platform, store_scope) rejected: upsert key. + await expect( + getPool().query( + `INSERT INTO public.webshop_store_settings + (company_id, user_id, platform, store_scope) + VALUES ($1, $2, 'woocommerce', $3)`, + [companyId, userId, storeScope], + ), + ).rejects.toMatchObject({ code: '23505' }) + + const { userId: outsiderId } = await seedCompany() + await withUserContext(outsiderId, async (client) => { + const read = await client.query( + `SELECT id FROM public.webshop_store_settings WHERE company_id = $1`, + [companyId], + ) + expect(read.rows).toHaveLength(0) + }) + }) +}) diff --git a/tests/pg/woocommerce-connections.pg.test.ts b/tests/pg/woocommerce-connections.pg.test.ts index 4b052f2f..e711c8ae 100644 --- a/tests/pg/woocommerce-connections.pg.test.ts +++ b/tests/pg/woocommerce-connections.pg.test.ts @@ -12,7 +12,8 @@ const uniqueStore = (label: string) => 'https://' + label + '-' + randomUUID() + * 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). + * 2. Multiple ACTIVE connections per company (multi-store: the + * one-active-per-company index was dropped in 20260811073422). * 3. One store actively connected to at most one company. * 4. No DELETE policy: a member DELETE silently affects zero rows. */ @@ -66,20 +67,19 @@ describe('woocommerce_connections RLS', () => { }) }) - it('only one ACTIVE connection per company is allowed', async () => { + it('a company may hold several ACTIVE connections (multi-store)', 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 + const second = 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('store-two')], + ) + expect(second.rows).toHaveLength(1) }) it('a store may be actively connected to at most one company', async () => { diff --git a/tests/schema/no-phantom-columns.test.ts b/tests/schema/no-phantom-columns.test.ts index 879e2f11..3261af5b 100644 --- a/tests/schema/no-phantom-columns.test.ts +++ b/tests/schema/no-phantom-columns.test.ts @@ -92,8 +92,13 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} * * Baseline 2026-08-06: 370 (158 dynamic-payload, 120 dynamic-select, * 47 dynamic-logical, 38 spread-payload, 5 dynamic-column, 2 computed key). + * + * 2026-08-12 +3: lib/webshop-orders/ingest.ts builds partial UPDATE payloads + * at runtime (frozen rows get safe fields only; unfrozen rows get optional + * parent/legacy links). Writing the shapes as inline literals would need one + * variant per key combination; the row shapes are covered by ingest.test.ts. */ -const UNRESOLVED_CEILING = 375 +const UNRESOLVED_CEILING = 378 /** * Floor on statically resolved column references. Guards the guard: if a change diff --git a/types/index.ts b/types/index.ts index b0045c31..e4b84089 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1690,6 +1690,7 @@ export type JournalEntrySourceType = | 'rot_rut_payout' | 'vat_settlement' | 'stripe_payout' + | 'webshop_order' // Journal entry status export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled' @@ -3958,6 +3959,99 @@ export interface IngestResult { shadow_date_drift_candidates?: number } +// ── Webshop orders (Orders page; synced by the woocommerce/shopify extensions) ── + +export type WebshopPlatform = 'woocommerce' | 'shopify' +export type WebshopOrderRowType = 'order' | 'refund' + +/** One VAT rate bucket of an order, in the order's currency. */ +export interface WebshopVatBreakdownLine { + /** Percent as a number (25, 12, 6, 0). */ + rate: number + net: number + tax: number +} + +/** One order line, in the order's currency. */ +export interface WebshopOrderLineItem { + name: string + quantity: number + total: number + total_tax: number + /** Percent; null when the rate could not be resolved from tax_lines. */ + vat_rate: number | null +} + +/** Row shape of public.webshop_orders. */ +export interface WebshopOrder { + id: string + company_id: string + user_id: string + platform: WebshopPlatform + /** Normalized store host(+path); the identity frozen into external_id. */ + store_scope: string + store_label: string | null + /** Soft pointer to the platform's *_connections row (no FK). */ + connection_id: string | null + row_type: WebshopOrderRowType + parent_order_id: string | null + /** Frozen feed scheme: woo_{scope}_order_{id} / woo_{scope}_refund_{id}. */ + external_id: string + platform_order_id: string + order_number: string + /** Raw platform status (pending/processing/completed/refunded/...). */ + status: string + is_paid: boolean + order_date: string + paid_date: string | null + currency: string + /** Gross incl. tax and shipping; negative on refund rows. */ + total: number + total_tax: number + /** Null until the FX rate resolves; booking is blocked while null. */ + total_sek: number | null + exchange_rate: number | null + vat_breakdown: WebshopVatBreakdownLine[] + line_items: WebshopOrderLineItem[] + customer_name: string | null + customer_company: string | null + customer_email: string | null + /** Best effort; must be user-confirmed before use in legal fields. */ + customer_orgnr: string | null + /** Billing country, ISO 3166-1 alpha-2; drives the export/EU 0%-sale hint. */ + customer_country: string | null + payment_method: string | null + payment_method_title: string | null + gateway_reference: string | null + /** Order rows: informational sum of refunds seen so far. */ + refunded_total: number + journal_entry_id: string | null + invoice_id: string | null + /** Same money event already imported by the legacy transactions feed. */ + legacy_transaction_id: string | null + /** Financial delta arrived from the store after booking froze this row. */ + remote_changed_after_freeze: boolean + created_at: string + updated_at: string +} + +/** Per-payment-method booking policy in webshop_store_settings. */ +export type WebshopPaymentMethodPolicy = + | { mode: 'book'; account: string } + | { mode: 'invoice' } + +/** Row shape of public.webshop_store_settings. */ +export interface WebshopStoreSettings { + id: string + company_id: string + user_id: string + platform: WebshopPlatform + store_scope: string + payment_method_account_map: Record + created_at: string + updated_at: string +} + // ── Invoice extraction (used by invoice-inbox extension and core utils) ── export type ExtractedDocumentKind =