fix(invoices): record manual and Stripe settlements in invoice_payments (#2236)

* fix(invoices): record manual and Stripe settlements in invoice_payments (#2019)

settleInvoicePayment created the payment voucher and flipped the invoice to
paid but never wrote the AR sub-ledger row. The kontantmetod bokslut cut-off
reads invoice_payments only (payment DATE, not remaining_amount), so a
manually settled invoice was booked again as a fordran with vilande moms at
year end, double-counting revenue and VAT. The same gap hid the payment from
the Betalningar view and from the voucher -> invoice reference map.

- Insert the row between voucher creation and the CAS status update, same
  shape as the bank-match path (amount in invoice currency, transaction_id
  null). An insert failure cancels the voucher and fails closed; both CAS
  failure branches remove the row together with the voucher.
- Backfill: scripts/backfill-invoice-payment-rows.ts (dry-run default) with
  a pure planner in lib/invoices/backfill-invoice-payment-rows.ts. Writes
  only where exactly one posted payment voucher exists; zero or several are
  reported, never guessed. Rows carry notes 'backfill:#2019' so one DELETE
  reverts a run. Executed on staging (10 rows); prod awaits explicit go.
- pg-real: transaction-less rows coexist under the tx/invoice unique index,
  the je/invoice index still refuses a double link, and the authenticated
  writer can delete its own row (the CAS-failure path depends on it).

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

* fix(invoices): write the payment row from every mark-paid path and harden the backfill

Skeptic and review round on #2236 (issue #2019):

- One helper (lib/invoices/invoice-payment-row.ts) now writes the
  invoice_payments row for all four transaction-less settlement paths:
  dashboard mark-paid and Stripe via settleInvoicePayment, plus the MCP
  mark_invoice_paid commit and the v1 mark-paid route, which booked their
  own voucher and never wrote the row. Amount = applied amount (new
  paid_amount minus prior), not cash received, so a 3740 öre absorption
  never yields a negative fordran in the cut-off or a wrong storno restore.
- The two duplicate detectors no longer treat a payment row with
  transaction_id NULL as "reconciled to a bank line": the bank line for a
  manual settlement arrives later and the voucher must stay a twin.
- Backfill: payment_date from the voucher entry_date (paid_at was
  wall-clock before #1332); refuse rows that disagree with the voucher's
  1510 credit / settlement debit; report partially covered invoices
  (rows_short) instead of patching; record each executed run in
  behandlingshistorik (InvoicePaymentRowBackfilled, migration
  20260903180000). Re-run end to end on staging: 10 rows, 10 events.
- Typecheck ratchet: cast in the cut-off test.

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

* fix(invoices): use roundOre in the #2019 backfill (guard ratchet)

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

* fix(invoices): log a failed payment-row rollback and keep backfill rows with their audit event

Swedish review round 2 on #2236:

- removeInvoicePaymentRow no longer swallows a failed compensating DELETE:
  it logs at error level with company and row id (a stranded row would
  read as a settlement in the kontantmetod cut-off) and returns whether
  the row is gone. Unit tests for the helper.
- The backfill deletes a company's rows from the run again when its
  behandlingshistorik event cannot be written, so rows and change log
  (BFNAR 2013:2 p. 9.16) never diverge; the company is listed for a re-run.

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

* fix(invoices): keep raw insert errors out of the v1 and MCP mark-paid responses

Compliance swarm on #2236 (ISO 27001 A.8.28): the payment-row insert
failure returned the driver's error text to API callers and MCP users.
The text now stays in the server log; callers get the reason code and a
generic Swedish outcome.

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

* fix(invoices): never backfill a payment row into a closed or locked period

Swedish review round 3 on #2236: a row dated into a closed or locked
fiscal period changes facts a filed bokslut or deklaration relied on. The
planner now reports such invoices (period_closed) instead of writing them,
and the script header states that the tagged DELETE is an emergency revert
for the window before any cut-off relies on the rows; afterwards the
correction path is a storno of the cut-off verifikat.

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

* test(fiscal-periods): pass route params in the two mid-month tests (typecheck ratchet)

cc18e9d53 (#2242) added two POST(req) calls without the params argument,
raising the file's TypeScript error count above the ratchet baseline
(25 vs 23). main is red on "Checks" for every PR since; this unblocks the
gate for #2236 and the rest without touching the baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 19:42:43 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 3159920d7c
commit e2d38b0ab3
21 changed files with 1641 additions and 16 deletions
@@ -575,6 +575,26 @@ describe('collectKontantmetodCutoff', () => {
expect(result.payables[0]).toMatchObject({ outstanding: 9200, vat: 1840 })
})
// Issue #2019: a manual "Markera som betald" payment carries no bank
// transaction. The cut-off keys on payment DATE alone, so such a row must
// retire the fordran exactly like a bank-matched one.
it('treats a transaction-less manual payment as settling the fordran', async () => {
const result = await collectKontantmetodCutoff(makePagedSupabase({
invoices: [{
id: 'inv-manual', invoice_number: '001', invoice_date: '2026-08-01', status: 'paid',
total: 12500, vat_amount: 2500, vat_treatment: 'standard_25', document_type: 'invoice',
currency: 'SEK',
}],
invoice_payments: [{
id: 'ip-manual', invoice_id: 'inv-manual', amount: 12500, payment_date: '2026-08-28',
transaction_id: null, journal_entry_id: 'je-cash',
}],
supplier_invoices: [],
supplier_invoice_payments: [],
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables).toEqual([])
})
it('collects reverse-charge rate, supplier type, and scaled declaration basis', async () => {
const result = await collectKontantmetodCutoff(makePagedSupabase({
supplier_invoices: [{
@@ -0,0 +1,210 @@
import { describe, it, expect } from 'vitest'
import {
BACKFILL_NOTES_TAG,
planInvoicePaymentBackfill,
settlementSekFromLines,
type BackfillInvoice,
type BackfillVoucher,
} from '../backfill-invoice-payment-rows'
const invoice = (over: Partial<BackfillInvoice> = {}): BackfillInvoice => ({
id: 'inv-1',
company_id: 'co-1',
user_id: 'user-1',
invoice_number: '001',
status: 'paid',
document_type: 'invoice',
currency: 'SEK',
exchange_rate: null,
paid_amount: 12500,
paid_at: '2026-08-28T12:00:00+00:00',
...over,
})
const NONE = { count: 0, sum: 0 }
const voucher = (over: Partial<BackfillVoucher> = {}): BackfillVoucher => ({
id: 'je-1',
source_id: 'inv-1',
source_type: 'invoice_cash_payment',
status: 'posted',
entry_date: '2026-08-28',
settlement_sek: 12500,
...over,
})
describe('planInvoicePaymentBackfill', () => {
it('plans one tagged row from the single posted payment voucher', () => {
const plan = planInvoicePaymentBackfill(invoice(), [voucher()], NONE)
expect(plan).toEqual({
kind: 'insert',
row: {
user_id: 'user-1',
company_id: 'co-1',
invoice_id: 'inv-1',
payment_date: '2026-08-28',
amount: 12500,
currency: 'SEK',
exchange_rate: null,
journal_entry_id: 'je-1',
transaction_id: null,
notes: expect.stringContaining(BACKFILL_NOTES_TAG),
},
})
})
it('takes the payment date from the voucher, never from paid_at', () => {
const partial = planInvoicePaymentBackfill(
invoice({ status: 'partially_paid', paid_amount: 5000, paid_at: null }),
[voucher({ source_type: 'invoice_paid', entry_date: '2026-08-20', settlement_sek: 5000 })],
NONE,
)
expect(partial).toMatchObject({ kind: 'insert', row: { payment_date: '2026-08-20', amount: 5000 } })
// Pre-#1332 paid_at was the wall-clock registration time: a December
// payment booked in January must stay in December.
const registeredLater = planInvoicePaymentBackfill(
invoice({ paid_at: '2026-01-08T09:12:44Z' }),
[voucher({ entry_date: '2025-12-30' })],
NONE,
)
expect(registeredLater).toMatchObject({ kind: 'insert', row: { payment_date: '2025-12-30' } })
})
it('keeps the invoice currency and rate on the row', () => {
const plan = planInvoicePaymentBackfill(
invoice({ currency: 'EUR', exchange_rate: 11.5, paid_amount: 1000 }),
[voucher({ source_type: 'invoice_paid', settlement_sek: 11500 })],
NONE,
)
expect(plan).toMatchObject({
kind: 'insert',
row: { currency: 'EUR', exchange_rate: 11.5, amount: 1000 },
})
})
it('skips invoices that already have a sub-ledger row', () => {
expect(planInvoicePaymentBackfill(invoice(), [voucher()], { count: 1, sum: 12500 })).toEqual({
kind: 'skip',
reason: 'has_rows',
})
})
it('reports rows that sum to less than paid_amount instead of patching the difference', () => {
// Manual partial 4 000 (no row) followed by a bank-matched 6 000 (row).
expect(
planInvoicePaymentBackfill(
invoice({ paid_amount: 10000 }),
[voucher(), voucher({ id: 'je-bank' })],
{ count: 1, sum: 6000 },
),
).toEqual({ kind: 'skip', reason: 'rows_short' })
// Öre noise is not a shortfall.
expect(
planInvoicePaymentBackfill(invoice({ paid_amount: 10000 }), [voucher()], { count: 1, sum: 9999.996 }),
).toEqual({ kind: 'skip', reason: 'has_rows' })
})
it('skips non-invoices, unpaid invoices and zero paid amounts', () => {
expect(planInvoicePaymentBackfill(invoice({ document_type: 'proforma' }), [voucher()], NONE))
.toMatchObject({ kind: 'skip', reason: 'not_invoice' })
expect(planInvoicePaymentBackfill(invoice({ status: 'sent' }), [voucher()], NONE))
.toMatchObject({ kind: 'skip', reason: 'not_paid' })
expect(planInvoicePaymentBackfill(invoice({ paid_amount: 0 }), [voucher()], NONE))
.toMatchObject({ kind: 'skip', reason: 'no_paid_amount' })
})
it('refuses a row whose amount the voucher never booked', () => {
// Header says 12 500 paid, the clearing entry credited 1510 with 12 000.
expect(
planInvoicePaymentBackfill(invoice(), [voucher({ settlement_sek: 12000 })], NONE),
).toEqual({ kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: ['je-1'] })
// Öre absorption on 3740 (voucher 12 500.40 vs paid 12 500) is inside the band.
expect(
planInvoicePaymentBackfill(invoice(), [voucher({ settlement_sek: 12500.4 })], NONE),
).toMatchObject({ kind: 'insert' })
// Foreign currency: SEK leg checked through the invoice rate within 1 %.
expect(
planInvoicePaymentBackfill(
invoice({ currency: 'EUR', exchange_rate: 11.5, paid_amount: 1000 }),
[voucher({ settlement_sek: 11000 })],
NONE,
),
).toEqual({ kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: ['je-1'] })
// No rate and no readable settlement leg cannot be verified.
expect(
planInvoicePaymentBackfill(
invoice({ currency: 'EUR', exchange_rate: null, paid_amount: 1000 }),
[voucher({ settlement_sek: 11500 })],
NONE,
),
).toEqual({ kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: ['je-1'] })
expect(
planInvoicePaymentBackfill(invoice(), [voucher({ settlement_sek: null })], NONE),
).toEqual({ kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: ['je-1'] })
})
it('reads the applied amount from the 1510 credit, else from the settlement debit', () => {
expect(
settlementSekFromLines([
{ account_number: '1930', debit_amount: 12500.4, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 12500 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.4 },
]),
).toBe(12500)
// Kontantmetoden ROT: the 1513 leg is Skatteverket's share, not customer money.
expect(
settlementSekFromLines([
{ account_number: '1930', debit_amount: 17500, credit_amount: 0 },
{ account_number: '1513', debit_amount: 7500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 20000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 5000 },
]),
).toBe(17500)
expect(
settlementSekFromLines([
{ account_number: '1686', debit_amount: 1250, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1250 },
]),
).toBe(1250)
expect(settlementSekFromLines([])).toBeNull()
})
it('reports a row that would land in a closed or locked period instead of writing it', () => {
const closedBefore2026 = (date: string) => date < '2026-01-01'
expect(
planInvoicePaymentBackfill(
invoice(),
[voucher({ entry_date: '2025-12-30' })],
NONE,
{ isPeriodClosed: closedBefore2026 },
),
).toEqual({ kind: 'skip', reason: 'period_closed', voucherIds: ['je-1'] })
expect(
planInvoicePaymentBackfill(invoice(), [voucher()], NONE, { isPeriodClosed: closedBefore2026 }),
).toMatchObject({ kind: 'insert', row: { payment_date: '2026-08-28' } })
})
it('never guesses the voucher: zero or several posted payment vouchers are skipped', () => {
expect(planInvoicePaymentBackfill(invoice(), [], NONE)).toEqual({
kind: 'skip',
reason: 'no_payment_voucher',
})
// A reversed voucher, a registration entry and a voucher of another
// invoice are not payment vouchers of this one.
expect(
planInvoicePaymentBackfill(
invoice(),
[
voucher({ status: 'reversed' }),
voucher({ id: 'je-reg', source_type: 'invoice_created' }),
voucher({ id: 'je-other', source_id: 'inv-2' }),
],
NONE,
),
).toEqual({ kind: 'skip', reason: 'no_payment_voucher' })
expect(
planInvoicePaymentBackfill(invoice(), [voucher(), voucher({ id: 'je-2' })], NONE),
).toEqual({ kind: 'skip', reason: 'multiple_payment_vouchers', voucherIds: ['je-1', 'je-2'] })
})
})
@@ -203,8 +203,8 @@ describe('detectDuplicatePaymentVoucher', () => {
date: '2026-05-15',
}),
])
// invoice_payments has a row linking this JE
enqueue({ data: [{ journal_entry_id: 'je-3' }], error: null })
// invoice_payments has a row linking this JE to a bank transaction
enqueue({ data: [{ journal_entry_id: 'je-3', transaction_id: 'tx-bank' }], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
@@ -218,6 +218,33 @@ describe('detectDuplicatePaymentVoucher', () => {
expect(result).toBeNull()
})
// #2019: "Markera som betald" and Stripe now write a payment row WITHOUT a
// bank transaction. That row means "paid by hand", not "reconciled to a
// bank line", so the voucher must still surface when the real bank line
// arrives; otherwise a second payment voucher posts silently.
it('still flags a voucher whose payment row carries no bank transaction (manual settlement)', async () => {
enqueueLines([
makeLineRow({
je_id: 'je-manual',
account: '1930',
debit: 1000,
date: '2026-05-15',
}),
])
enqueue({ data: [{ journal_entry_id: 'je-manual', transaction_id: null }], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
transactionCurrency: 'SEK',
})
expect(result?.journal_entry_id).toBe('je-manual')
})
it('excludes JEs already linked from another transaction', async () => {
enqueueLines([
makeLineRow({
@@ -0,0 +1,97 @@
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('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)
})
})
@@ -79,6 +79,7 @@ describe('settleInvoicePayment', () => {
it('books via the payment entry and forwards the settlement account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
const invoice = payableInvoice({ journal_entry_id: 'je-orig' } as Partial<Invoice>)
@@ -161,6 +162,7 @@ describe('settleInvoicePayment', () => {
it('uses the cash entry for unbooked kontantmetoden invoices', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] })
const invoice = payableInvoice({ journal_entry_id: null } as Partial<Invoice>)
@@ -189,6 +191,7 @@ describe('settleInvoicePayment', () => {
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-ore' } as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
// Invoice total 1234.75, PDF "Att betala" 1235.00: the customer pays the
@@ -227,6 +230,7 @@ describe('settleInvoicePayment', () => {
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-partial' } as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
// Deliberate partial: both legs lowered, no 3740. Absorbing here would
@@ -357,6 +361,7 @@ describe('settleInvoicePayment', () => {
it('cancels the orphaned voucher when the CAS update loses the race', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [] }) // CAS update matched nothing (concurrent settle)
const result = await settleInvoicePayment(
@@ -380,6 +385,7 @@ describe('settleInvoicePayment', () => {
// pointing at it as a match suggestion.
it('retires the settled invoice suggestions when the invoice reaches paid', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] })
const result = await settleInvoicePayment(
@@ -401,6 +407,7 @@ describe('settleInvoicePayment', () => {
it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] })
const result = await settleInvoicePayment(
@@ -422,6 +429,7 @@ describe('settleInvoicePayment', () => {
const handler = vi.fn()
eventBus.on('invoice.paid', handler)
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] })
await settleInvoicePayment(supabase as unknown as SupabaseClient, 'company-1', 'user-1', {
@@ -443,4 +451,156 @@ describe('settleInvoicePayment', () => {
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-07-12T12:00:00Z' })
})
// Issue #2019: the manual and Stripe flows never wrote the AR sub-ledger
// row, so the kontantmetod cut-off (which reads invoice_payments only)
// booked a paid invoice as a fordran with vilande moms at bokslut.
describe('invoice_payments row (#2019)', () => {
it('records the payment in the sub-ledger with the voucher and no bank transaction', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' } }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
const invoice = payableInvoice({
journal_entry_id: 'je-orig',
exchange_rate: 1,
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{ ...BASE_PARAMS, invoice },
)
expect(result).toMatchObject({ ok: true, newStatus: 'paid', journalEntryId: 'je-1' })
const inserts = findCalls('invoice_payments', 'insert')
expect(inserts).toHaveLength(1)
expect(inserts[0][0]).toEqual({
user_id: 'user-1',
company_id: 'company-1',
invoice_id: 'inv-1',
payment_date: '2026-07-12',
amount: 1250,
currency: 'SEK',
exchange_rate: 1,
journal_entry_id: 'je-1',
transaction_id: null,
notes: null,
})
expect(findCalls('invoice_payments', 'delete')).toHaveLength(0)
})
it('stores a partial in invoice currency, not the SEK line total', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' } })
enqueue({ data: [{ id: 'inv-1' }] })
const invoice = payableInvoice({
total: 1000,
remaining_amount: 1000,
currency: 'EUR',
exchange_rate: 11.5,
journal_entry_id: 'je-orig',
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{ ...BASE_PARAMS, invoice, paymentAmountInInvoiceCurrency: 400 },
)
expect(result).toMatchObject({ ok: true, newStatus: 'partially_paid', newRemaining: 600 })
expect(findCalls('invoice_payments', 'insert')[0][0]).toMatchObject({
amount: 400,
currency: 'EUR',
exchange_rate: 11.5,
transaction_id: null,
})
})
it('writes the row before the CAS update so the invoice never reaches paid without it', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { code: '42501', message: 'rls' } }) // insert refused
// Deliberately no CAS slot: the update must not run.
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{ ...BASE_PARAMS, invoice: payableInvoice() },
)
expect(result).toMatchObject({
ok: false,
code: 'INVOICE_PAID_BOOK_FAILED',
details: { reason: 'payment_row_insert_failed', error: 'rls' },
})
expect(vi.mocked(cancelOrphanedPaymentEntry)).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-1',
expect.any(String),
)
})
it('removes the row together with the voucher when the CAS update loses the race', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' } }) // insert
enqueue({ data: [] }) // CAS matched nothing
enqueue({ data: null }) // delete
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{ ...BASE_PARAMS, invoice: payableInvoice() },
)
expect(result).toMatchObject({ ok: false, code: 'INVOICE_PAID_RACE' })
expect(findCalls('invoice_payments', 'delete')).toHaveLength(1)
const deleteEqs = findCalls('invoice_payments', 'eq')
expect(deleteEqs).toEqual(
expect.arrayContaining([
['id', 'ip-1'],
['company_id', 'company-1'],
]),
)
expect(vi.mocked(cancelOrphanedPaymentEntry)).toHaveBeenCalledTimes(1)
})
it('removes the row when the invoice update itself fails', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'ip-1' } }) // insert
enqueue({ data: null, error: { message: 'update failed' } }) // CAS update error
enqueue({ data: null }) // delete
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{ ...BASE_PARAMS, invoice: payableInvoice() },
)
expect(result).toMatchObject({ ok: false, code: 'UPDATE_FAILED' })
expect(findCalls('invoice_payments', 'delete')).toHaveLength(1)
expect(vi.mocked(cancelOrphanedPaymentEntry)).toHaveBeenCalledTimes(1)
})
it('skips the sub-ledger for non-invoice document types', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update only
const invoice = payableInvoice({ document_type: 'proforma' } as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{ ...BASE_PARAMS, invoice },
)
expect(result).toMatchObject({ ok: true, journalEntryId: null })
expect(findCalls('invoice_payments', 'insert')).toHaveLength(0)
})
})
})
@@ -0,0 +1,210 @@
/**
* Planner for the #2019 backfill: paid or partially paid customer invoices
* that were settled through "Markera som betald" (or the Stripe sync) before
* settleInvoicePayment wrote the AR sub-ledger row. Pure: the script in
* scripts/backfill-invoice-payment-rows.ts owns the reads and writes.
*
* Deterministic on purpose (project doctrine: never guess). A row is planned
* only when the invoice has exactly ONE posted payment voucher, so the
* journal link is unambiguous. Everything else is reported and skipped:
* zero vouchers (imported / migrated invoices never booked here), several
* vouchers (partials whose split cannot be reconstructed from the header),
* or a row already present (bank-matched, link-to-voucher, or an earlier run).
*/
import { roundOre } from '@/lib/money'
/** Tag written to invoice_payments.notes so one DELETE reverts a whole run. */
export const BACKFILL_NOTES_TAG = 'backfill:#2019'
/** Source types settleInvoicePayment produces (lib/bookkeeping/invoice-entries.ts). */
export const PAYMENT_VOUCHER_SOURCE_TYPES = ['invoice_paid', 'invoice_cash_payment'] as const
export interface BackfillInvoice {
id: string
company_id: string
user_id: string
invoice_number: string | null
status: string
document_type: string | null
currency: string | null
exchange_rate: number | null
paid_amount: number | null
paid_at: string | null
}
export interface BackfillVoucher {
id: string
source_id: string | null
source_type: string
status: string
entry_date: string
/**
* What the voucher actually applied to the receivable, in SEK: the 1510
* credit for a clearing entry (faktureringsmetoden), else the debit on the
* settlement account (19xx / 1686) for a kontantmetoden cash entry. null
* when neither leg exists; undefined when the caller did not load lines.
*/
settlement_sek?: number | null
}
/**
* Derive `settlement_sek` from a voucher's lines. Exported for the script and
* its test; the planner only consumes the result.
*/
export function settlementSekFromLines(
lines: Array<{ account_number: string; debit_amount: number | null; credit_amount: number | null }>,
): number | null {
const credit1510 = lines
.filter((l) => l.account_number === '1510')
.reduce((sum, l) => sum + Number(l.credit_amount ?? 0), 0)
if (credit1510 > 0) return roundOre(credit1510)
const settlementDebit = lines
.filter((l) => l.account_number.startsWith('19') || l.account_number === '1686')
.reduce((sum, l) => sum + Number(l.debit_amount ?? 0), 0)
if (settlementDebit > 0) return roundOre(settlementDebit)
return null
}
export interface BackfillPaymentRow {
user_id: string
company_id: string
invoice_id: string
payment_date: string
amount: number
currency: string
exchange_rate: number | null
journal_entry_id: string
transaction_id: null
notes: string
}
export type BackfillSkipReason =
| 'has_rows'
| 'rows_short'
| 'not_invoice'
| 'not_paid'
| 'no_paid_amount'
| 'no_payment_voucher'
| 'multiple_payment_vouchers'
| 'voucher_amount_mismatch'
| 'voucher_amount_unverifiable'
| 'period_closed'
export type BackfillPlan =
| { kind: 'insert'; row: BackfillPaymentRow }
| { kind: 'skip'; reason: BackfillSkipReason; voucherIds?: string[] }
export interface ExistingPaymentRows {
count: number
/** Sum of invoice_payments.amount, invoice currency. */
sum: number
}
/**
* Decide what to do for one invoice given every voucher whose source_id
* points at it and the invoice_payments rows it already has.
*
* Rows present but summing to less than paid_amount means an earlier manual
* partial has no row while a later bank-matched one does. That invoice is
* `rows_short`: reported for a human, never patched, because the difference
* cannot be attributed to a voucher without guessing.
*/
export interface BackfillPlanOptions {
/**
* Whether the fiscal period covering `date` (YYYY-MM-DD) for this invoice's
* company is closed or locked. A row dated into such a period changes facts
* a filed bokslut or deklaration relied on, so it is reported, not written.
*/
isPeriodClosed?: (date: string) => boolean
}
export function planInvoicePaymentBackfill(
invoice: BackfillInvoice,
vouchers: BackfillVoucher[],
existing: ExistingPaymentRows,
options: BackfillPlanOptions = {},
): BackfillPlan {
const paidAmountRaw = Number(invoice.paid_amount ?? 0)
if (existing.count > 0) {
const short = roundOre(paidAmountRaw - existing.sum)
return short > 0 ? { kind: 'skip', reason: 'rows_short' } : { kind: 'skip', reason: 'has_rows' }
}
if (invoice.document_type && invoice.document_type !== 'invoice') {
return { kind: 'skip', reason: 'not_invoice' }
}
if (invoice.status !== 'paid' && invoice.status !== 'partially_paid') {
return { kind: 'skip', reason: 'not_paid' }
}
const paidAmount = Number(invoice.paid_amount ?? 0)
if (!Number.isFinite(paidAmount) || paidAmount <= 0) {
return { kind: 'skip', reason: 'no_paid_amount' }
}
const paymentVouchers = vouchers.filter(
(v) =>
v.source_id === invoice.id &&
v.status === 'posted' &&
(PAYMENT_VOUCHER_SOURCE_TYPES as readonly string[]).includes(v.source_type),
)
if (paymentVouchers.length === 0) return { kind: 'skip', reason: 'no_payment_voucher' }
if (paymentVouchers.length > 1) {
return {
kind: 'skip',
reason: 'multiple_payment_vouchers',
voucherIds: paymentVouchers.map((v) => v.id),
}
}
const voucher = paymentVouchers[0]
// The row must agree with what the voucher booked, or the cut-off inherits
// a header figure the ledger never carried. SEK invoices must match to the
// öre band; foreign-currency ones are checked through the invoice rate
// within 1 %. An unreadable voucher (no 1510 credit, no settlement debit) or
// a rate-less foreign invoice cannot be verified and is left to a human.
const settlementSek = voucher.settlement_sek
if (settlementSek === undefined || settlementSek === null) {
return { kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: [voucher.id] }
}
const currency = invoice.currency ?? 'SEK'
if (currency === 'SEK') {
if (Math.abs(settlementSek - paidAmount) > 0.5) {
return { kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: [voucher.id] }
}
} else {
const rate = Number(invoice.exchange_rate ?? 0)
if (!(rate > 0)) {
return { kind: 'skip', reason: 'voucher_amount_unverifiable', voucherIds: [voucher.id] }
}
if (Math.abs(settlementSek / rate - paidAmount) > paidAmount * 0.01) {
return { kind: 'skip', reason: 'voucher_amount_mismatch', voucherIds: [voucher.id] }
}
}
// The voucher's entry_date is the affärshändelse date (BFL 5 kap 7 §) and
// is what the settle paths stamp from the user's payment date. paid_at is
// NOT usable: before 2026-08-02 (#1332) it was the wall-clock registration
// time, so a payment booked in January for a December date would land in
// the wrong year. The two agree for every row written since.
const paymentDate = voucher.entry_date
if (options.isPeriodClosed?.(paymentDate)) {
return { kind: 'skip', reason: 'period_closed', voucherIds: [voucher.id] }
}
return {
kind: 'insert',
row: {
user_id: invoice.user_id,
company_id: invoice.company_id,
invoice_id: invoice.id,
payment_date: paymentDate,
amount: roundOre(paidAmount),
currency: invoice.currency ?? 'SEK',
exchange_rate: invoice.exchange_rate ?? null,
journal_entry_id: voucher.id,
transaction_id: null,
notes: `${BACKFILL_NOTES_TAG} Markera som betald utan betalningsrad; verifikat ${voucher.id}`,
},
}
}
+10 -4
View File
@@ -204,13 +204,16 @@ export async function detectDuplicatePaymentVoucher(
if (candidates.length === 0) return null
// Exclude entries already linked from invoice_payments or any transaction.
// Exclude entries already linked to a bank transaction, directly or through
// an invoice_payments row that carries one. A payment row with
// transaction_id NULL is a manual / Stripe settlement (#2019): its bank line
// has not been matched yet, so the voucher stays a duplicate candidate.
const entryIds = candidates.map((l) => l.journal_entry.id)
const [{ data: paymentLinks }, { data: txLinks }] = await Promise.all([
supabase
.from('invoice_payments')
.select('journal_entry_id')
.select('journal_entry_id, transaction_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
supabase
@@ -221,8 +224,11 @@ export async function detectDuplicatePaymentVoucher(
])
const linkedIds = new Set<string>()
for (const row of (paymentLinks ?? []) as { journal_entry_id: string | null }[]) {
if (row.journal_entry_id) linkedIds.add(row.journal_entry_id)
for (const row of (paymentLinks ?? []) as {
journal_entry_id: string | null
transaction_id: string | null
}[]) {
if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id)
}
for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) {
// A transaction can link to its own JE via the current match flow: but
+111
View File
@@ -0,0 +1,111 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
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.
*
* 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.
*
* Shape mirrors the bank-match path (app/api/transactions/[id]/match-invoice):
* amount in INVOICE currency, transaction_id null. 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
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
}
export type RecordInvoicePaymentRowResult =
| { ok: true; id: string }
| { ok: false; error: string }
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 { 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: invoice.exchange_rate ?? null,
journal_entry_id: journalEntryId,
transaction_id: null,
notes: null,
})
.select('id')
.single()
if (error || !data) {
return { ok: false, error: error?.message ?? 'no_row_returned' }
}
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
}
}
+43 -3
View File
@@ -10,6 +10,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { recordInvoicePaymentRow, removeInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { eventBus } from '@/lib/events'
import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/types'
@@ -23,9 +24,13 @@ import type { CreateJournalEntryInput, Customer, EntityType, Invoice } from '@/t
* 1. planInvoicePayment: ledger math + overpayment guard
* 2. journal entry: custom lines | cash entry (kontantmetoden, unbooked) |
* payment entry (clears 1510), fail-closed for real invoices
* 3. CAS-guarded invoice status update; a lost race or failed update cancels
* the just-posted voucher so GL and sub-ledger never diverge
* 4. invoice.paid event (best-effort)
* 3. invoice_payments row (the AR sub-ledger): the only source of the
* payment DATE, which the kontantmetod bokslut cut-off, the voucher ->
* invoice reference map and the "Betalningar" view all read (#2019)
* 4. CAS-guarded invoice status update; a lost race or failed update cancels
* the just-posted voucher and removes the payment row so GL and
* sub-ledger never diverge
* 5. invoice.paid event (best-effort)
*
* `settlementAccountNumber` routes the debit side: default 1930 (bank), 1686
* for PSP-balance settlements (Stripe) where the money reaches the bank only
@@ -272,6 +277,39 @@ export async function settleInvoicePayment(
}
}
// Sub-ledger row (see lib/invoices/invoice-payment-row.ts for why and for
// the shape). Written BEFORE the CAS update so the failure branches below
// can undo it together with the voucher; a real invoice never reaches paid
// through this service without it.
let paymentRowId: string | null = null
if (isRealInvoice) {
const recorded = await recordInvoicePaymentRow(supabase, {
userId,
companyId,
invoice,
paymentDate,
newPaidAmount,
journalEntryId,
})
if (!recorded.ok) {
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase,
companyId,
userId,
journalEntryId,
'Automatiskt makulerad: betalningsraden kunde inte sparas efter bokförd betalning',
)
}
return {
ok: false,
code: 'INVOICE_PAID_BOOK_FAILED',
details: { reason: 'payment_row_insert_failed', error: recorded.error },
}
}
paymentRowId = recorded.id
}
// CAS guard: only update if status is still in a payable state.
const { data: updateResult, error: updateError } = await supabase
.from('invoices')
@@ -289,6 +327,7 @@ export async function settleInvoicePayment(
if (updateError) {
// The payment voucher already posted but the invoice row did not flip to
// paid; cancel the orphan so the GL doesn't diverge from the sub-ledger.
await removeInvoicePaymentRow(supabase, companyId, paymentRowId)
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase,
@@ -304,6 +343,7 @@ export async function settleInvoicePayment(
if (!updateResult || updateResult.length === 0) {
// Status changed between read and write (concurrent settle): cancel the
// orphaned payment voucher; the trigger documents the voucher gap.
await removeInvoicePaymentRow(supabase, companyId, paymentRowId)
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase,
@@ -122,6 +122,7 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () =>
error: null,
}) // invoice fetch
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert (#2019)
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
enqueue({ data: null, error: null }) // dispatcher pending_operations update
@@ -157,6 +158,15 @@ describe('commitPendingOperation: mark_invoice_paid state + invoice.paid', () =>
// transaction, so nothing is excluded.
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
expect(mockClearSuggestions).toHaveBeenCalledWith(supabase, 'company-1', 'invoice', 'inv-1')
// #2019: the AR sub-ledger row is what the kontantmetod cut-off reads.
const paymentInserts = findCalls('invoice_payments', 'insert')
expect(paymentInserts).toHaveLength(1)
expect(paymentInserts[0][0]).toMatchObject({
user_id: 'user-1',
company_id: 'company-1',
invoice_id: 'inv-1',
transaction_id: null,
})
})
// No partial-payment counterpart here: this executor always settles the full
+45
View File
@@ -97,6 +97,7 @@ import {
type LinkSupplierInvoiceToVoucherResult,
} from '@/lib/invoices/supplier-voucher-matching'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { recordInvoicePaymentRow, removeInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import {
clearSettledBatchAllocationSuggestions,
@@ -2662,6 +2663,48 @@ async function commitMarkInvoicePaid(
}
}
// AR sub-ledger row (#2019): the kontantmetod cut-off reads invoice_payments
// only, so a paid invoice without it is re-booked as a fordran at bokslut.
// Written before the CAS update so the failure branches undo it with the
// voucher. See lib/invoices/invoice-payment-row.ts.
let paymentRowId: string | null = null
if (isRealInvoice) {
const recorded = await recordInvoicePaymentRow(supabase, {
userId,
companyId,
invoice: {
id: invoiceId,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
paid_amount: inv.paid_amount,
},
paymentDate,
newPaidAmount,
journalEntryId,
})
if (!recorded.ok) {
// Raw driver text stays server-side; the user gets the outcome only.
log.error('mark_invoice_paid: invoice_payments insert failed', undefined, {
invoiceId,
companyId,
error: recorded.error,
})
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase, companyId, userId, journalEntryId,
'Automatiskt makulerad: betalningsraden kunde inte sparas efter bokförd betalning',
)
}
return {
error:
'Betalningen kunde inte registreras i reskontran. ' +
'Verifikationen har makulerats och fakturan har inte markerats som betald.',
status: 500,
}
}
paymentRowId = recorded.id
}
const paidAt = newStatus === 'paid' ? paidAtFromDate(paymentDate) : null
// CAS guard: only flip from a payable status so a concurrently-settled
// invoice no-ops here instead of double-booking the payment.
@@ -2681,6 +2724,7 @@ async function commitMarkInvoicePaid(
if (updateError) {
// The payment voucher already posted but the invoice row did not flip;
// cancel the orphan so the GL doesn't diverge from the sub-ledger.
await removeInvoicePaymentRow(supabase, companyId, paymentRowId)
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase, companyId, userId, journalEntryId,
@@ -2694,6 +2738,7 @@ async function commitMarkInvoicePaid(
// Race lost: the invoice was settled concurrently between our read and
// write. Cancel the orphaned payment voucher and document the gap rather
// than leaving a double booking.
await removeInvoicePaymentRow(supabase, companyId, paymentRowId)
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
supabase, companyId, userId, journalEntryId,
+1
View File
@@ -57,6 +57,7 @@ export const PROCESSING_EVENT_TYPES = [
'InboxUnderlagReconciled',
'InvoiceDuplicatePaymentDismissed',
'InvoiceJournalEntrySkipped',
'InvoicePaymentRowBackfilled',
'OAuthClientRevoked',
'PendingOperationApproved',
'PendingOperationRejected',
@@ -447,7 +447,7 @@ function makeLedgerSupabase(opts: {
ledgerAccount?: string | null
lines?: Jel[]
txLinks?: LedgerTxLink[]
payLinks?: { journal_entry_id: string }[]
payLinks?: { journal_entry_id: string; transaction_id?: string | null }[]
transactionRows?: TxRow[] // siblings for the orchestrator fall-through
}) {
// Fixtures stay embed-shaped for readability; the two-step fetch reads the
@@ -598,13 +598,31 @@ describe('detectLedgerDuplicateVoucher', () => {
})
it('excludes a voucher already linked to an invoice payment', async () => {
const supabase = makeLedgerSupabase({ lines: [jel()], payLinks: [{ journal_entry_id: 'je-2' }] })
const supabase = makeLedgerSupabase({
lines: [jel()],
payLinks: [{ journal_entry_id: 'je-2', transaction_id: 'tx-bank' }],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result).toBeNull()
})
// #2019: a manual / Stripe settlement writes a payment row with no bank
// transaction. The bank line for that money arrives later; the voucher must
// stay a twin so the user is offered a link instead of a blind re-booking.
it('keeps a voucher whose payment row has no bank transaction as a twin', async () => {
const supabase = makeLedgerSupabase({
lines: [jel()],
payLinks: [{ journal_entry_id: 'je-2', transaction_id: null }],
})
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
})
expect(result?.journal_entry_id).toBe('je-2')
expect(result?.transaction_id).toBeNull()
})
// ── De-exclusion (gap G3): the linking transaction is itself the twin ─────
it('returns a voucher whose linking transaction itself matches the target, with transaction_id set', async () => {
// The date-drifted duplicate-import shape: one copy of the bank fee is
@@ -683,7 +683,11 @@ export async function detectLedgerDuplicateVoucher(
.select('id, date, amount, currency, cash_account_id, journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
supabase.from('invoice_payments').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
supabase
.from('invoice_payments')
.select('journal_entry_id, transaction_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
])
type LinkedTxRow = {
@@ -712,9 +716,14 @@ export async function detectLedgerDuplicateVoucher(
arr.push(r)
linkedTxByEntry.set(r.journal_entry_id, arr)
}
// Only a payment row that carries a bank transaction means "reconciled to
// a bank line". A row with transaction_id NULL is a manual / Stripe
// settlement (#2019): the bank line for that money is still to come, and
// this voucher must stay a twin candidate so the user is offered a link
// instead of a blind second booking.
const paymentLinked = new Set<string>()
for (const r of (payLinks ?? []) as { journal_entry_id: string | null }[]) {
if (r.journal_entry_id) paymentLinked.add(r.journal_entry_id)
for (const r of (payLinks ?? []) as { journal_entry_id: string | null; transaction_id: string | null }[]) {
if (r.journal_entry_id && r.transaction_id) paymentLinked.add(r.journal_entry_id)
}
const survivors: { line: (typeof candidates)[number]; twinTransactionId: string | null }[] = []