Files
accounted/supabase/migrations/20260811073315_webshop_orders.sql
Mattsson c35b2547fb 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>
2026-08-12 15:08:13 +02:00

167 lines
8.5 KiB
PL/PgSQL

-- 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';