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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-12 15:08:13 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 89cfa82f2f
commit c35b2547fb
47 changed files with 6104 additions and 698 deletions
+75
View File
@@ -3343,6 +3343,80 @@ const NETWORK_TRANSIENT_ENTRY: StructuredErrorEntry = {
retryable: true,
}
const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
ECONNREFUSED: NETWORK_TRANSIENT_ENTRY,
ECONNRESET: NETWORK_TRANSIENT_ENTRY,
@@ -3396,6 +3470,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...BOLAGSVERKET,
...ASSETS,
...DIMENSION,
...WEBSHOP_ORDERS,
...NODE_SYSTEM,
}