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) && (