From 82d25b7deeb61a6a35445bff18bbaf49a6cdf19d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 18:50:57 +0200 Subject: [PATCH] fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar (#2213) (#2344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar The Betalningar row on the customer invoice page rendered "Inga registrerade betalningar ännu" whenever invoice_payments had no row, even under a header saying Betald / Betalning mottagen / Återstår 0 kr. A migrated invoice that was paid in the previous system is exactly that state: the provider migration writes status, paid_amount and paid_at from the source and nothing else, while invoice_payments is written only by Accounted's own settlement paths (all fail-closed). "Settled with zero rows" therefore never means "no payment yet"; it means the payment was recorded where this ledger never saw it. lib/invoices/payment-history-gap.ts classifies that state from the data (no provenance column): settled + zero rows + no posted invoice_paid / invoice_cash_payment voucher keyed on the invoice renders one line, "Betald {date}, före migreringen till Accounted" (partial and undated variants); settled + zero rows + such vouchers (#2019 leftovers) lists the vouchers in place of the rows, linked to the verifikat; a failed lookup says the history could not be loaded instead of asserting either. payment_status_empty is removed from both locales: no reachable state renders it any more. Closes #2213 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(invoices): log a failed payment voucher lookup instead of returning null silently fetchInvoicePaymentVouchers returned null on a DB error with no trace, so a failure behind "Betalningshistoriken kunde inte hämtas" was invisible. Warn with the invoice id and the error code/message (no invoice content), and assert it in the test. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- app/(dashboard)/invoices/[id]/page.tsx | 71 ++++++++- .../__tests__/payment-history-gap.test.ts | 148 ++++++++++++++++++ lib/invoices/payment-history-gap.ts | 100 ++++++++++++ messages/en.json | 5 +- messages/sv.json | 5 +- 5 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 lib/invoices/__tests__/payment-history-gap.test.ts create mode 100644 lib/invoices/payment-history-gap.ts diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 7f41f14b..ded7e83d 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -28,6 +28,11 @@ import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note' import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode' import { canCopyInvoice } from '@/lib/invoices/copy-invoice' +import { + classifyPaymentHistoryGap, + fetchInvoicePaymentVouchers, + type PaymentVoucherRef, +} from '@/lib/invoices/payment-history-gap' import { effectiveQuoteStatus, isQuoteExpired } from '@/lib/invoices/quote-status' import type { QuoteStatus } from '@/types' import { @@ -184,13 +189,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st Array<{ id: string payment_date: string - amount: number + // null for a voucher standing in for a missing sub-ledger row (#2019 + // leftovers): the voucher's settlement amount needs its lines. + amount: number | null currency: string journal_entry_id: string | null voucher_series: string | null voucher_number: number | null }> >([]) + // Posted payment vouchers keyed on this invoice, for the case where the + // header reads paid but no payment row exists (#2213): null when either + // lookup failed, so the row never asserts a provenance it cannot know. + const [paymentVouchers, setPaymentVouchers] = useState([]) // ROT/RUT begäran rows this invoice is part of (fakturamodellen). Empty // for invoices without a deduction and for claimed invoices whose begäran // has not been generated yet; the Skattereduktion card reads it. @@ -368,7 +379,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st // Invoice, reminders, payments, and deliveries all key on the route id: one // parallel batch. Only the follow-ups below need the invoice row. - const [{ data, error }, { data: reminderData }, { data: paymentData }, deliveryData, { data: payoutData }] = + const [ + { data, error }, + { data: reminderData }, + { data: paymentData, error: paymentError }, + deliveryData, + { data: payoutData }, + paymentVoucherData, + ] = await Promise.all([ supabase .from('invoices') @@ -404,6 +422,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st 'id, requested_amount, decided_amount, request:rot_rut_payout_requests(status, name, created_at, submitted_at, decided_at)', ) .eq('invoice_id', id), + // Payment vouchers keyed on the invoice: what the Betalningar row + // falls back to when the header reads paid but no row exists. + fetchInvoicePaymentVouchers(supabase, id), ]) // A newer fetch owns the page now (pager step or later refresh): commit @@ -454,6 +475,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st })), ) } + // A failed rows query leaves `payments` at its previous value, so the + // count alone cannot say "no rows"; null here makes the gap unreadable. + setPaymentVouchers(paymentError ? null : paymentVoucherData) type PayoutRow = { id: string @@ -1395,6 +1419,27 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const canConvertQuote = canDecideQuote && quoteStatus !== 'declined' // #1693: only a fully paid faktura has a betalningsbekräftelse to offer. const canSendPaymentConfirmation = isPaymentConfirmationEligible(invoice) + // Paid header but no payment row (#2213): the row must state where the + // payment lives instead of implying none happened. When vouchers settled + // the invoice here without a row, they stand in for the rows. + const paymentGap = classifyPaymentHistoryGap({ + status: invoice.status, + paid_at: invoice.paid_at, + paymentRows: payments.length, + paymentVouchers, + }) + const paymentHistory = + paymentGap.kind === 'vouchers_without_rows' + ? paymentGap.vouchers.map((v) => ({ + id: v.id, + payment_date: v.entry_date, + amount: null, + currency: invoice.currency, + journal_entry_id: v.id, + voucher_series: v.voucher_series, + voucher_number: v.voucher_number, + })) + : payments const isCreditNote = !!invoice.credited_invoice_id const booksOnIssue = isCreditNote ? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice) @@ -2347,11 +2392,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st - {payments.length === 0 ? ( - {t('payment_status_empty')} + {paymentGap.kind === 'settled_before_accounted' ? ( + // Migrated in as paid: the payment is in the previous system's + // books, so there is nothing to register here. One dated line; + // "inga registrerade ännu" read as still unpaid under a paid + // header (#2213). + + {!paymentGap.full + ? t('payment_status_partial_before_migration') + : paymentGap.paid_at + ? t('payment_status_before_migration', { date: formatDate(paymentGap.paid_at) }) + : t('payment_status_before_migration_undated')} + + ) : paymentGap.kind === 'unreadable' ? ( + {t('payment_status_unavailable')} ) : (
    - {payments.map((p) => { + {paymentHistory.map((p) => { const voucherLabel = p.voucher_series && p.voucher_number != null ? `${p.voucher_series}-${p.voucher_number}` @@ -2359,7 +2416,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st return (
  • {formatDate(p.payment_date)} - {formatCurrency(p.amount, p.currency)} + {p.amount != null && ( + {formatCurrency(p.amount, p.currency)} + )} {p.journal_entry_id && voucherLabel ? ( ({ mockLoggerWarn: vi.fn() })) +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: mockLoggerWarn, + error: vi.fn(), + child: vi.fn().mockReturnThis(), + }), +})) + +import { + classifyPaymentHistoryGap, + fetchInvoicePaymentVouchers, + type PaymentVoucherRef, +} from '../payment-history-gap' +import { PAYMENT_VOUCHER_SOURCE_TYPES } from '../backfill-invoice-payment-rows' + +const voucher: PaymentVoucherRef = { + id: 'je-1', + entry_date: '2025-03-12', + voucher_series: 'A', + voucher_number: 217, +} + +describe('classifyPaymentHistoryGap', () => { + it('is none for an unsettled invoice, whatever the rows say', () => { + for (const status of ['draft', 'sent', 'overdue', 'cancelled', 'credited']) { + expect( + classifyPaymentHistoryGap({ status, paymentRows: 0, paymentVouchers: [] }), + ).toEqual({ kind: 'none' }) + expect( + classifyPaymentHistoryGap({ status, paymentRows: null, paymentVouchers: null }), + ).toEqual({ kind: 'none' }) + } + }) + + it('is none when payment rows exist: the list is the truth', () => { + expect( + classifyPaymentHistoryGap({ + status: 'paid', + paid_at: '2025-03-12T12:00:00Z', + paymentRows: 1, + paymentVouchers: [voucher], + }), + ).toEqual({ kind: 'none' }) + // Rows exist, so the voucher lookup's outcome is irrelevant. + expect( + classifyPaymentHistoryGap({ status: 'partially_paid', paymentRows: 2, paymentVouchers: null }), + ).toEqual({ kind: 'none' }) + }) + + it('reports a migrated invoice paid before Accounted had it (#2213)', () => { + expect( + classifyPaymentHistoryGap({ + status: 'paid', + paid_at: '2025-03-12T12:00:00Z', + paymentRows: 0, + paymentVouchers: [], + }), + ).toEqual({ kind: 'settled_before_accounted', paid_at: '2025-03-12T12:00:00Z', full: true }) + }) + + it('keeps the partial flag for a migrated partly paid invoice and tolerates a missing date', () => { + expect( + classifyPaymentHistoryGap({ status: 'partially_paid', paymentRows: 0, paymentVouchers: [] }), + ).toEqual({ kind: 'settled_before_accounted', paid_at: null, full: false }) + expect( + classifyPaymentHistoryGap({ status: 'paid', paid_at: null, paymentRows: 0, paymentVouchers: [] }), + ).toEqual({ kind: 'settled_before_accounted', paid_at: null, full: true }) + }) + + it('shows the vouchers when the invoice was settled here but the row is missing (#2019 leftovers)', () => { + const second = { ...voucher, id: 'je-2', entry_date: '2025-04-01', voucher_number: 260 } + expect( + classifyPaymentHistoryGap({ + status: 'paid', + paid_at: '2025-04-01T12:00:00Z', + paymentRows: 0, + paymentVouchers: [voucher, second], + }), + ).toEqual({ kind: 'vouchers_without_rows', vouchers: [voucher, second] }) + }) + + it('never asserts a provenance when a lookup failed', () => { + expect( + classifyPaymentHistoryGap({ status: 'paid', paymentRows: null, paymentVouchers: [] }), + ).toEqual({ kind: 'unreadable' }) + expect( + classifyPaymentHistoryGap({ status: 'paid', paymentRows: 0, paymentVouchers: null }), + ).toEqual({ kind: 'unreadable' }) + }) +}) + +describe('fetchInvoicePaymentVouchers', () => { + const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase() + const client = supabase as unknown as SupabaseClient + + beforeEach(() => { + reset() + vi.clearAllMocks() + }) + + it('looks up posted payment vouchers keyed on the invoice, oldest first', async () => { + enqueue({ data: [voucher], error: null }) + + await expect(fetchInvoicePaymentVouchers(client, 'inv-1')).resolves.toEqual([voucher]) + + expect(findCall('journal_entries', 'select')).toEqual([ + 'id, entry_date, voucher_series, voucher_number', + ]) + expect(findCalls('journal_entries', 'eq')).toEqual([ + ['source_id', 'inv-1'], + ['status', 'posted'], + ]) + expect(findCall('journal_entries', 'in')).toEqual([ + 'source_type', + [...PAYMENT_VOUCHER_SOURCE_TYPES], + ]) + expect(findCall('journal_entries', 'order')).toEqual(['entry_date', { ascending: true }]) + }) + + it('returns an empty list when nothing is keyed on the invoice', async () => { + enqueue({ data: [], error: null }) + await expect(fetchInvoicePaymentVouchers(client, 'inv-1')).resolves.toEqual([]) + }) + + it('returns null, not an empty list, when the lookup fails, and logs it', async () => { + enqueue({ data: null, error: { code: '57014', message: 'canceling statement due to statement timeout' } }) + await expect(fetchInvoicePaymentVouchers(client, 'inv-1')).resolves.toBeNull() + // The failure must not be invisible: id and code, no invoice content. + expect(mockLoggerWarn).toHaveBeenCalledTimes(1) + expect(mockLoggerWarn).toHaveBeenCalledWith('payment voucher lookup failed', { + invoiceId: 'inv-1', + code: '57014', + message: 'canceling statement due to statement timeout', + }) + }) + + it('does not log when the lookup merely finds nothing', async () => { + enqueue({ data: [], error: null }) + await fetchInvoicePaymentVouchers(client, 'inv-1') + expect(mockLoggerWarn).not.toHaveBeenCalled() + }) +}) diff --git a/lib/invoices/payment-history-gap.ts b/lib/invoices/payment-history-gap.ts new file mode 100644 index 00000000..ecdad989 --- /dev/null +++ b/lib/invoices/payment-history-gap.ts @@ -0,0 +1,100 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { PAYMENT_VOUCHER_SOURCE_TYPES } from '@/lib/invoices/backfill-invoice-payment-rows' + +const log = createLogger('invoices/payment-history-gap') + +/** + * What the Betalningar row on a customer invoice should say when the invoice + * header reads paid but the AR sub-ledger (`invoice_payments`) has no row. + * + * The header state (`status`, `paid_amount`, `paid_at`) and the payment rows + * have different writers. Every settlement path inside Accounted writes the + * row fail-closed (lib/invoices/invoice-payment-row.ts), so a settled invoice + * with no row was settled somewhere this ledger never saw: + * + * - a provider migration imports the invoice already paid (extensions/ + * general/arcim-migration mapSalesInvoice writes status, paid_amount and + * paid_at from the source system and nothing else). No payment voucher + * exists here either; the payment lives in the previous system's books. + * - a "Markera som betald" from before the sub-ledger existed (#2019) left + * a posted invoice_paid / invoice_cash_payment voucher with no row. The + * backfill repaired the single-voucher cases; what remains still has the + * voucher to show. + * + * Rendering "Inga registrerade betalningar ännu" under a paid header reads as + * "still unpaid" and contradicts the header (#2213). The classifier below + * turns the data state into the one honest line the row can carry instead. + * No provenance column is needed: the state itself is the marker. + */ + +export interface PaymentVoucherRef { + id: string + entry_date: string + voucher_series: string | null + voucher_number: number | null +} + +export type PaymentHistoryGap = + /** Rows exist (the list is the truth) or the invoice is not settled. */ + | { kind: 'none' } + /** A lookup failed: say so rather than assert either provenance. */ + | { kind: 'unreadable' } + /** Settled in Accounted, row missing: show the vouchers that settled it. */ + | { kind: 'vouchers_without_rows'; vouchers: PaymentVoucherRef[] } + /** Settled before the invoice existed here: paid in the previous system. */ + | { kind: 'settled_before_accounted'; paid_at: string | null; full: boolean } + +const SETTLED_STATUSES = new Set(['paid', 'partially_paid']) + +export function classifyPaymentHistoryGap(input: { + status: string + paid_at?: string | null + /** invoice_payments rows for the invoice; null when the lookup failed. */ + paymentRows: number | null + /** Posted payment vouchers keyed on the invoice; null when the lookup failed. */ + paymentVouchers: PaymentVoucherRef[] | null +}): PaymentHistoryGap { + if (!SETTLED_STATUSES.has(input.status)) return { kind: 'none' } + if (input.paymentRows === null) return { kind: 'unreadable' } + if (input.paymentRows > 0) return { kind: 'none' } + if (input.paymentVouchers === null) return { kind: 'unreadable' } + if (input.paymentVouchers.length > 0) { + return { kind: 'vouchers_without_rows', vouchers: input.paymentVouchers } + } + return { + kind: 'settled_before_accounted', + paid_at: input.paid_at ?? null, + full: input.status === 'paid', + } +} + +/** + * Posted payment vouchers the invoice engine keyed on this invoice + * (`source_id` = invoice id, `source_type` invoice_paid / invoice_cash_payment; + * the (source_type, source_id) index makes this a point lookup). Returns null + * when the query failed: unknown must never read as "no voucher". + */ +export async function fetchInvoicePaymentVouchers( + supabase: SupabaseClient, + invoiceId: string, +): Promise { + const { data, error } = await supabase + .from('journal_entries') + .select('id, entry_date, voucher_series, voucher_number') + .eq('source_id', invoiceId) + .in('source_type', [...PAYMENT_VOUCHER_SOURCE_TYPES]) + .eq('status', 'posted') + .order('entry_date', { ascending: true }) + if (error) { + // The row then reads "kunde inte hämtas" instead of a provenance; the + // failure itself must not stay invisible (ids and codes only, no PII). + log.warn('payment voucher lookup failed', { + invoiceId, + code: error.code, + message: error.message, + }) + return null + } + return (data ?? []) as PaymentVoucherRef[] +} diff --git a/messages/en.json b/messages/en.json index 6065fd8d..91ba67b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4168,7 +4168,10 @@ "payment_status_payments_heading": "Payments", "payment_status_view_voucher": "Voucher {label}", "payment_status_view_voucher_unlinked": "No voucher linked", - "payment_status_empty": "No payments recorded yet.", + "payment_status_before_migration": "Paid {date}, before the migration to Accounted.", + "payment_status_before_migration_undated": "Paid before the migration to Accounted.", + "payment_status_partial_before_migration": "Partly paid before the migration to Accounted.", + "payment_status_unavailable": "The payment history could not be loaded.", "payment_confirmation_hint": "A new document with a PAID stamp. The sent invoice is left untouched.", "payment_confirmation_download": "Download paid copy", "payment_confirmation_send": "Send payment confirmation", diff --git a/messages/sv.json b/messages/sv.json index fa543b22..21ebe4da 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4168,7 +4168,10 @@ "payment_status_payments_heading": "Betalningar", "payment_status_view_voucher": "Verifikat {label}", "payment_status_view_voucher_unlinked": "Saknar verifikat", - "payment_status_empty": "Inga registrerade betalningar ännu.", + "payment_status_before_migration": "Betald {date}, före migreringen till Accounted.", + "payment_status_before_migration_undated": "Betald före migreringen till Accounted.", + "payment_status_partial_before_migration": "Delbetald före migreringen till Accounted.", + "payment_status_unavailable": "Betalningshistoriken kunde inte hämtas.", "payment_confirmation_hint": "Nytt dokument med BETALD-stämpel. Den skickade fakturan rörs inte.", "payment_confirmation_download": "Ladda ner betald kopia", "payment_confirmation_send": "Skicka betalningsbekräftelse",