feat(woo): mark an order as already booked outside the integration (#1895)

* feat(woo): mark an order as already booked outside the integration

Orders booked by hand before the store was connected sat under Att
bokfora forever: the only exits were the book and create-invoice routes.

- Migration: manually_booked_at/_by + optional
  manually_booked_journal_entry_id on webshop_orders (informational link,
  no financial freeze; the mark produced no accounting objects).
- POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional
  posted-verifikat reference (validated per company), conditional claim
  against concurrent booking/invoicing; unmark is a plain revert.
- book and create-invoice routes refuse marked rows (409
  WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims.
- List route: booked/unbooked filters treat a manual mark as a closed
  exit, so marked rows leave the Att bokfora tab and join Bokforda.
- Orders page: row overflow menu with Markera som bokford / Angra
  markering, MarkOrderBookedDialog with a searchable candidate list of
  posted entries near the order date, muted status text linking to the
  referenced verifikat.

Fixes #1879

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): close skeptic findings on the manual-booked mark

- mark-booked applies the same open-twin gate as book/create-invoice:
  an OPEN legacy feed transaction blocks the mark (409
  WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows
  unlock it, so no open path to a duplicate remains.
- ingest treats manually marked rows as frozen for drift purposes:
  remote financial deltas set remote_changed_after_freeze (same badge as
  booked rows) instead of silently refreshing the row under the user's
  assertion.
- re-marking with a journal_entry_id updates the informational link
  instead of silently dropping it.
- dialog: candidate amount computed from the returned lines (the list
  API does not return total_amount), newest-first ordering, cap hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(woo): resolve PR review findings in one pass

- freeze v3 migration: financial fields are frozen at the DB level while
  a row is manually marked as booked (review finding: the mark's freeze
  lived only in ingest.ts, so any other write path could silently mutate
  a marked row); unmark stays the escape hatch. pg test added.
- pass the active locale to getErrorMessage in the orders page and
  MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors).

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-25 14:23:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent cd46d936f3
commit 1f9578ca76
20 changed files with 1231 additions and 22 deletions
@@ -0,0 +1,34 @@
-- Manual booking mark for webshop orders (issue #1879).
--
-- Orders booked by hand BEFORE the integration was connected sit in the
-- "Att bokfora" list forever: the only exits are the book and create-invoice
-- routes. These columns add a third, non-accounting exit: the user marks the
-- row as already handled outside the integration, optionally pointing at the
-- existing verifikat.
--
-- Deliberately separate from journal_entry_id: that column is the claim the
-- booking route takes atomically and the freeze trigger treats it as "this
-- row PRODUCED that entry" (financial fields freeze, link becomes immutable
-- once posted). A manual mark produced nothing; it is a user assertion with
-- an optional soft reference, so it stays reversible (unmark) and does not
-- freeze the row. The book/create-invoice routes refuse marked rows
-- application-side, mirroring the legacy_transaction_id double-booking lock.
--
-- No RLS change: the existing member UPDATE policy already covers the mark/
-- unmark writes. No audit trigger (consistent with the table: accounting-
-- relevant events are audited on journal_entries/invoices; the mark keeps
-- who/when on the row itself via manually_booked_by/_at).
alter table public.webshop_orders
add column manually_booked_at timestamptz,
add column manually_booked_by uuid references auth.users(id) on delete set null,
add column manually_booked_journal_entry_id uuid references public.journal_entries(id) on delete set null;
comment on column public.webshop_orders.manually_booked_at is
'When the user marked this row as already booked/handled outside the integration; null = not marked. Marked rows leave the to-book list and the book/create-invoice routes refuse them.';
comment on column public.webshop_orders.manually_booked_by is
'User who marked the row as manually booked.';
comment on column public.webshop_orders.manually_booked_journal_entry_id is
'Optional user-chosen reference to the existing verifikat that covers this order. Informational link only: the entry was created outside the order flow, so this never freezes the row.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,72 @@
-- Freeze v3: financial fields are also frozen while a row is MANUALLY marked
-- as booked outside the integration (issue #1879, review finding on PR #1895).
--
-- v2 (20260812124858) froze financials once journal_entry_id/invoice_id was
-- set. The manual mark (manually_booked_at) got the same protection only in
-- application code (lib/webshop-orders/ingest.ts isFrozen): any other write
-- path (browser-client PATCH through the member UPDATE policy, a future
-- endpoint, an ad-hoc script) could still silently mutate total/line_items
-- under the user's "this row is covered by verifikat X" assertion. Now the
-- trigger holds the same line: while marked, the financial fields are
-- immutable at the DB level; sync's safe-field updates (status, refund
-- summary, labels, remote_changed_after_freeze) still pass, and clearing the
-- mark itself stays allowed (the manual columns are not in the protected
-- list), which is exactly the unmark route's escape hatch: unmark first,
-- then the row is fully mutable again.
--
-- The link-column protections from v2 are unchanged. CREATE OR REPLACE keeps
-- the trigger binding 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
or old.manually_booked_at 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/marked as booked; financial fields are frozen (unmark or use storno)', old.id
using errcode = 'P0001';
end if;
end if;
return new;
end;
$$;