Files
accounted/lib/invoices/__tests__/invoice-payment-row.test.ts
T
743e3ae7cc fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments (#2277)
* fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments

The dashboard match-invoice route, its v1 twin and the pending-operation
match_transaction_invoice executor wrote invoice_payments.amount as the
cash received in invoice currency. When a whole-krona bank line settles an
öre-carrying remaining (the customer pays the rounded "Att betala"),
planInvoicePayment advances paid_amount by the remaining only and books
the öre on 3740, so the row exceeded the receivable by the absorbed öre:
remaining 999.60, bank 1 000.00 gave a 1 000.00 row against a 999.60
paid_amount. The kontantmetod cut-off then pushed a -0.40 receivable with
negative scaled moms, the historical AR ledger showed -0.40 outstanding
on a paid invoice, and a storno of the payment voucher restored
paid_amount 0.40 off (issue #2250).

PR #2236 defined the amount for the manual, MCP and Stripe paths as the
amount APPLIED to the invoice (new paid_amount minus the prior one). The
three bank-match paths now share that definition through one helper,
appliedPaymentAmount() in lib/invoices/invoice-payment-row.ts, which
recordInvoicePaymentRow() uses as well. Every other field of the row
(payment date, currency, exchange rate, journal entry, bank transaction,
notes) is unchanged. Without a residual the applied amount equals the
cash received, so ordinary matches post identical rows; cross-currency
rows are now öre-rounded like paid_amount instead of the 4-decimal spot
conversion, so row and paid_amount agree.

Existing rows carrying the overshoot are not repaired here; that is a
separate call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

* refactor(invoices): one writer for invoice_payments rows

Rework of the #2250 fix from first principles. The bank-match paths did
not just get the amount wrong; the class of bug is that invoice_payments
rows were hand-built at five product sites (dashboard bank match, its v1
twin, the pending-operation match, the link-to-existing-voucher flow, and
the #2236 paths through the helper), each computing its own fields with
no single definition of what the row means.

recordInvoicePaymentRow() (lib/invoices/invoice-payment-row.ts) is now the
one writer. Its options grew by what the bank paths set, all optional with
today's defaults so the #2236 callers are unchanged: transactionId
(default null), exchangeRate (the rate actually used; omitted =
invoice.exchange_rate, explicit null stored as null) and notes (default
null). The failure result carries the Postgres SQLSTATE so the routes keep
mapping a unique violation (23505) exactly as before. The applied-amount
formula is an internal detail of that file again.

Routed through the writer: app/api/transactions/[id]/match-invoice, the
v1 match-invoice twin, commitMatchTransactionInvoice in
lib/pending-operations/commit.ts, and lib/transactions/link-journal-entry.ts
(strict plan, same currency only: its amount is unchanged, it now shares
the row semantics). The pending-operation path used to drop the insert
error on the floor; it stays non-fatal but is logged with ids.

Guard: scripts/checks/no-new-antipatterns.mjs gains
direct-invoice-payment-insert, a file-set rule with no baseline (0 today):
.from('invoice_payments').insert( or .upsert( anywhere under app/, lib/ or
extensions/ outside lib/invoices/invoice-payment-row.ts fails
npm run check:guards. Operator scripts under scripts/ are out of its scope
on purpose.

Tests: the writer's unit tests cover the new options, the explicit-null
rate, the SQLSTATE passthrough and the öre-rounded prior-paid
subtraction; the per-path 3740 tests from the first commit stand; mock
insert slots now return the row id the writer selects back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:13:29 +02:00

210 lines
7.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
const { logError } = vi.hoisted(() => ({ logError: vi.fn() }))
vi.mock('@/lib/logger', () => ({
createLogger: () => ({ error: logError, warn: vi.fn(), info: vi.fn(), debug: vi.fn() }),
}))
import { recordInvoicePaymentRow, removeInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
describe('recordInvoicePaymentRow', () => {
beforeEach(() => vi.clearAllMocks())
it('stores the applied amount, not the cash received', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' } })
// Öresavrundning: 1 235 kr received against a 1 234.75 remaining; the
// 0.25 sits on 3740 and is not part of the receivable.
const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, {
userId: 'user-1',
companyId: 'company-1',
invoice: { id: 'inv-1', currency: 'SEK', exchange_rate: null, paid_amount: 500 },
paymentDate: '2026-08-28',
newPaidAmount: 1734.75,
journalEntryId: 'je-1',
})
expect(result).toEqual({ ok: true, id: 'ip-1' })
expect(findCalls('invoice_payments', 'insert')[0][0]).toEqual({
user_id: 'user-1',
company_id: 'company-1',
invoice_id: 'inv-1',
payment_date: '2026-08-28',
amount: 1234.75,
currency: 'SEK',
exchange_rate: null,
journal_entry_id: 'je-1',
transaction_id: null,
notes: null,
})
})
it('subtracts the prior paid_amount on a final partial, rounded to the öre (#2250)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-2' } })
// Remaining 999.60 after a 1 000 partial, settled by a 1 000.00 bank line:
// planInvoicePayment advances paid_amount to 1 999.60 and 3740 carries the
// 0.40. 1999.6 - 1000 is not exact in IEEE 754; roundOre makes it 999.60.
await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, {
userId: 'user-1',
companyId: 'company-1',
invoice: { id: 'inv-1', currency: 'SEK', exchange_rate: null, paid_amount: 1000 },
paymentDate: '2026-08-28',
newPaidAmount: 1999.6,
journalEntryId: 'je-1',
})
expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({ amount: 999.6 })
})
it('treats a missing prior paid_amount as zero and defaults currency to SEK', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-3' } })
await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, {
userId: 'user-1',
companyId: 'company-1',
invoice: { id: 'inv-1' },
paymentDate: '2026-08-28',
newPaidAmount: 12500,
journalEntryId: 'je-1',
})
expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({
amount: 12500,
currency: 'SEK',
exchange_rate: null,
transaction_id: null,
notes: null,
})
})
it('bank match: carries the transaction, the rate actually used and the note', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-4' } })
// 1 000 SEK bank line on a 140 USD invoice booked at 9.30, spot 10.45 on
// the payment date: the row records the spot rate (ML 8 kap 21-23 §), not
// the booking rate, and the öre-rounded applied amount.
const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, {
userId: 'user-1',
companyId: 'company-1',
invoice: { id: 'inv-1', currency: 'USD', exchange_rate: 9.3, paid_amount: 0 },
paymentDate: '2026-05-30',
newPaidAmount: 95.69,
journalEntryId: 'je-fx',
transactionId: 'tx-1',
exchangeRate: 10.45,
notes: 'Manuell kurs 10,45',
})
expect(result).toEqual({ ok: true, id: 'ip-4' })
expect(findCalls('invoice_payments', 'insert')[0][0]).toEqual({
user_id: 'user-1',
company_id: 'company-1',
invoice_id: 'inv-1',
payment_date: '2026-05-30',
amount: 95.69,
currency: 'USD',
exchange_rate: 10.45,
journal_entry_id: 'je-fx',
transaction_id: 'tx-1',
notes: 'Manuell kurs 10,45',
})
})
it('an explicit null exchangeRate is stored as null, not replaced by the invoice rate', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-5' } })
// The link-to-existing-voucher flow records no rate for a SEK bank line
// even when the invoice carries a booking rate.
await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, {
userId: 'user-1',
companyId: 'company-1',
invoice: { id: 'inv-1', currency: 'SEK', exchange_rate: 9.3, paid_amount: 0 },
paymentDate: '2026-08-28',
newPaidAmount: 100,
journalEntryId: 'je-1',
transactionId: 'tx-1',
exchangeRate: null,
})
expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({
exchange_rate: null,
transaction_id: 'tx-1',
})
})
it('surfaces the Postgres SQLSTATE so a caller can map a unique violation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'duplicate key value', code: '23505' } })
const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, {
userId: 'user-1',
companyId: 'company-1',
invoice: { id: 'inv-1' },
paymentDate: '2026-08-28',
newPaidAmount: 100,
journalEntryId: 'je-1',
})
expect(result).toEqual({ ok: false, error: 'duplicate key value', code: '23505' })
})
it('reports an insert failure instead of throwing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'rls' } })
const result = await recordInvoicePaymentRow(supabase as unknown as SupabaseClient, {
userId: 'user-1',
companyId: 'company-1',
invoice: { id: 'inv-1' },
paymentDate: '2026-08-28',
newPaidAmount: 100,
journalEntryId: 'je-1',
})
expect(result).toEqual({ ok: false, error: 'rls' })
})
})
describe('removeInvoicePaymentRow', () => {
beforeEach(() => vi.clearAllMocks())
it('deletes by id and company and reports success', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
removeInvoicePaymentRow(supabase as unknown as SupabaseClient, 'company-1', 'ip-1'),
).resolves.toBe(true)
expect(findCalls('invoice_payments', 'delete')).toHaveLength(1)
expect(findCalls('invoice_payments', 'eq')).toEqual([
['id', 'ip-1'],
['company_id', 'company-1'],
])
expect(logError).not.toHaveBeenCalled()
})
it('logs a failed rollback at error level with the row id, and never throws', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'permission denied' } })
await expect(
removeInvoicePaymentRow(supabase as unknown as SupabaseClient, 'company-1', 'ip-1'),
).resolves.toBe(false)
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('stranded'),
{ message: 'permission denied' },
{ companyId: 'company-1', invoicePaymentId: 'ip-1' },
)
})
it('is a no-op without a row id', async () => {
const { supabase, findCalls } = createQueuedMockSupabase()
await expect(
removeInvoicePaymentRow(supabase as unknown as SupabaseClient, 'company-1', null),
).resolves.toBe(true)
expect(findCalls('invoice_payments', 'delete')).toHaveLength(0)
})
})