diff --git a/DECISIONS.md b/DECISIONS.md index e492d744..46c23ebd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -889,3 +889,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-12] Detail pager uses sessionStorage list-context + router.replace, not server neighbor queries: the kundfaktura list order is client-computed and not expressible in PostgREST. [2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out. [2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag. +[2026-08-12] Inbox "booked" state is derived server-side from the matched transaction (GET /items enrichment) in ADDITION to write-side created_journal_entry_id stamps, not stamps alone: created_journal_entry_id is UNIQUE (migration 20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry the stamp; a stamp-only fix could not clear the reported flood of matched items on bulk-booked transactions. The constraint stays (it guards the book-direct double-fire race); the stamp becomes a fast path and the derivation the source of truth. diff --git a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts index 9228a477..479439c0 100644 --- a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts +++ b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts @@ -107,6 +107,7 @@ describe('POST /api/transactions/[id]/attach-document', () => { enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: null }) // inbox-link best-effort update + enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked) const res = await POST( makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), createMockRouteParams({ id: 'tx-1' }), @@ -128,6 +129,7 @@ describe('POST /api/transactions/[id]/attach-document', () => { enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: null }) // inbox-link best-effort update enqueue({ data: null, error: null }) // document_attachments propagation + enqueue({ data: [], error: null }) // completion: matched inbox items (none) const res = await POST( makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), createMockRouteParams({ id: 'tx-1' }), @@ -145,6 +147,7 @@ describe('POST /api/transactions/[id]/attach-document', () => { enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: null }) // inbox-link best-effort update + enqueue({ data: [], error: null }) // completion: matched inbox items (none) const res = await POST( makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), createMockRouteParams({ id: 'tx-1' }), @@ -159,6 +162,7 @@ describe('POST /api/transactions/[id]/attach-document', () => { it('returns 409 when the document already belongs to a different verifikation', async () => { enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch + enqueue({ data: [], error: null }) // voucher-link check: je-OTHER anchors nothing here const res = await POST( makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), createMockRouteParams({ id: 'tx-1' }), @@ -168,6 +172,30 @@ describe('POST /api/transactions/[id]/attach-document', () => { expect(body.error).toContain('annan verifikation') }) + it('completes the matched inbox item when the tx is anchored via a bulk-book samlingsverifikat', async () => { + // A bulk-booked tx keeps transactions.journal_entry_id null: the + // verifikat hangs off transaction_voucher_links. Attaching a receipt to + // it must link the underlag to that verifikat and stamp the matched + // inbox item, or the item strands as "linked" (the 2026-08-12 report). + const DOC = '11111111-1111-4111-8111-111111111111' + enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch + enqueue({ data: { id: DOC, journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING) + enqueue({ data: null, error: null }) // inbox-link best-effort update + enqueue({ data: [{ transaction_id: 'tx-1', journal_entry_id: 'je-9' }], error: null }) // voucher links + enqueue({ data: [{ id: 'inbox-1', document_id: DOC }], error: null }) // matched inbox items + enqueue({ data: { journal_entry_id: null }, error: null }) // doc anchor check: free + enqueue({ data: { id: 'je-9' }, error: null }) // linkToJournalEntry: JE ownership check + enqueue({ data: { id: DOC, journal_entry_id: 'je-9' }, error: null }) // linkToJournalEntry: doc update + enqueue({ data: null, error: null }) // created_journal_entry_id stamp + + const res = await POST(makeReq({ document_id: DOC }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ data: { journal_entry_id: string } }>(res) + expect(status).toBe(200) + // The response reports the samlingsverifikat the attach completed against. + expect(body.data.journal_entry_id).toBe('je-9') + }) + it('returns 409 when the verifikation period is locked during propagation', async () => { enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch @@ -219,6 +247,7 @@ describe('POST /api/transactions/[id]/attach-document', () => { enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: null }) // inbox-link update + enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked) await POST( makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), @@ -234,6 +263,7 @@ describe('POST /api/transactions/[id]/attach-document', () => { enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails + enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const res = await POST( diff --git a/app/api/transactions/[id]/attach-document/route.ts b/app/api/transactions/[id]/attach-document/route.ts index e2ca7f83..d7ac8fd8 100644 --- a/app/api/transactions/[id]/attach-document/route.ts +++ b/app/api/transactions/[id]/attach-document/route.ts @@ -4,6 +4,10 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { AttachDocumentSchema } from '@/lib/api/schemas' import { appendProcessingHistory } from '@/lib/processing-history/append' +import { + completeInboxItemsForBookedTransaction, + resolveVoucherLinkedEntryIds, +} from '@/lib/transactions/inbox-underlag' ensureInitialized() @@ -55,13 +59,20 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( // A document that already serves as underlag for a DIFFERENT verifikation // cannot be pinned here: propagating would either corrupt that link or be // blocked by the document-metadata immutability trigger. Same verifikation - // is fine (idempotent re-attach; propagation below becomes a no-op). + // is fine (idempotent re-attach; propagation below becomes a no-op). A + // bulk-booked tx keeps journal_entry_id null and is anchored through + // transaction_voucher_links, so that anchoring counts as "same" too. const docJournalEntryId = (document.journal_entry_id as string | null) ?? null if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) { - return NextResponse.json( - { error: 'Underlaget är redan kopplat till en annan verifikation.' }, - { status: 409 }, - ) + const voucherLinked = await resolveVoucherLinkedEntryIds(supabase, companyId, [ + transactionId, + ]) + if (docJournalEntryId !== voucherLinked.get(transactionId)) { + return NextResponse.json( + { error: 'Underlaget är redan kopplat till en annan verifikation.' }, + { status: 409 }, + ) + } } // Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we @@ -152,6 +163,21 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( } } + // The transaction may already be booked, directly or via a bulk-book + // samlingsverifikat (journal_entry_id null, anchored through + // transaction_voucher_links): complete the matched inbox items against + // the anchoring verifikat so an after-the-fact attach resolves them + // instead of stranding them as "linked" forever. Best-effort, logged + // inside. Returns the anchoring verifikat (the direct id when there is + // one), so the response and audit trail can report the voucher-linked + // case too. + const effectiveJournalEntryId = await completeInboxItemsForBookedTransaction( + supabase, + companyId, + transactionId, + { directJournalEntryId: journalEntryId }, + ) + // Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc // was replaced. Best-effort: a logging failure must not roll back the // (compliant) attach. @@ -167,7 +193,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( transaction_id: transactionId, previous_document_id: previousDocumentId, new_document_id: document_id, - journal_entry_id: journalEntryId, + journal_entry_id: effectiveJournalEntryId, }, actor: { type: 'user', id: user.id }, occurredAt: new Date(), @@ -182,7 +208,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( transaction_id: transactionId, document_id, previous_document_id: previousDocumentId, - journal_entry_id: journalEntryId, + journal_entry_id: effectiveJournalEntryId, }, }) }, diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index e87bd780..c4d55652 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -7,6 +7,7 @@ import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { BookTransactionSchema } from '@/lib/api/schemas' import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' +import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { getErrorMessage } from '@/lib/errors/get-error-message' import { appendProcessingHistory } from '@/lib/processing-history/append' @@ -181,6 +182,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( ) } + // A hunt- or hand-matched inbox item is consumed by this booking even + // though the dialog never saw it: link its underlag to the verifikat and + // stamp it so it leaves the active inbox (best-effort, logged inside). + await propagateUnderlagForBookedTransaction(supabase, companyId, id, journalEntry.id) + // Emit event (non-blocking) try { await eventBus.emit({ diff --git a/app/api/transactions/bulk-book/__tests__/route.test.ts b/app/api/transactions/bulk-book/__tests__/route.test.ts index 9dc31984..eaa02b2b 100644 --- a/app/api/transactions/bulk-book/__tests__/route.test.ts +++ b/app/api/transactions/bulk-book/__tests__/route.test.ts @@ -33,8 +33,16 @@ vi.mock('@/lib/bookkeeping/template-library', () => ({ applyTemplate: vi.fn(), })) +// Stubbed so the queued supabase mock stays aligned with the route's own +// queries; the helper's behaviour is covered in +// lib/transactions/__tests__/inbox-underlag.test.ts. +vi.mock('@/lib/transactions/inbox-underlag', () => ({ + propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined), +})) + import { POST } from '../route' import { applyTemplate } from '@/lib/bookkeeping/template-library' +import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' const TX1 = '11111111-1111-4111-8111-111111111111' const TX2 = '22222222-2222-4222-8222-222222222222' @@ -114,6 +122,20 @@ describe('POST /api/transactions/bulk-book', () => { expect(body.data.mode).toBe('link_existing') expect(body.data.journal_entry_id).toBe(JE) expect(body.data.linked_tx_count).toBe(2) + // Every booked tx gets its matched inbox items completed against the + // samlingsverifikat (underlag link + consumed stamp). + expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + TX1, + JE, + ) + expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + TX2, + JE, + ) }) it('create-new path fetches template, expands per mode, and calls RPC', async () => { diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts index 951bb6c2..8036dcce 100644 --- a/app/api/transactions/bulk-book/route.ts +++ b/app/api/transactions/bulk-book/route.ts @@ -11,6 +11,7 @@ import { fetchActiveDimensionRules, } from '@/lib/bookkeeping/dimension-rules' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { BookingTemplateLibraryLine, Transaction } from '@/types' @@ -324,6 +325,20 @@ export const POST = withRouteContext( return errorResponseFromCode(code, opLog, { requestId, details }) } + // Complete any matched inbox items against the samlingsverifikat: link + // their underlag and stamp them consumed so they leave the active inbox. + // Best-effort, logged inside; created_journal_entry_id is UNIQUE so at + // most one item can carry the stamp; the inbox list derives "booked" + // from the voucher links for the rest. + for (const txId of body.tx_ids) { + await propagateUnderlagForBookedTransaction( + supabase, + companyId!, + txId, + result.journal_entry_id, + ) + } + // Emit one transaction.reconciled event per tx so existing subscribers // (reminder cancellation, automation, processing-history) keep working. // Best-effort; a failure here does not roll back the booking. diff --git a/components/extensions/general/BulkBookInboxDialog.tsx b/components/extensions/general/BulkBookInboxDialog.tsx index 4d691e47..0d193808 100644 --- a/components/extensions/general/BulkBookInboxDialog.tsx +++ b/components/extensions/general/BulkBookInboxDialog.tsx @@ -27,6 +27,9 @@ interface BulkBookInboxItem { matched_transaction_id: string | null created_journal_entry_id: string | null created_supplier_invoice_id: string | null + // Server-derived: the verifikat anchoring an already-booked matched + // transaction (see InvoiceInboxWorkspace's InboxItem). + matched_transaction_journal_entry_id?: string | null extracted_data: InvoiceExtractionResult | null } @@ -85,7 +88,14 @@ const VAT_OPTIONS: { value: VatTreatment | 'auto'; label: string }[] = [ ] function isBookable(it: BulkBookInboxItem): boolean { - return Boolean(it.matched_transaction_id) && !it.created_journal_entry_id && !it.created_supplier_invoice_id + return ( + Boolean(it.matched_transaction_id) && + !it.created_journal_entry_id && + !it.created_supplier_invoice_id && + // A matched transaction that is already booked has nothing left to book: + // the server would only skip it as already_booked_or_duplicate. + !it.matched_transaction_journal_entry_id + ) } export default function BulkBookInboxDialog({ open, onOpenChange, items, onSuccess }: Props) { @@ -101,7 +111,13 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce [items], ) const alreadyBooked = useMemo( - () => items.filter((it) => it.created_journal_entry_id || it.created_supplier_invoice_id).length, + () => + items.filter( + (it) => + it.created_journal_entry_id || + it.created_supplier_invoice_id || + it.matched_transaction_journal_entry_id, + ).length, [items], ) diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 7498f52a..22411e46 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -95,6 +95,12 @@ interface InboxItem { matched_transaction_id: string | null created_supplier_invoice_id: string | null created_journal_entry_id: string | null + // The verifikat that anchors the matched transaction when it is already + // booked (directly or via a bulk-book samlingsverifikat). Server-derived by + // GET /items: created_journal_entry_id is UNIQUE per verifikat, so on a + // samlingsverifikat only one of N items can carry the stamp; this field is + // what lets the rest read as booked. Absent on client-side placeholders. + matched_transaction_journal_entry_id?: string | null error_message: string | null // True when AI extraction was skipped: either because the upload caller // passed skip_extraction=true (MCP/agent path) or because the server's @@ -214,16 +220,18 @@ function countExtractedFields(data: InvoiceExtractionResult | null): number { // Lifecycle stage of an inbox item. Single source of truth shared by the list // filter, the count pills, and the row icons so they never drift apart. // -// Precedence mirrors the FieldsRail: a booked item (supplier invoice OR a -// direct journal entry) is done and drops out of the active inbox. A -// matched-but-unbooked item is "linked": it STAYS in the inbox as its own -// category because the bank payment still needs booking (a document attached -// to a transaction is not the same as a booked one). An extraction failure is -// "error"; everything else needs a first action. +// Precedence mirrors the FieldsRail: a booked item (supplier invoice, a +// direct journal entry, OR a matched transaction that is itself booked) is +// done and drops out of the active inbox. A matched-but-unbooked item is +// "linked": it STAYS in the inbox as its own category because the bank +// payment still needs booking (a document attached to a transaction is not +// the same as a booked one). An extraction failure is "error"; everything +// else needs a first action. type InboxStatus = 'needs_action' | 'linked' | 'booked' | 'error' function deriveInboxStatus(item: InboxItem): InboxStatus { if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked' + if (item.matched_transaction_journal_entry_id) return 'booked' if (item.matched_transaction_id) return 'linked' if (item.status === 'error') return 'error' return 'needs_action' @@ -976,7 +984,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const bookableSelectedCount = useMemo( () => selectedItems.filter( - (it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id, + (it) => + it.matched_transaction_id && + !it.created_journal_entry_id && + !it.created_supplier_invoice_id && + // A matched transaction that is already booked has nothing left to + // bulk-book: the server would only skip it with a 409. + !it.matched_transaction_journal_entry_id, ).length, [selectedItems], ) @@ -2724,7 +2738,11 @@ function FieldsRail({ }, [item.id]) const isProcessed = !!item.created_supplier_invoice_id - const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id + // The verifikat this item resolved into: its own stamp, or the entry that + // anchors its matched (and already booked) transaction. See InboxItem. + const bookedEntryId = + item.created_journal_entry_id ?? item.matched_transaction_journal_entry_id ?? null + const isBookedDirectly = !isProcessed && !!bookedEntryId // "Resolved" now means a journal entry exists: matched_transaction_id alone // is not resolved, it's the prerequisite for booking against that tx. const isLinkedToTransaction = !isProcessed && !isBookedDirectly && !!item.matched_transaction_id @@ -3059,8 +3077,8 @@ function FieldsRail({ Öppna leverantörsfaktura - ) : isBookedDirectly && item.created_journal_entry_id ? ( - + ) : isBookedDirectly && bookedEntryId ? ( +