* 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>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
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()
|
|
})
|
|
})
|