Files
accounted/lib/invoices/invoice-payment-row.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

155 lines
5.5 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
const log = createLogger('invoice-payment-row')
/**
* The single writer of invoice_payments rows (the customer AR sub-ledger).
*
* Every settlement path records its payment here: "Markera som betald"
* (dashboard, v1, MCP), the Stripe payment sync, the bank match (dashboard,
* v1, pending-operation commit) and the link-to-existing-voucher flow.
* `npm run check:guards` (direct-invoice-payment-insert) refuses a
* `.from('invoice_payments').insert(` anywhere else under app/, lib/ or
* extensions/, so the row's field semantics cannot drift per code path
* again: five hand-built inserts each computed their own `amount`, and the
* bank-match ones stored the cash received (#2250).
*
* Without the row the payment has no DATE anywhere. The kontantmetod bokslut
* cut-off (lib/core/bookkeeping/kontantmetod-cutoff.ts) reads invoice_payments
* only and would book a paid invoice as a fordran with vilande moms at year
* end, double-counting revenue and VAT (#2019); the Betalningar view and the
* voucher -> invoice reference map read the same table.
*
* `amount` is the amount APPLIED to the invoice (new paid_amount minus the
* prior one), in INVOICE currency, never the cash received: a SEK
* öresavrundning overshoot absorbed on 3740 is part of the voucher but not
* of the receivable, and every reader subtracts rows from `total`. The
* (transaction_id, invoice_id) unique index treats nulls as distinct, so
* several manual partials on one invoice coexist; (journal_entry_id,
* invoice_id) still refuses the same voucher twice.
*/
export interface RecordInvoicePaymentRowParams {
userId: string
companyId: string
invoice: {
id: string
currency?: string | null
exchange_rate?: number | null
paid_amount?: number | null
}
/** Booking date (YYYY-MM-DD); same value the voucher carries. */
paymentDate: string
/** paid_amount after this payment, in invoice currency. */
newPaidAmount: number
journalEntryId: string | null
/** The bank transaction that drove the payment. Default null: manual, MCP and Stripe rows have none. */
transactionId?: string | null
/**
* The rate ACTUALLY USED for this payment when it is not the invoice's
* booking rate: Riksbanken or a manual override on the payment date for a
* cross-currency bank match (ML 8 kap 21-23 §), or null when the caller
* deliberately records none. Omitted: invoice.exchange_rate.
*/
exchangeRate?: number | null
/** Free text on the row (rate provenance, link note). Default null. */
notes?: string | null
}
export type RecordInvoicePaymentRowResult =
| { ok: true; id: string }
/** `code` is the Postgres SQLSTATE when the driver reported one ('23505' = unique violation). */
| { ok: false; error: string; code?: string }
/**
* new paid_amount minus the prior one, to the öre. Internal on purpose: the
* only way to write a row is recordInvoicePaymentRow, so nothing else needs
* the formula.
*/
function appliedPaymentAmount(
invoice: { paid_amount?: number | null },
newPaidAmount: number,
): number {
return roundOre(newPaidAmount - (invoice.paid_amount ?? 0))
}
export async function recordInvoicePaymentRow(
supabase: SupabaseClient,
params: RecordInvoicePaymentRowParams,
): Promise<RecordInvoicePaymentRowResult> {
const {
userId,
companyId,
invoice,
paymentDate,
newPaidAmount,
journalEntryId,
transactionId,
exchangeRate,
notes,
} = params
const amount = appliedPaymentAmount(invoice, newPaidAmount)
const { data, error } = await supabase
.from('invoice_payments')
.insert({
user_id: userId,
company_id: companyId,
invoice_id: invoice.id,
payment_date: paymentDate,
amount,
currency: invoice.currency ?? 'SEK',
exchange_rate: exchangeRate !== undefined ? exchangeRate : (invoice.exchange_rate ?? null),
journal_entry_id: journalEntryId,
transaction_id: transactionId ?? null,
notes: notes ?? null,
})
.select('id')
.single()
if (error || !data) {
return {
ok: false,
error: error?.message ?? 'no_row_returned',
...(error?.code ? { code: error.code } : {}),
}
}
return { ok: true, id: (data as { id: string }).id }
}
/**
* Undo the row on a failed settlement. Best-effort like the voucher storno
* next to it: the caller is already on a decided error path (race or update
* failure), and that response must not be replaced by a delete error. Never
* throws, but never silent either: a row that survives here points at a
* cancelled voucher for an invoice that never reached paid, and the
* kontantmetod cut-off would read it as a settlement, so the failure is
* logged at error level with everything an operator needs to delete it.
*
* @returns true when the row is gone, false when it may be stranded.
*/
export async function removeInvoicePaymentRow(
supabase: SupabaseClient,
companyId: string,
paymentRowId: string | null,
): Promise<boolean> {
if (!paymentRowId) return true
const ctx = { companyId, invoicePaymentId: paymentRowId }
try {
const { error } = await supabase
.from('invoice_payments')
.delete()
.eq('id', paymentRowId)
.eq('company_id', companyId)
if (error) {
log.error('invoice_payments rollback failed (row may be stranded)', error, ctx)
return false
}
return true
} catch (err) {
log.error('invoice_payments rollback threw (row may be stranded)', err as Error, ctx)
return false
}
}