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:
co-authored by
Claude Fable 5.1
parent
3159920d7c
commit
e2d38b0ab3
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user