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
@@ -42,6 +42,118 @@ describe('recordInvoicePaymentRow', () => {
})
})
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' } })
+59 -16
View File
@@ -5,8 +5,16 @@ import { roundOre } from '@/lib/money'
const log = createLogger('invoice-payment-row')
/**
* The AR sub-ledger row for a payment that no bank transaction drives:
* "Markera som betald" (dashboard, v1, MCP) and the Stripe payment sync.
* 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
@@ -14,16 +22,13 @@ const log = createLogger('invoice-payment-row')
* end, double-counting revenue and VAT (#2019); the Betalningar view and the
* voucher -> invoice reference map read the same table.
*
* Shape mirrors the bank-match path (app/api/transactions/[id]/match-invoice):
* amount in INVOICE currency, transaction_id null. The
* `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.
*
* `amount` is the amount APPLIED to the invoice (new paid_amount minus the
* prior one), not 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`.
*/
export interface RecordInvoicePaymentRowParams {
userId: string
@@ -39,18 +44,52 @@ export interface RecordInvoicePaymentRowParams {
/** 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 }
| { ok: false; error: 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 } = params
const amount = roundOre(newPaidAmount - (invoice.paid_amount ?? 0))
const {
userId,
companyId,
invoice,
paymentDate,
newPaidAmount,
journalEntryId,
transactionId,
exchangeRate,
notes,
} = params
const amount = appliedPaymentAmount(invoice, newPaidAmount)
const { data, error } = await supabase
.from('invoice_payments')
@@ -61,16 +100,20 @@ export async function recordInvoicePaymentRow(
payment_date: paymentDate,
amount,
currency: invoice.currency ?? 'SEK',
exchange_rate: invoice.exchange_rate ?? null,
exchange_rate: exchangeRate !== undefined ? exchangeRate : (invoice.exchange_rate ?? null),
journal_entry_id: journalEntryId,
transaction_id: null,
notes: null,
transaction_id: transactionId ?? null,
notes: notes ?? null,
})
.select('id')
.single()
if (error || !data) {
return { ok: false, error: error?.message ?? 'no_row_returned' }
return {
ok: false,
error: error?.message ?? 'no_row_returned',
...(error?.code ? { code: error.code } : {}),
}
}
return { ok: true, id: (data as { id: string }).id }
}
@@ -231,7 +231,7 @@ describe('commitPendingOperation: link_transaction_journal_entry', () => {
})
enqueue({ data: [{ id: TX_UUID }], error: null }) // tx UPDATE
enqueue({ data: [{ id: INV_UUID }], error: null }) // optimistic-lock invoice UPDATE
enqueue({ data: null, error: null }) // invoice_payments INSERT
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments INSERT
enqueue({ data: null, error: null }) // logMatchEvent insert
enqueue({ data: null, error: null }) // dispatcher commit update
@@ -171,7 +171,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: null, error: null }) // invoice_payments insert
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher pending_operations update
@@ -196,6 +196,15 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
expect(mockCreateCashEntry).not.toHaveBeenCalled()
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
// No residual: the applied amount IS the cash received (#2250).
expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({
invoice_id: 'inv-1',
transaction_id: 'tx-1',
payment_date: '2026-05-12',
amount: 12500,
currency: 'SEK',
journal_entry_id: 'je-1',
})
expect(matchedHandler).toHaveBeenCalledWith(
expect.objectContaining({
invoice: expect.objectContaining({
@@ -253,7 +262,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: null, error: null }) // invoice_payments insert
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher pending_operations update
@@ -302,7 +311,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
// it keeps the 1930 fallback.
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: null, error: null }) // invoice_payments insert
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher pending_operations update
@@ -407,7 +416,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: null, error: null }) // invoice_payments insert
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher pending_operations update
@@ -435,6 +444,79 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
})
})
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 and
// v1 routes; the #2236 definition every reader relies on).
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: {
id: 'tx-1',
company_id: 'company-1',
amount: 1000,
currency: 'SEK',
date: '2026-05-12',
invoice_id: null,
journal_entry_id: null,
cash_account_id: null,
},
error: null,
}) // transaction fetch
enqueue({
data: {
id: 'inv-1',
invoice_number: 'F-2026002',
status: 'sent',
total: 999.6,
remaining_amount: 999.6,
paid_amount: 0,
currency: 'SEK',
exchange_rate: null,
journal_entry_id: null,
customer: { name: 'Test AB' },
},
error: null,
}) // invoice fetch
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher pending_operations update
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
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,
})
expect(findCalls('invoice_payments', 'insert').at(-1)?.[0]).toMatchObject({
invoice_id: 'inv-1',
transaction_id: 'tx-1',
payment_date: '2026-05-12',
amount: 999.6,
currency: 'SEK',
journal_entry_id: 'je-1',
})
})
it('converts a cross-currency payment into the invoice currency before recording it', async () => {
// Parity with the dashboard/v1 routes: feeding the raw SEK amount into a
// USD invoice corrupts the units of paid_amount / remaining_amount and
@@ -474,7 +556,7 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: null, error: null }) // invoice_payments insert
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: null, error: null }) // transactions update (link)
enqueue({ data: null, error: null }) // dispatcher pending_operations update
+28 -11
View File
@@ -3504,18 +3504,35 @@ async function commitMatchTransactionInvoice(
// No cash-method note here anymore: pure kontantmetoden partials are now
// rejected above, and for an invoice booked at send the clearing entry
// handles a partial correctly, so the note would be misleading.
await supabase.from('invoice_payments').insert({
user_id: userId,
company_id: companyId,
invoice_id: invoiceId,
payment_date: transaction.date,
amount: paidAmount,
currency: invoice.currency,
exchange_rate: fx.required ? fx.rate : invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: null,
// The AR sub-ledger row goes through the single writer
// (lib/invoices/invoice-payment-row.ts), which owns the amount definition:
// applied to the invoice, never the cash received (#2250). Not fatal here,
// exactly like the fire-and-forget insert it replaces: the voucher and the
// invoice update are already committed. The failure is logged now, so the
// row the kontantmetod cut-off would otherwise miss can be added.
const recorded = await recordInvoicePaymentRow(supabase, {
userId,
companyId,
invoice: {
id: invoiceId,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
paid_amount: invoice.paid_amount,
},
paymentDate: transaction.date,
newPaidAmount,
journalEntryId,
transactionId,
exchangeRate: fx.required ? fx.rate : invoice.exchange_rate,
})
if (!recorded.ok) {
log.error('match_transaction_invoice: invoice_payments insert failed', undefined, {
invoiceId,
transactionId,
companyId,
error: recorded.error,
})
}
// The invoice is now settled, so every OTHER transaction still carrying a
// suggestion pointer at it is dead: retire them (issue #1259). This
+23 -14
View File
@@ -17,6 +17,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { recordInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
@@ -389,22 +390,30 @@ export async function linkTransactionToJournalEntry(
// reporting.
const paymentExchangeRate = transaction.exchange_rate ?? null
const { error: paymentInsertError } = await supabase
.from('invoice_payments')
.insert({
user_id: userId,
company_id: companyId,
invoice_id: invoiceId,
payment_date: transaction.date,
amount: transaction.amount,
// The AR sub-ledger row goes through the single writer
// (lib/invoices/invoice-payment-row.ts). This path plans strictly (no öre
// absorption, same currency only), so the applied amount IS the bank line
// to the öre: the writer is used for one set of row semantics, not for a
// different number. A unique violation (23505) means the row already
// exists for this voucher and is not an error here.
const recorded = await recordInvoicePaymentRow(supabase, {
userId,
companyId,
invoice: {
id: invoiceId,
currency: invoice.currency,
exchange_rate: paymentExchangeRate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)',
})
exchange_rate: invoice.exchange_rate,
paid_amount: invoice.paid_amount,
},
paymentDate: transaction.date,
newPaidAmount,
journalEntryId,
transactionId,
exchangeRate: paymentExchangeRate,
notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)',
})
if (paymentInsertError && paymentInsertError.code !== '23505') {
if (!recorded.ok && recorded.code !== '23505') {
const { error: invRevertErr } = await supabase
.from('invoices')
.update({