From 30f5877e57527c3da21a56a0ca58c2e1d5c641dc Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 6 May 2026 23:41:03 +0200 Subject: [PATCH] fix(invoices): init remaining_amount on create + attach invoice PDF to payment JE (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(invoices): initialize remaining_amount on create The invoices table column remaining_amount has DB default 0. The create path never set it, so brand-new fakturor were stored with remaining_amount=0 even though no payment had been received. The InvoicePicker (and any future open-invoice query that filters on remaining_amount > 0) treated these as fully settled and hid them from match candidates — surfaced when a real user reported "Inga öppna fakturor" despite having 5 sent invoices. Set remaining_amount = total on insert for document_type='invoice'. Proformas and delivery notes have no payment obligation, so they keep the 0 default. Backfill of the 46 existing rows across 20 companies (1.32M SEK in orphaned receivables) ran separately as a one-shot UPDATE — restricted to rows with paid_amount IS NULL OR 0 so any legitimately-paid invoice with stale status was untouched (verified: 0 such rows). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(match-invoice): attach invoice PDF as underlag for payment JE The payment verifikation created on transaction match (debit 1930 / credit 1510) had no document attachment. The invoice PDF was archived on send and pinned to the AR-booking JE, but document_attachments .journal_entry_id is one-to-one — the payment JE was left without underlag, a BFL 7 kap audit-trail gap. Cheapest fix: after the payment JE is created, look up the invoice's existing document_attachment row and insert a parallel row that points at the same storage_path with the new journal_entry_id. The original WORM file is untouched (single storage object, two DB pointers); no schema change. Wrapped in non-blocking try/catch so a document lookup failure doesn't abort the payment match. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(match-invoice): destructure document_attachments insert error Supabase JS client returns { data, error } on Postgres-level failures (unique constraint, RLS reject) instead of throwing. The surrounding try/catch only caught thrown JS exceptions, so DB errors on the payment JE document attachment were silently swallowed — the txLog.warn path was unreachable for the most likely failure mode. Destructure { error: attachErr } and log on error with both JE ids so attachment failures are visible and reparable. Greptile P1 on PR #406. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/invoices/route.ts | 7 +++ .../transactions/[id]/match-invoice/route.ts | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 314e8358..0296cb66 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -182,6 +182,13 @@ export const POST = withRouteContext( vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek, total, total_sek: documentType === 'delivery_note' ? null : totalSek, + // Initialize remaining_amount to total for real invoices so the open- + // invoice queries (InvoicePicker, AR ledger, supplier matching) treat + // newly-created invoices as fully unpaid. The DB default is 0 — without + // this, brand-new fakturor look settled and disappear from match + // candidate lists. Proformas and delivery notes have no payment + // obligation, so they keep the 0 default. + remaining_amount: documentType === 'invoice' ? total : 0, vat_treatment: vatRules.treatment, vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)), moms_ruta: vatRules.momsRuta, diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 8425aff6..454c8be7 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -179,6 +179,53 @@ export const POST = withRouteContext( } } + // Underlag for the payment verifikation: re-attach the invoice PDF that + // was archived on send to the new payment journal entry. document_ + // attachments.journal_entry_id is one-to-one, so we insert a parallel + // row pointing at the same storage_path. Same WORM file, second JE + // pointer — no copy, no schema change. Non-blocking (BFL 7 kap audit + // gap, but the bank line + invoice still exist as evidence). + if (journalEntryId && invoice.journal_entry_id) { + try { + const { data: invoiceDoc } = await supabase + .from('document_attachments') + .select('storage_path, file_name, file_size_bytes, mime_type, sha256_hash') + .eq('journal_entry_id', invoice.journal_entry_id) + .eq('company_id', companyId) + .eq('is_current_version', true) + .limit(1) + .maybeSingle() + if (invoiceDoc) { + // Destructure error: Supabase client returns { data, error } on + // postgres-level failures (unique constraint, RLS reject) instead + // of throwing, so the surrounding try/catch only covers thrown + // JS exceptions. Log via warn so attachment failures are visible + // in logs even though we don't abort the match. + const { error: attachErr } = await supabase.from('document_attachments').insert({ + user_id: user.id, + company_id: companyId, + uploaded_by: user.id, + upload_source: 'system', + storage_path: invoiceDoc.storage_path, + file_name: invoiceDoc.file_name, + file_size_bytes: invoiceDoc.file_size_bytes, + mime_type: invoiceDoc.mime_type, + sha256_hash: invoiceDoc.sha256_hash, + journal_entry_id: journalEntryId, + }) + if (attachErr) { + txLog.warn('failed to attach invoice PDF to payment journal entry', { + attachError: attachErr.message, + paymentJournalEntryId: journalEntryId, + invoiceJournalEntryId: invoice.journal_entry_id, + }) + } + } + } catch (err) { + txLog.warn('failed to attach invoice PDF to payment journal entry', err as Error) + } + } + // Optimistic lock: only update if invoice is still in a matchable state. const { data: updatedRows, error: updateInvError } = await supabase .from('invoices')