From 9782f80db01db22908e334028fd841ae9643e886 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:29:37 +0200 Subject: [PATCH] feat(invoices): offert to kundorder, the missing step in offert, order, faktura (#2442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(invoices): offert to kundorder, the missing step in offert, order, faktura "Skapa order" on an open or accepted quote creates a draft kundorder from its lines. The quote stays as the customer's accepted agreement (flips to quote_status accepted with a compare-and-set on the decision that was read); the order is delivered and invoiced, in full or in parts, from the kundorder page. Declined quotes are refused. Same action on the MCP side: gnubok_convert_invoice takes target 'order', staged under the existing convert_invoice operation type. Why the problem occurred: the proforma -> order conversion refused every source that was not a proforma, so the offert, which is what users actually send before an order, could only become an invoice. The product had both ends of the Fortnox flow (offert, kundorder) but no bridge. What was removed or simplified: no second service and no new operation type. The proforma conversion became the document conversion (lib/sales-orders/convert-to-sales-order.ts) with the quote source as a branch on the source update, mirroring how convertToInvoice already treats the two. The MCP surface is one tool with a target parameter rather than a sibling tool, which also gives proforma -> order the MCP surface it did not have. Why this shape: the sale must never exist twice. A quote with a live converted invoice cannot become an order (INVOICE_QUOTE_ALREADY_INVOICED), and a quote with a live kundorder cannot become an invoice a second time (new INVOICE_QUOTE_ALREADY_ORDERED: invoice from the order instead). A cancelled order or invoice frees the quote again. Rejected: cancelling the quote like the proforma path (hides the accepted agreement), a separate gnubok_convert_quote_to_order tool, and refusing expired quotes (the invoice path allows them behind a confirm; the order path does the same). Fixes #2224 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RxwavqBoG1HwFD5znkCGLv * fix(sales-orders): hold the one-sale-per-quote guard in the database and fail closed on a missing FX rate Skeptic refutations on the offert -> kundorder change: 1. An already-accepted quote could be converted twice concurrently (two orders, or an order and an invoice): the services' pre-checks are not serialized and the accepted -> accepted compare-and-set matches for every caller. Migration 20260908152555 adds a partial unique index (one live kundorder per source document) and two BEFORE triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa, so concurrent conversions queue and the second one sees the first. The services map the raised codes onto the same 409s the pre-checks use. pg-real test covers the index, both directions, reopen from cancelled, the member-session lock, and the concurrent pair on two connections. 2. createInvoiceFromSalesOrder booked a foreign-currency invoice with a NULL exchange rate when Riksbanken had none, which resolveSekAmount() then posts 1:1 as kronor. Pre-existing, but the quote now depends on the order path and the fail-closed quote -> invoice route is refused while an order lives. The order path now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE, like convertToInvoice. Refs #2224 Co-Authored-By: Claude Fable 5.1 * fix(pending): describe the kundorder outcome when approving a convert_invoice staged with target order The approval dialog's consequence sentence was keyed on operation_type alone and promised a faktura with F-number for every convert_invoice. With target 'order' the commit creates a draft kundorder and books nothing, so the sentence now reads the params (skeptic refutation). Refs #2224 Co-Authored-By: Claude Fable 5.1 * fix(invoices): lock the quote decision behind a live kundorder, run the guards as definer, name the offert on the order page Correctness skeptic refutations on the offert -> kundorder change: 1. A quote with a live kundorder could still be set to open or declined (dashboard route, v1, MCP): the decision guard only knew converted invoices. The dashboard then hid the re-accept button, so the quote was stuck as "Avböjd" behind a confirmed, invoiced order. Migration 20260908155231 extends invoices_quote_decision_guard to refuse leaving accepted while a live kundorder points at the quote (INVOICE_QUOTE_ALREADY_ORDERED); the three writers map the code. 2. The two source guards from 20260908152555 locked the quote row with a SELECT FOR UPDATE as the invoker. Under RLS that also applies the UPDATE policy, which admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. All three guard functions are now SECURITY DEFINER. pg-real test covers the non-active company and the decision lock. 3. The kundorder page labelled every source "Proformafaktura". It now loads the source document and shows "Offert OF-nnn" for a quote; the MCP field description and the type comment say proforma or quote. Refs #2224 Co-Authored-By: Claude Fable 5.1 * fix(mcp): keep tools/list under its token ceiling and refuse cross-company sources in the definer guards CI: the target parameter and two description edits pushed the projected tools/list payload to 60 502 tokens against the 60 500 ceiling; the same facts now fit in fewer words (ceiling unchanged). Superagent P2: the source guards run as definer since 20260908155231, so a source_invoice_id or converted_from_id pointing at another company's document would have locked and inspected that row. Both guards now require the source to belong to the row's company and refuse otherwise (SALES_ORDER_SOURCE_COMPANY_MISMATCH / INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH), covered by a cross-company pg-real case. Migration 20260908155231 was re-applied to staging under the same version (never on prod). Refs #2224 Co-Authored-By: Claude Fable 5.1 * chore(migrations): move the quote conversion guards to versions after main's 20260908164944 Main merged a later version while this branch was open; Supabase applies pending versions in order, so both files are renamed to fresh versions (20260908165000, 20260908165100) and re-tracked on staging under those. Byte-identical SQL. Refs #2224 Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 2 + app/(dashboard)/invoices/[id]/page.tsx | 63 +++- app/(dashboard)/pending/page.tsx | 8 +- app/(dashboard)/sales-orders/[id]/page.tsx | 19 +- .../convert-to-order/__tests__/route.test.ts | 164 +++++++++- .../invoices/[id]/convert-to-order/route.ts | 10 +- .../[id]/convert/__tests__/route.test.ts | 43 +++ .../[id]/quote-status/__tests__/route.test.ts | 13 + app/api/invoices/[id]/quote-status/route.ts | 4 + .../[id]/quote-status/__tests__/route.test.ts | 22 ++ .../invoices/[id]/quote-status/route.ts | 5 + .../__tests__/vocabulary.test.ts | 27 ++ components/pending-operations/vocabulary.ts | 14 +- .../mcp-server/__tests__/quote-tools.test.ts | 137 +++++++- extensions/general/mcp-server/server.ts | 89 ++++-- lib/errors/structured-errors.ts | 24 +- lib/invoices/convert-to-invoice.ts | 27 +- lib/pending-operations/commit.ts | 10 + .../create-invoice-from-order.test.ts | 36 +++ lib/sales-orders/__tests__/result.test.ts | 12 + lib/sales-orders/convert-proforma.ts | 110 ------- lib/sales-orders/convert-to-sales-order.ts | 167 ++++++++++ lib/sales-orders/create-invoice-from-order.ts | 6 + lib/sales-orders/result.ts | 6 + messages/en.json | 5 +- messages/sv.json | 5 +- ...8165000_quote_source_conversion_guards.sql | 117 +++++++ ...rsion_guards_definer_and_decision_lock.sql | 145 +++++++++ .../quote-source-conversion-guards.pg.test.ts | 295 ++++++++++++++++++ types/index.ts | 2 +- 30 files changed, 1419 insertions(+), 168 deletions(-) create mode 100644 components/pending-operations/__tests__/vocabulary.test.ts delete mode 100644 lib/sales-orders/convert-proforma.ts create mode 100644 lib/sales-orders/convert-to-sales-order.ts create mode 100644 supabase/migrations/20260908165000_quote_source_conversion_guards.sql create mode 100644 supabase/migrations/20260908165100_quote_conversion_guards_definer_and_decision_lock.sql create mode 100644 tests/pg/quote-source-conversion-guards.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 2e00d8d6..cb46f2ff 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1672,5 +1672,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched. [2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place. [2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call. +[2026-09-08] Issue #2224 offert -> kundorder: the quote source was added to the existing proforma -> order service (lib/sales-orders/convert-to-sales-order.ts) and to gnubok_convert_invoice as a target parameter staged under the existing convert_invoice operation type, instead of a second service, a separate MCP tool and a new pending-operation type. Why: one conversion path and one guard pair (a quote with a live order cannot become an invoice, a quote with a live invoice cannot become an order) keep the proforma and quote flows identical, and reusing the operation type keeps the pending-operations type list untouched. The quote flips to accepted like quote -> invoice does; cancelling it like the proforma path would hide the customer's accepted agreement. Skeptic refutation: for an already-accepted quote that accepted -> accepted compare-and-set cannot detect a concurrent conversion, so the "never twice" guard moved into the database (migration 20260908165000: one live kundorder per source, and two triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa); the service pre-checks stay for the friendly error. Second refutation: order -> invoice booked a foreign-currency invoice 1:1 when Riksbanken had no rate (pre-existing, but the quote now depends on that path and the fail-closed quote -> invoice route is refused while an order lives), so createInvoiceFromSalesOrder now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE. Locking the quote decision (open/declined) while a live order exists was left out: the DB trigger only knows about converted invoices and changing it is a migration; a declined quote with a live order is harmless since the order carries the sale. +[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version. [2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are. [2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 22adc2d5..2f56c9c8 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -235,6 +235,10 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st // Offert: the invoice created from this quote (converted_from_id points // back here), and the accept/decline round trip. const [quoteInvoice, setQuoteInvoice] = useState(null) + // Offert -> kundorder: the live order created from this quote, if any. Its + // presence locks the decision and moves invoicing to the order. + const [quoteOrder, setQuoteOrder] = useState<{ id: string; order_number: string | null } | null>(null) + const [showExpiredOrderDialog, setShowExpiredOrderDialog] = useState(false) const [isDeciding, setIsDeciding] = useState(false) const [showExpiredAcceptDialog, setShowExpiredAcceptDialog] = useState(false) const [showExpiredConvertDialog, setShowExpiredConvertDialog] = useState(false) @@ -582,7 +586,16 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st .order('created_at', { ascending: false }) .limit(1) : Promise.resolve(null), - ]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes, invoicedRes]) => { + data.document_type === 'quote' + ? supabase + .from('sales_orders') + .select('id, order_number') + .eq('source_invoice_id', id) + .neq('status', 'cancelled') + .order('created_at', { ascending: false }) + .limit(1) + : Promise.resolve(null), + ]).then(([personnummerMasked, creditNoteRes, originalRes, convertedRes, invoicedRes, orderedRes]) => { // Deferred writes need the same guard: they land after first paint // and would otherwise attach the previous invoice's related documents // to the one the pager has since navigated to. @@ -596,6 +609,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st setConvertedFromInvoice(convertedRes.data as Invoice) } setQuoteInvoice((invoicedRes?.data?.[0] as Invoice | undefined) ?? null) + setQuoteOrder( + (orderedRes?.data?.[0] as { id: string; order_number: string | null } | undefined) ?? null, + ) }) } @@ -753,8 +769,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st setIsConverting(false) } - // Proforma -> draft kundorder (sibling of convertToInvoice). The proforma is - // cancelled by the service; the user lands on the new order. + /** "Skapa order" on an expired open quote confirms the lapse first, like + * startQuoteConvert; an accepted quote past valid_until converts directly. */ + function startQuoteOrder() { + if (!invoice) return + if (isQuoteExpired(invoice)) { + setShowExpiredOrderDialog(true) + return + } + void convertToOrder() + } + + // Proforma or offert -> draft kundorder (sibling of convertToInvoice). The + // service cancels the proforma or marks the quote accepted; the user lands + // on the new order. async function convertToOrder() { if (!invoice) return setIsCreatingOrder(true) @@ -1467,7 +1495,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st // Offert: the effective status (expired is derived, never stored) and what // can still happen to it. Once an invoice exists the decision is final. const quoteStatus = isQuote ? effectiveQuoteStatus(invoice) : null - const canDecideQuote = isQuote && invoice.status !== 'cancelled' && !quoteInvoice + const canDecideQuote = isQuote && invoice.status !== 'cancelled' && !quoteInvoice && !quoteOrder const canConvertQuote = canDecideQuote && quoteStatus !== 'declined' // #1693: only a fully paid faktura has a betalningsbekräftelse to offer. const canSendPaymentConfirmation = isPaymentConfirmationEligible(invoice) @@ -1830,11 +1858,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {t('quote_create_invoice')} )} - {isProforma && invoice.status !== 'cancelled' && ( + {((isProforma && invoice.status !== 'cancelled') || canConvertQuote) && (