* 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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
b2d3a3e273
commit
82d25b7dee
@@ -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<PaymentVoucherRef[] | null>([])
|
||||
// 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
|
||||
</span>
|
||||
</DefRow>
|
||||
<DefRow label={t('payment_status_payments_heading')} className="items-baseline">
|
||||
{payments.length === 0 ? (
|
||||
<span className="text-muted-foreground">{t('payment_status_empty')}</span>
|
||||
{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).
|
||||
<span className="text-muted-foreground">
|
||||
{!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')}
|
||||
</span>
|
||||
) : paymentGap.kind === 'unreadable' ? (
|
||||
<span className="text-muted-foreground">{t('payment_status_unavailable')}</span>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{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 (
|
||||
<li key={p.id} className="flex items-center gap-4 py-1.5 first:pt-0 last:pb-0">
|
||||
<span className="tabular-nums text-muted-foreground">{formatDate(p.payment_date)}</span>
|
||||
<span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>
|
||||
{p.amount != null && (
|
||||
<span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>
|
||||
)}
|
||||
{p.journal_entry_id && voucherLabel ? (
|
||||
<Link
|
||||
href={`/bookkeeping/${p.journal_entry_id}`}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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[]
|
||||
}
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user