* 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>
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
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)
|
|
})
|
|
})
|