fix(invoices): init remaining_amount on create + attach invoice PDF to payment JE (#406)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-06 23:41:03 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 97db09a3ff
commit 30f5877e57
2 changed files with 54 additions and 0 deletions
+7
View File
@@ -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,
@@ -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')