fix(invoices): migrated paid invoice no longer reads as unpaid under Betalningar (#2213) (#2344)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 18:50:57 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent b2d3a3e273
commit 82d25b7dee
5 changed files with 321 additions and 8 deletions
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { mockLoggerWarn } = vi.hoisted(() => ({ 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()
})
})
+100
View File
@@ -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<PaymentVoucherRef[] | null> {
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[]
}