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>
This commit is contained in:
Jakob Wennberg
2026-09-04 17:13:29 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 2d927349d3
commit 743e3ae7cc
13 changed files with 617 additions and 110 deletions
@@ -238,7 +238,7 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => {
// Update invoice (optimistic lock returns updated row)
enqueue({ data: [{ id: INV_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
enqueue({ data: { id: 'ip-1' }, error: null })
// logMatchEvent
enqueue({ data: null, error: null })
@@ -317,7 +317,7 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => {
})
enqueue({ data: [{ id: TX_UUID }], error: null }) // update transaction
enqueue({ data: [{ id: INV_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // logMatchEvent
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
@@ -304,7 +304,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx' })
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
@@ -404,7 +404,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateJournalEntry.mockResolvedValue({ id: 'je-fx-manual' })
enqueue({ data: [{ id: VALID_UUID }], error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
@@ -465,7 +465,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
// Update invoice (optimistic lock returns updated row)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
enqueue({ data: { id: 'ip-1' }, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent insert (fire-and-forget)
@@ -494,6 +494,16 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(body.paid_at).toBe('2024-06-15T12:00:00Z')
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
expect(invoiceUpdate).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' })
// No residual: the applied amount IS the cash received, and the row keeps
// the bank transaction, date and currency it always carried (#2250).
expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({
invoice_id: VALID_UUID,
transaction_id: 'tx-1',
payment_date: '2024-06-15',
amount: 12500,
currency: 'SEK',
journal_entry_id: 'je-1',
})
expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith(
expect.objectContaining({
type: 'invoice.match_confirmed',
@@ -533,6 +543,78 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
)
})
it('3740 residual: the invoice_payments row carries the applied amount, not the cash received (#2250)', async () => {
// Remaining 999.60 settled by a whole-krona 1 000.00 bank line: 3740
// absorbs the 0.40 and paid_amount advances by the remaining only. The AR
// sub-ledger row must be 999.60 too, or every reader that subtracts rows
// from total (kontantmetod cut-off, reskontra, the storno sync) lands
// 0.40 off with a negative outstanding on a paid invoice.
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2024-06-15' })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 999.6,
remaining_amount: 999.6,
paid_amount: 0,
subtotal: 799.68,
vat_amount: 199.92,
invoice_number: 'F-2024002',
customer: makeCustomer(),
})
enqueue({ data: tx, error: null }) // fetch transaction
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: [], error: null }) // hard-duplicate check
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
invoice_status: string
paid_amount: number
remaining_amount: number
}>(response)
expect(status).toBe(200)
expect(body.invoice_status).toBe('paid')
expect(body.paid_amount).toBe(999.6)
expect(body.remaining_amount).toBe(0)
// The voucher carries the cash: Dr 1930 1 000 / Cr 1510 999.60 / Cr 3740 0.40.
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
lines: expect.arrayContaining([
expect.objectContaining({ account_number: '1930', debit_amount: 1000 }),
expect.objectContaining({ account_number: '1510', credit_amount: 999.6 }),
expect.objectContaining({ account_number: '3740', credit_amount: 0.4 }),
]),
}),
)
expect(findCalls('invoices', 'update').at(-1)?.[0]).toMatchObject({
status: 'paid',
paid_amount: 999.6,
remaining_amount: 0,
})
// The AR sub-ledger row carries the receivable it cleared, not the cash.
expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({
invoice_id: VALID_UUID,
transaction_id: 'tx-1',
payment_date: '2024-06-15',
amount: 999.6,
currency: 'SEK',
journal_entry_id: 'je-1',
})
})
it('stornos conflicting journal entry before matching', async () => {
const tx = makeTransaction({
id: 'tx-1',
@@ -568,7 +650,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
enqueue({ data: { id: 'ip-1' }, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent for match
@@ -639,7 +721,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
enqueue({ data: { id: 'ip-1' }, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
@@ -685,7 +767,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
@@ -809,7 +891,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
// invoice_payments → update transaction → logMatchEvent.
enqueue({ data: null, error: null }) // document_attachments lookup
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
@@ -861,7 +943,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1940' })
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
@@ -915,7 +997,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-cash-1940' })
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update transaction
enqueue({ data: null, error: null }) // logMatchEvent
@@ -967,7 +1049,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateJournalEntry.mockResolvedValue({ id: 'je-default' })
enqueue({ data: [{ id: VALID_UUID }], error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
@@ -1093,7 +1175,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
// Update invoice
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
enqueue({ data: { id: 'ip-1' }, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
@@ -1288,7 +1370,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial-extra' })
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update tx
enqueue({ data: null, error: null }) // logMatchEvent
@@ -1368,7 +1450,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateJournalEntry.mockResolvedValue({ id: 'je-forced' })
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
enqueue({ data: null, error: null }) // insert invoice_payments
enqueue({ data: { id: 'ip-1' }, error: null }) // insert invoice_payments
enqueue({ data: null, error: null }) // update tx
enqueue({ data: null, error: null }) // logMatchEvent
@@ -15,6 +15,7 @@ import { validateBody } from '@/lib/api/validate'
import { MatchInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { recordInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
@@ -700,33 +701,37 @@ export const POST = withRouteContext(
const paymentNotes = manualRateNote
// Payment row stores amount in INVOICE currency (the column unit). For
// same-currency that's tx.amount; for cross-currency it's the spot-rate
// conversion above. exchange_rate records the rate ACTUALLY USED for
// this payment: Riksbanken (or manual override) on tx.date: per
// ML 8 kap 21-23§. Falling back to invoice.exchange_rate would record
// the invoice-date rate, which is what the round-7/8 bot reviews
// explicitly flagged as wrong.
const { error: paymentInsertError } = await supabase
.from('invoice_payments')
.insert({
user_id: user.id,
company_id: companyId,
invoice_id,
payment_date: transaction.date,
amount: paidAmountInInvoiceCurrency,
// The AR sub-ledger row goes through the single writer
// (lib/invoices/invoice-payment-row.ts): amount = the amount APPLIED to
// the invoice (new paid_amount minus the prior one), never the cash
// received, so a whole-krona overshoot absorbed on 3740 stays in the
// voucher and out of the receivable (#2250). exchange_rate is the rate
// ACTUALLY USED for this payment: Riksbanken (or manual override) on
// tx.date, per ML 8 kap 21-23§. Falling back to invoice.exchange_rate
// would record the invoice-date rate, which is what the round-7/8 bot
// reviews explicitly flagged as wrong.
const recorded = await recordInvoicePaymentRow(supabase, {
userId: user.id,
companyId,
invoice: {
id: invoice_id,
currency: invoice.currency,
exchange_rate: fx.required ? fx.rate : invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: paymentNotes,
})
exchange_rate: invoice.exchange_rate,
paid_amount: invoice.paid_amount,
},
paymentDate: transaction.date,
newPaidAmount,
journalEntryId,
transactionId,
exchangeRate: fx.required ? fx.rate : invoice.exchange_rate,
notes: paymentNotes,
})
if (paymentInsertError) {
if (paymentInsertError.code === '23505') {
if (!recorded.ok) {
if (recorded.code === '23505') {
return errorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, { requestId })
}
txLog.error('failed to record invoice payment', paymentInsertError)
txLog.error('failed to record invoice payment', undefined, { error: recorded.error })
return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId })
}
@@ -557,7 +557,10 @@ describe('POST :id/match-invoice', () => {
{ data: [{ id: INV_ID }], error: null }, // status update select
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
invoice_payments: { data: null, error: null },
invoice_payments: [
{ data: [], error: null },
{ data: { id: 'ip-1' }, error: null },
],
}),
)
const res = await matchInvoicePOST(
@@ -716,7 +719,10 @@ describe('POST :id/match-invoice', () => {
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
cash_accounts: { data: { ledger_account: '1940' }, error: null },
invoice_payments: { data: null, error: null },
invoice_payments: [
{ data: [], error: null },
{ data: { id: 'ip-1' }, error: null },
],
}),
)
const res = await matchInvoicePOST(
@@ -154,6 +154,12 @@ describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', ()
data: { accounting_method: 'accrual', entity_type: 'enskild_firma' },
error: null,
},
// First read is the hard-duplicate guard (no prior voucher); the
// writer then selects the inserted row's id back.
invoice_payments: [
{ data: [], error: null },
{ data: { id: 'ip-1' }, error: null },
],
},
calls,
),
@@ -176,6 +182,18 @@ describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', ()
(call) => call.table === 'invoices' && call.method === 'update',
)
expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2024-06-15T12:00:00Z' })
// No residual: the applied amount IS the cash received (#2250).
const paymentInsert = calls.find(
(call) => call.table === 'invoice_payments' && call.method === 'insert',
)
expect(paymentInsert?.args[0]).toMatchObject({
invoice_id: INVOICE_ID,
transaction_id: TX_ID,
payment_date: '2024-06-15',
amount: 12500,
currency: 'SEK',
journal_entry_id: 'je-1',
})
expect(matchedHandler).toHaveBeenCalledWith(
expect.objectContaining({
invoice: expect.objectContaining({
@@ -192,6 +210,85 @@ describe('POST /api/v1/companies/:companyId/transactions/:id/match-invoice', ()
)
})
it('3740 residual: the invoice_payments row carries the applied amount, not the cash received (#2250)', async () => {
// Remaining 999.60 settled by a whole-krona 1 000.00 bank line: 3740
// absorbs the 0.40 and paid_amount advances by the remaining only, so the
// AR sub-ledger row must be 999.60 as well (parity with the dashboard
// route and the pending-operation commit path).
const calls: RecordedCall[] = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: { data: { ...TRANSACTION, amount: 1000 }, error: null },
invoices: [
{
data: { ...SENT_INVOICE, total: 999.6, remaining_amount: 999.6, paid_amount: 0 },
error: null,
},
{ data: [{ id: INVOICE_ID }], error: null },
],
company_settings: {
data: { accounting_method: 'accrual', entity_type: 'enskild_firma' },
error: null,
},
// First read is the hard-duplicate guard (no prior voucher); the
// writer then selects the inserted row's id back.
invoice_payments: [
{ data: [], error: null },
{ data: { id: 'ip-1' }, error: null },
],
},
calls,
),
)
const response = await matchInvoice(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
{ invoice_id: INVOICE_ID },
),
detailParams(COMPANY_ID, TX_ID),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.invoice_status).toBe('paid')
expect(body.data.paid_amount).toBe(999.6)
expect(body.data.remaining_amount).toBe(0)
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
USER_ID,
expect.objectContaining({
lines: expect.arrayContaining([
expect.objectContaining({ account_number: '1930', debit_amount: 1000 }),
expect.objectContaining({ account_number: '1510', credit_amount: 999.6 }),
expect.objectContaining({ account_number: '3740', credit_amount: 0.4 }),
]),
}),
)
const invoiceUpdate = calls.find(
(call) => call.table === 'invoices' && call.method === 'update',
)
expect(invoiceUpdate?.args[0]).toMatchObject({
status: 'paid',
paid_amount: 999.6,
remaining_amount: 0,
})
const paymentInsert = calls.find(
(call) => call.table === 'invoice_payments' && call.method === 'insert',
)
expect(paymentInsert?.args[0]).toMatchObject({
invoice_id: INVOICE_ID,
transaction_id: TX_ID,
payment_date: '2024-06-15',
amount: 999.6,
currency: 'SEK',
journal_entry_id: 'je-1',
})
})
it('returns 401 when no bearer token is supplied', async () => {
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const response = await matchInvoice(
@@ -41,6 +41,7 @@ import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { recordInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
@@ -709,33 +710,38 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const paymentNotes = [cashMethodNote, manualRateNote].filter(Boolean).join(' · ') || null
// amount and currency must agree: the row stores the payment in INVOICE
// currency (the column's unit), never a SEK magnitude wearing the invoice's
// foreign currency code. exchange_rate is the rate ACTUALLY USED for this
// payment (Riksbanken or the caller's override on the payment date, per
// ML 8 kap 21-23§), falling back to the invoice's booking rate only when no
// conversion was needed.
const { error: paymentInsertErr } = await ctx.supabase
.from('invoice_payments')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
invoice_id,
payment_date: transaction.date,
amount: paidAmount,
// The AR sub-ledger row goes through the single writer
// (lib/invoices/invoice-payment-row.ts): amount = the amount APPLIED to
// the invoice (new paid_amount minus the prior one) in INVOICE currency,
// never a SEK magnitude wearing the invoice's foreign currency code and
// never the cash received (a whole-krona overshoot absorbed on 3740 is
// part of the voucher, not of the receivable, #2250). exchange_rate is
// the rate ACTUALLY USED for this payment (Riksbanken or the caller's
// override on the payment date, per ML 8 kap 21-23§), falling back to the
// invoice's booking rate only when no conversion was needed.
const recorded = await recordInvoicePaymentRow(ctx.supabase, {
userId: ctx.userId,
companyId: ctx.companyId!,
invoice: {
id: invoice_id,
currency: invoice.currency,
exchange_rate: fx.required ? fx.rate : invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: txId,
notes: paymentNotes,
})
if (paymentInsertErr) {
if (paymentInsertErr.code === '23505') {
exchange_rate: invoice.exchange_rate,
paid_amount: invoice.paid_amount,
},
paymentDate: transaction.date,
newPaidAmount,
journalEntryId,
transactionId: txId,
exchangeRate: fx.required ? fx.rate : invoice.exchange_rate,
notes: paymentNotes,
})
if (!recorded.ok) {
if (recorded.code === '23505') {
return v1ErrorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, {
requestId: ctx.requestId,
})
}
txLog.error('failed to record payment', paymentInsertErr)
txLog.error('failed to record payment', undefined, { error: recorded.error })
return v1ErrorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, {
requestId: ctx.requestId,
})