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:
@@ -1527,6 +1527,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact.
|
||||
[2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191.
|
||||
[2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client.
|
||||
[2026-09-03] settleInvoicePayment writes the invoice_payments row BEFORE the CAS status update and removes it in both failure branches, instead of inserting after the update: the kontantmetod cut-off reads invoice_payments only, so a paid invoice without a row is the #2019 defect itself; failing closed on the insert (voucher storno + INVOICE_PAID_BOOK_FAILED) keeps GL, sub-ledger and invoice status in step. The #2019 backfill inserts only where exactly one posted payment voucher exists (invoice_paid / invoice_cash_payment with source_id = invoice); zero or several vouchers are reported, never guessed, and every row is tagged backfill:#2019 in notes so one DELETE reverts a run.
|
||||
[2026-09-03] #2019 skeptic round: the invoice_payments row is written by one helper (lib/invoices/invoice-payment-row.ts) from all four transaction-less settlement paths (dashboard, v1, MCP mark-paid, Stripe), with amount = applied amount (new paid_amount minus prior) rather than cash received, so a 3740 öre absorption never produces a negative fordran in the cut-off or a wrong storno restore. A payment row with transaction_id NULL does NOT count as "reconciled to a bank line" in the two duplicate detectors: the bank line for a manual settlement arrives later and the voucher must still be offered as a twin. The backfill dates rows from the voucher entry_date (paid_at was wall-clock before #1332), refuses rows that disagree with the voucher's 1510 credit / settlement debit, reports partially covered invoices (rows_short) instead of patching them, and records each executed run in behandlingshistorik (new event type InvoicePaymentRowBackfilled, migration 20260903180000). Not done here, pre-existing: the bank-match and pending-operations match paths still store cash received as the row amount, and the kontantmetod cut-off ignores ROT/RUT deduction_total (1513 share shows as outstanding); both filed as follow-ups.
|
||||
[2026-09-03] Enable Banking ASPSP_ERROR ladder (#2202): the widest history window a bank has answered is stored per account as accounts_data[].accepted_history_days (no migration; same write-back as dedup_scope and the balance), not as an absolute date, because a bank's limit is a width from today and an absolute known-good date would age into a genuine width rejection. On a rejected window: no wider than accepted = bank unavailable after ONE call; wider = one retry at the accepted width, then unavailable (was up to five calls). AspspUnavailableError maps to 503 BANK_UNAVAILABLE on the web sync route with copy that says the connection does not need renewing; the agent path keeps the contract code BANK_SYNC_FAILED (adding a code touches core contract + structured-errors + v1 docs, left for a follow-up) but no longer persists renewal advice. The envelope's `detail` field is NOT used as a signal: one sample, "Unknown error", identical to a width rejection. A narrowed sync now returns history_from so the UI can say from which date it is complete.
|
||||
[2026-09-03] KPI monthly breakdown counts reversed originals (#2201): the monthly section of get_kpi_report_aggregates (new migration 20260903160000) and lib/reports/monthly-breakdown.ts now use tb_ex_year_end's entry set verbatim (posted + reversed, minus the undone year-end chain) instead of posted-only. A same-year storno then cancels inside the months as it does in the year total, so sum(months) = Nettoresultat; the reversal shows as negative revenue in its own month, which is the honest month view. The pg-real pin "in tb, not in monthly" was flipped, not worked around. Unblocks the per-month sum on Nyckeltal (#2196).
|
||||
[2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903173000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error.
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
@@ -168,6 +168,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// Fetch company settings (now before update due to journal-first ordering)
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
@@ -267,6 +268,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' })
|
||||
@@ -318,6 +320,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// Fetch company settings (before update, journal-first ordering)
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -460,6 +463,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Guard query is SKIPPED because force=true short-circuits the check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-force' })
|
||||
@@ -488,6 +492,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// No guard query enqueued: guard is skipped for partial payments
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -535,6 +540,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -582,6 +588,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -658,6 +665,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -869,6 +877,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-auto' })
|
||||
@@ -917,6 +926,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// No duplicate-guard probes enqueued: 500 EUR < 1 000 EUR remaining, so the
|
||||
// guard is skipped entirely.
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
@@ -1095,6 +1105,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-eur-full' })
|
||||
@@ -1125,4 +1136,48 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(body.error.code).toBe('INVOICE_QUOTE_NOT_PAYABLE')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Issue #2019: the manual flow flipped the invoice to paid without an
|
||||
// invoice_payments row, so the kontantmetod cut-off saw no payment date and
|
||||
// re-booked the paid invoice as a fordran at bokslut.
|
||||
it('records the manual payment in invoice_payments with the voucher and no bank transaction', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
currency: 'SEK',
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // duplicate guard: merchant_name
|
||||
enqueue({ data: [], error: null }) // duplicate guard: description
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-cash' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-08-28' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
const inserts = findCalls('invoice_payments', 'insert')
|
||||
expect(inserts).toHaveLength(1)
|
||||
expect(inserts[0][0]).toMatchObject({
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
invoice_id: 'inv-1',
|
||||
payment_date: '2026-08-28',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
journal_entry_id: 'je-cash',
|
||||
transaction_id: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,6 +168,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}, calls),
|
||||
)
|
||||
|
||||
@@ -202,6 +203,23 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
)
|
||||
const invoiceUpdate = calls.find((call) => call.table === 'invoices' && call.method === 'update')
|
||||
expect(invoiceUpdate?.args[0]).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
|
||||
// #2019: the AR sub-ledger row (what the kontantmetod cut-off reads) is
|
||||
// written with the voucher and no bank transaction, before the update.
|
||||
const paymentInsert = calls.find(
|
||||
(call) => call.table === 'invoice_payments' && call.method === 'insert',
|
||||
)
|
||||
expect(paymentInsert?.args[0]).toMatchObject({
|
||||
user_id: USER_ID,
|
||||
company_id: COMPANY_ID,
|
||||
invoice_id: INVOICE_ID,
|
||||
payment_date: '2026-05-12',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
journal_entry_id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj',
|
||||
transaction_id: null,
|
||||
})
|
||||
expect(calls.findIndex((c) => c.table === 'invoice_payments' && c.method === 'insert'))
|
||||
.toBeLessThan(calls.findIndex((c) => c.table === 'invoices' && c.method === 'update'))
|
||||
// Issue #1259: the invoice is settled, so no transaction may keep pointing
|
||||
// at it as a match suggestion.
|
||||
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
|
||||
@@ -222,6 +240,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -248,6 +267,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
@@ -289,6 +309,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -318,6 +339,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
@@ -349,6 +371,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -376,6 +399,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -421,6 +445,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: { ...ROT_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 86800, paid_at: '2026-08-29T12:00:00Z' }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -468,6 +493,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: BOOKED_ROT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -506,6 +532,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: ROT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -552,6 +579,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: { ...ORE_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 1234.75 }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
@@ -627,6 +655,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -674,6 +703,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
@@ -715,6 +745,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
// transactions queue not consulted: force=true short-circuits the guard
|
||||
}),
|
||||
)
|
||||
@@ -748,6 +779,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
@@ -854,6 +886,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
},
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
@@ -916,6 +949,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: EUR_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
transactions: {
|
||||
// In kronor: the candidate lookup scans transactions.amount, which is SEK.
|
||||
data: [
|
||||
@@ -961,6 +995,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: { ...EUR_INVOICE, exchange_rate: null, total_sek: null }, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1003,6 +1038,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
},
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
invoice_payments: { data: { id: 'ip-1' }, error: null },
|
||||
},
|
||||
calls,
|
||||
),
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { eventBus } from '@/lib/events'
|
||||
@@ -48,6 +49,7 @@ 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 { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
@@ -543,7 +545,46 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: update the invoice row.
|
||||
// Step 2: 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(ctx.supabase, {
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
invoice: typed,
|
||||
paymentDate,
|
||||
newPaidAmount,
|
||||
journalEntryId,
|
||||
})
|
||||
if (!recorded.ok) {
|
||||
ctx.log.error('mark-paid: invoice_payments insert failed: cancelling the payment voucher', undefined, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
error: recorded.error,
|
||||
})
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: betalningsraden kunde inte sparas efter bokförd betalning',
|
||||
)
|
||||
}
|
||||
// The raw driver text stays in the server log above; API callers get
|
||||
// the reason code only.
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'payment_row_insert_failed' },
|
||||
})
|
||||
}
|
||||
paymentRowId = recorded.id
|
||||
}
|
||||
|
||||
// Step 3: update the invoice row.
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
@@ -571,6 +612,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
.maybeSingle()
|
||||
|
||||
if (updateErr) {
|
||||
await removeInvoicePaymentRow(ctx.supabase, ctx.companyId!, paymentRowId)
|
||||
ctx.log.error('mark-paid: invoice update failed', updateErr as Error, {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
@@ -583,6 +625,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
if (!updated) {
|
||||
// Race: status transitioned (concurrent mark-paid / credit) between
|
||||
// pre-flight and our update. Surface as 409.
|
||||
await removeInvoicePaymentRow(ctx.supabase, ctx.companyId!, paymentRowId)
|
||||
ctx.log.warn('mark-paid: race: invoice status transitioned during request', {
|
||||
invoiceId,
|
||||
companyId: ctx.companyId,
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }[] = []
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Backfill for issue #2019: customer invoices settled through "Markera som
|
||||
* betald" (or the Stripe payment sync) before settleInvoicePayment wrote the
|
||||
* invoice_payments row.
|
||||
*
|
||||
* Why it matters: the kontantmetod bokslut cut-off reads invoice_payments
|
||||
* ONLY (payment DATE, not remaining_amount), so a paid invoice without a row
|
||||
* is re-booked as a fordran with vilande moms at year end, double-counting
|
||||
* revenue and VAT. The same gap hides the payment from the "Betalningar"
|
||||
* view and from the voucher -> invoice reference map.
|
||||
*
|
||||
* What it writes: one row per invoice, amount = paid_amount in invoice
|
||||
* currency, payment_date = the payment voucher's entry_date (paid_at was the
|
||||
* wall-clock registration time before 2026-08-02 and is not trusted),
|
||||
* journal_entry_id = the single posted payment voucher, transaction_id NULL,
|
||||
* notes tagged `backfill:#2019` so the whole run reverts with one statement:
|
||||
*
|
||||
* DELETE FROM invoice_payments WHERE notes LIKE 'backfill:#2019%';
|
||||
*
|
||||
* That revert is an emergency path for the window in which nothing has
|
||||
* relied on the rows yet (BFL 5 kap 5 §). Rows are never written into a
|
||||
* closed or locked fiscal period (see period_closed below), and once a
|
||||
* bokslut cut-off has been posted from them the correction path is a storno
|
||||
* of that cut-off verifikat, not a DELETE of the sub-ledger rows.
|
||||
*
|
||||
* Never guesses (lib/invoices/backfill-invoice-payment-rows.ts): an invoice
|
||||
* with zero or several posted payment vouchers is listed, not written, and
|
||||
* so is one whose existing rows sum to less than paid_amount (a pre-fix
|
||||
* manual partial next to a bank-matched one) or whose voucher booked a
|
||||
* different amount than the header says, or whose payment date falls in a
|
||||
* closed or locked fiscal period (a filed bokslut or deklaration may rely on
|
||||
* that year's cut-off). Idempotent: invoices whose rows cover paid_amount
|
||||
* are excluded.
|
||||
*
|
||||
* Every executed run is recorded in behandlingshistorik (one
|
||||
* InvoicePaymentRowBackfilled event per company, BFL 5 kap 11 §): the rows
|
||||
* feed the bokslut cut-off, so the bulk write is a change to processing.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/backfill-invoice-payment-rows.ts # dry-run (default)
|
||||
* npx tsx scripts/backfill-invoice-payment-rows.ts --execute # apply
|
||||
* npx tsx scripts/backfill-invoice-payment-rows.ts --company <id> # one company only
|
||||
* npx tsx scripts/backfill-invoice-payment-rows.ts --verbose # list every skipped invoice
|
||||
*
|
||||
* DRY-RUN IS THE DEFAULT. Point NEXT_PUBLIC_SUPABASE_URL /
|
||||
* SUPABASE_SERVICE_ROLE_KEY (.env.local) at staging first; prod only after
|
||||
* explicit confirmation. Service role bypasses RLS but not the
|
||||
* payment_company_consistency trigger, so a row can never land on the wrong
|
||||
* tenant.
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { appendProcessingHistoryWithClient } from '@/lib/processing-history/append'
|
||||
import {
|
||||
BACKFILL_NOTES_TAG,
|
||||
PAYMENT_VOUCHER_SOURCE_TYPES,
|
||||
planInvoicePaymentBackfill,
|
||||
settlementSekFromLines,
|
||||
type BackfillInvoice,
|
||||
type BackfillPaymentRow,
|
||||
type BackfillSkipReason,
|
||||
type BackfillVoucher,
|
||||
type ExistingPaymentRows,
|
||||
} from '@/lib/invoices/backfill-invoice-payment-rows'
|
||||
|
||||
const EXECUTE = process.argv.includes('--execute')
|
||||
const VERBOSE = process.argv.includes('--verbose')
|
||||
const companyArgIndex = process.argv.indexOf('--company')
|
||||
const COMPANY_FILTER = companyArgIndex >= 0 ? process.argv[companyArgIndex + 1] : null
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
||||
process.exit(1)
|
||||
}
|
||||
if (companyArgIndex >= 0 && !COMPANY_FILTER) {
|
||||
console.error('--company needs a company id')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
||||
|
||||
const CHUNK = 200
|
||||
const INSERT_BATCH = 100
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Target: ${supabaseUrl}`)
|
||||
console.log(EXECUTE ? 'MODE: EXECUTE (writing)' : 'MODE: dry-run (no writes)')
|
||||
if (COMPANY_FILTER) console.log(`Company filter: ${COMPANY_FILTER}`)
|
||||
|
||||
// 1. Candidate invoices: paid or partially paid with money received.
|
||||
const invoices = await fetchAllRows<BackfillInvoice>(({ from, to }) => {
|
||||
let q = supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, company_id, user_id, invoice_number, status, document_type, currency, exchange_rate, paid_amount, paid_at',
|
||||
)
|
||||
.in('status', ['paid', 'partially_paid'])
|
||||
.gt('paid_amount', 0)
|
||||
if (COMPANY_FILTER) q = q.eq('company_id', COMPANY_FILTER)
|
||||
return q.order('id', { ascending: true }).range(from, to)
|
||||
})
|
||||
console.log(`Paid / partially paid invoices with paid_amount > 0: ${invoices.length}`)
|
||||
|
||||
const invoiceIds = invoices.map((i) => i.id)
|
||||
|
||||
// 2. Existing sub-ledger rows and posted payment vouchers, per invoice.
|
||||
const existingRows = new Map<string, ExistingPaymentRows>()
|
||||
const vouchersByInvoice = new Map<string, BackfillVoucher[]>()
|
||||
for (const ids of chunk(invoiceIds, CHUNK)) {
|
||||
const rows = await fetchAllRows<{ id: string; invoice_id: string; amount: number | null }>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('id, invoice_id, amount')
|
||||
.in('invoice_id', ids)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
for (const r of rows) {
|
||||
const acc = existingRows.get(r.invoice_id) ?? { count: 0, sum: 0 }
|
||||
acc.count += 1
|
||||
acc.sum = roundOre(acc.sum + Number(r.amount ?? 0))
|
||||
existingRows.set(r.invoice_id, acc)
|
||||
}
|
||||
|
||||
const vouchers = await fetchAllRows<BackfillVoucher>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, source_id, source_type, status, entry_date')
|
||||
.in('source_id', ids)
|
||||
.in('source_type', [...PAYMENT_VOUCHER_SOURCE_TYPES])
|
||||
.eq('status', 'posted')
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
const voucherIds = vouchers.map((v) => v.id)
|
||||
const lines = voucherIds.length
|
||||
? await fetchAllRows<{
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number | null
|
||||
credit_amount: number | null
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', voucherIds)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
: []
|
||||
const linesByVoucher = new Map<string, typeof lines>()
|
||||
for (const l of lines) {
|
||||
const list = linesByVoucher.get(l.journal_entry_id) ?? []
|
||||
list.push(l)
|
||||
linesByVoucher.set(l.journal_entry_id, list)
|
||||
}
|
||||
for (const v of vouchers) {
|
||||
if (!v.source_id) continue
|
||||
v.settlement_sek = settlementSekFromLines(linesByVoucher.get(v.id) ?? [])
|
||||
const list = vouchersByInvoice.get(v.source_id) ?? []
|
||||
list.push(v)
|
||||
vouchersByInvoice.set(v.source_id, list)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Closed / locked fiscal periods per company: a row dated into one is
|
||||
// reported, never written.
|
||||
const companyIds = Array.from(new Set(invoices.map((i) => i.company_id)))
|
||||
const closedRanges = new Map<string, Array<{ start: string; end: string }>>()
|
||||
for (const ids of chunk(companyIds, CHUNK)) {
|
||||
const periods = await fetchAllRows<{
|
||||
company_id: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
is_closed: boolean
|
||||
locked_at: string | null
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('company_id, period_start, period_end, is_closed, locked_at')
|
||||
.in('company_id', ids)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
for (const fp of periods) {
|
||||
if (!fp.is_closed && !fp.locked_at) continue
|
||||
const list = closedRanges.get(fp.company_id) ?? []
|
||||
list.push({ start: fp.period_start, end: fp.period_end })
|
||||
closedRanges.set(fp.company_id, list)
|
||||
}
|
||||
}
|
||||
const isPeriodClosedFor = (companyId: string) => (date: string) =>
|
||||
(closedRanges.get(companyId) ?? []).some((r) => date >= r.start && date <= r.end)
|
||||
|
||||
// 4. Plan.
|
||||
const toInsert: Array<{ invoice: BackfillInvoice; row: BackfillPaymentRow }> = []
|
||||
const skipped = new Map<BackfillSkipReason, BackfillInvoice[]>()
|
||||
for (const invoice of invoices) {
|
||||
const plan = planInvoicePaymentBackfill(
|
||||
invoice,
|
||||
vouchersByInvoice.get(invoice.id) ?? [],
|
||||
existingRows.get(invoice.id) ?? { count: 0, sum: 0 },
|
||||
{ isPeriodClosed: isPeriodClosedFor(invoice.company_id) },
|
||||
)
|
||||
if (plan.kind === 'insert') {
|
||||
toInsert.push({ invoice, row: plan.row })
|
||||
} else {
|
||||
const list = skipped.get(plan.reason) ?? []
|
||||
list.push(invoice)
|
||||
skipped.set(plan.reason, list)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log(`Rows to insert: ${toInsert.length}`)
|
||||
const perCompany = new Map<string, number>()
|
||||
for (const { row } of toInsert) perCompany.set(row.company_id, (perCompany.get(row.company_id) ?? 0) + 1)
|
||||
for (const [companyId, n] of perCompany) console.log(` ${companyId}: ${n}`)
|
||||
for (const { invoice, row } of toInsert) {
|
||||
console.log(
|
||||
` + ${invoice.company_id} ${invoice.invoice_number ?? invoice.id} ` +
|
||||
`${row.amount} ${row.currency} on ${row.payment_date} -> ${row.journal_entry_id}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('Skipped:')
|
||||
for (const [reason, list] of skipped) {
|
||||
console.log(` ${reason}: ${list.length}`)
|
||||
// The reasons that need a human: a paid invoice with no voucher to hang
|
||||
// the row on, several vouchers whose split is unknown, or existing rows
|
||||
// that do not cover paid_amount. On prod the first group is dominated by
|
||||
// imported history (paid long before the company came here; thousands of
|
||||
// rows), so it is summarised per company unless --verbose asks for every
|
||||
// invoice.
|
||||
if (
|
||||
reason === 'no_payment_voucher' ||
|
||||
reason === 'multiple_payment_vouchers' ||
|
||||
reason === 'rows_short' ||
|
||||
reason === 'voucher_amount_mismatch' ||
|
||||
reason === 'voucher_amount_unverifiable' ||
|
||||
reason === 'period_closed'
|
||||
) {
|
||||
if (VERBOSE) {
|
||||
for (const inv of list) {
|
||||
console.log(` ${inv.company_id} ${inv.invoice_number ?? inv.id} (${inv.status}, paid ${inv.paid_amount})`)
|
||||
}
|
||||
} else {
|
||||
const byCompany = new Map<string, number>()
|
||||
for (const inv of list) byCompany.set(inv.company_id, (byCompany.get(inv.company_id) ?? 0) + 1)
|
||||
for (const [companyId, n] of byCompany) console.log(` ${companyId}: ${n}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!EXECUTE) {
|
||||
console.log('')
|
||||
console.log('Re-run with --execute to apply.')
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Write in batches. A unique-index collision (a row written between the
|
||||
// read and this insert) fails the batch loudly rather than being skipped.
|
||||
let inserted = 0
|
||||
for (const batch of chunk(toInsert, INSERT_BATCH)) {
|
||||
const { error } = await supabase.from('invoice_payments').insert(batch.map((b) => b.row))
|
||||
if (error) {
|
||||
console.error(`Insert failed after ${inserted} rows: ${error.code ?? ''} ${error.message}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
inserted += batch.length
|
||||
}
|
||||
console.log('')
|
||||
console.log(`Inserted ${inserted} row(s) tagged "${BACKFILL_NOTES_TAG}".`)
|
||||
console.log(`Rollback: DELETE FROM invoice_payments WHERE notes LIKE '${BACKFILL_NOTES_TAG}%';`)
|
||||
|
||||
// 6. Behandlingshistorik: one event per company naming every row written.
|
||||
// The rows feed the bokslut cut-off, so the run is a change to processing
|
||||
// (BFL 5 kap 11 §, BFNAR 2013:2 p. 9.16). Rows without their change-log
|
||||
// entry must not stay: if the append fails, that company's rows from this
|
||||
// run are deleted again (by id, tag-guarded) so data and audit trail move
|
||||
// together, and the company is listed for a re-run.
|
||||
const runId = randomUUID()
|
||||
const byCompany = new Map<string, Array<{ invoice: BackfillInvoice; row: BackfillPaymentRow }>>()
|
||||
for (const item of toInsert) {
|
||||
const list = byCompany.get(item.row.company_id) ?? []
|
||||
list.push(item)
|
||||
byCompany.set(item.row.company_id, list)
|
||||
}
|
||||
let appended = 0
|
||||
const rolledBack: string[] = []
|
||||
for (const [companyId, items] of byCompany) {
|
||||
try {
|
||||
await appendProcessingHistoryWithClient(supabase, {
|
||||
companyId,
|
||||
correlationId: runId,
|
||||
aggregateType: 'System',
|
||||
aggregateId: companyId,
|
||||
eventType: 'InvoicePaymentRowBackfilled',
|
||||
payload: {
|
||||
source: 'backfill-invoice-payment-rows',
|
||||
issue: 2019,
|
||||
notes_tag: BACKFILL_NOTES_TAG,
|
||||
row_count: items.length,
|
||||
invoice_ids: items.map((i) => i.invoice.id),
|
||||
journal_entry_ids: items.map((i) => i.row.journal_entry_id),
|
||||
},
|
||||
actor: { type: 'system', id: 'backfill-invoice-payment-rows' },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
appended += 1
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`processing_history append failed for company ${companyId}: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
)
|
||||
const { error: rollbackError } = await supabase
|
||||
.from('invoice_payments')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', items.map((i) => i.invoice.id))
|
||||
.like('notes', `${BACKFILL_NOTES_TAG}%`)
|
||||
if (rollbackError) {
|
||||
console.error(
|
||||
` rollback of ${items.length} row(s) for ${companyId} FAILED: ${rollbackError.message}. ` +
|
||||
`Delete by hand: DELETE FROM invoice_payments WHERE company_id = '${companyId}' AND notes LIKE '${BACKFILL_NOTES_TAG}%';`,
|
||||
)
|
||||
} else {
|
||||
console.error(` rolled back ${items.length} row(s) for ${companyId}; re-run the script for this company.`)
|
||||
rolledBack.push(companyId)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
console.log(`Behandlingshistorik: ${appended}/${byCompany.size} company event(s) appended (run ${runId}).`)
|
||||
if (rolledBack.length > 0) {
|
||||
console.log(`Rolled back (no audit event): ${rolledBack.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
-- Register the behandlingshistorik event type emitted by
|
||||
-- scripts/backfill-invoice-payment-rows.ts (#2019).
|
||||
--
|
||||
-- The backfill writes invoice_payments rows outside the normal settlement
|
||||
-- flow, and those rows feed the kontantmetod bokslut cut-off, so the run is a
|
||||
-- change to processing that BFL 5 kap 11 § / BFNAR 2013:2 p. 9.16 require in
|
||||
-- the change log. processing_history.event_type has an FK to this catalog and
|
||||
-- every append is best-effort, so an unregistered type would be lost silently
|
||||
-- (see lib/processing-history/append.ts and
|
||||
-- tests/pg/processing-event-types.pg.test.ts).
|
||||
|
||||
INSERT INTO public.processing_event_types (event_type)
|
||||
VALUES ('InvoicePaymentRowBackfilled')
|
||||
ON CONFLICT (event_type) DO NOTHING;
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* pg-real test for the invoice_payments rows written by settleInvoicePayment
|
||||
* (issue #2019: "Markera som betald" and the Stripe sync now record the
|
||||
* payment in the AR sub-ledger).
|
||||
*
|
||||
* Those rows carry transaction_id NULL because no bank line drives the flow.
|
||||
* The service relies on three database facts that only a real Postgres can
|
||||
* pin:
|
||||
*
|
||||
* 1. idx_invoice_payments_tx_inv_unique (transaction_id, invoice_id) treats
|
||||
* NULL as distinct, so several manual partials on one invoice coexist.
|
||||
* 2. idx_invoice_payments_je_inv_unique still refuses the same voucher
|
||||
* linked twice to the same invoice (the last line of defence against a
|
||||
* double settle).
|
||||
* 3. The authenticated writer can DELETE its own row: the CAS-failure
|
||||
* branch in settleInvoicePayment removes the row through the user
|
||||
* client, and a policy gap there would strand rows silently.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { insertPostedJournalEntry, seedCompany } from './fixtures'
|
||||
|
||||
async function seedCustomerInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
total?: number
|
||||
}): Promise<string> {
|
||||
const customerId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
|
||||
VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
|
||||
[customerId, params.userId, params.companyId],
|
||||
)
|
||||
const id = randomUUID()
|
||||
const total = params.total ?? 12500
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-08-01', '2026-08-31', 'SEK',
|
||||
$6, 0, $6, 'standard_25', 25, 'sent', 0, $6)`,
|
||||
[id, params.userId, params.companyId, customerId, `F-${id.slice(0, 8)}`, total],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function setActiveCompany(userId: string, companyId: string) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
}
|
||||
|
||||
const INSERT_MANUAL_PAYMENT = `
|
||||
INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
|
||||
journal_entry_id, transaction_id, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, 'SEK', NULL, $6, NULL, NULL)
|
||||
RETURNING id`
|
||||
|
||||
async function seedPaymentVoucher(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
invoiceId: string
|
||||
amount: number
|
||||
voucherNumber: number
|
||||
}): Promise<string> {
|
||||
return insertPostedJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
entryDate: '2026-08-28',
|
||||
description: 'Kontantbetalning kundfaktura',
|
||||
sourceType: 'invoice_cash_payment',
|
||||
sourceId: params.invoiceId,
|
||||
voucherNumber: params.voucherNumber,
|
||||
lines: [
|
||||
{ accountNumber: '1930', debitAmount: params.amount, creditAmount: 0 },
|
||||
{ accountNumber: '3001', debitAmount: 0, creditAmount: params.amount },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('invoice_payments rows without a bank transaction (#2019)', () => {
|
||||
it('lets several transaction-less payments coexist on one invoice', async () => {
|
||||
const seeded = await seedCompany()
|
||||
const invoiceId = await seedCustomerInvoice(seeded)
|
||||
const jeA = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 5000, voucherNumber: 31 })
|
||||
const jeB = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 7500, voucherNumber: 32 })
|
||||
|
||||
const first = await getPool().query(INSERT_MANUAL_PAYMENT, [
|
||||
seeded.userId, seeded.companyId, invoiceId, '2026-08-20', 5000, jeA,
|
||||
])
|
||||
const second = await getPool().query(INSERT_MANUAL_PAYMENT, [
|
||||
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 7500, jeB,
|
||||
])
|
||||
expect(first.rowCount).toBe(1)
|
||||
expect(second.rowCount).toBe(1)
|
||||
|
||||
const rows = await getPool().query<{ n: string; total: string }>(
|
||||
`SELECT count(*)::text AS n, sum(amount)::text AS total
|
||||
FROM public.invoice_payments WHERE invoice_id = $1 AND transaction_id IS NULL`,
|
||||
[invoiceId],
|
||||
)
|
||||
expect(rows.rows[0]).toEqual({ n: '2', total: '12500' })
|
||||
})
|
||||
|
||||
it('still refuses the same voucher linked twice to the same invoice', async () => {
|
||||
const seeded = await seedCompany()
|
||||
const invoiceId = await seedCustomerInvoice(seeded)
|
||||
const je = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 12500, voucherNumber: 41 })
|
||||
|
||||
await getPool().query(INSERT_MANUAL_PAYMENT, [
|
||||
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je,
|
||||
])
|
||||
await expect(
|
||||
getPool().query(INSERT_MANUAL_PAYMENT, [
|
||||
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je,
|
||||
]),
|
||||
).rejects.toThrow(/idx_invoice_payments_je_inv_unique/)
|
||||
})
|
||||
|
||||
it('lets the authenticated writer insert and delete its own row under RLS', async () => {
|
||||
const seeded = await seedCompany()
|
||||
await setActiveCompany(seeded.userId, seeded.companyId)
|
||||
const invoiceId = await seedCustomerInvoice(seeded)
|
||||
const je = await seedPaymentVoucher({ ...seeded, invoiceId, amount: 12500, voucherNumber: 51 })
|
||||
|
||||
const outcome = await withUserContext(seeded.userId, async (client) => {
|
||||
const inserted = await client.query<{ id: string }>(INSERT_MANUAL_PAYMENT, [
|
||||
seeded.userId, seeded.companyId, invoiceId, '2026-08-28', 12500, je,
|
||||
])
|
||||
const rowId = inserted.rows[0]?.id
|
||||
// The CAS-failure branch deletes by (id, company_id) through the user
|
||||
// client: the delete policy must let the row go.
|
||||
const deleted = await client.query(
|
||||
`DELETE FROM public.invoice_payments WHERE id = $1 AND company_id = $2`,
|
||||
[rowId, seeded.companyId],
|
||||
)
|
||||
return { inserted: inserted.rowCount, deleted: deleted.rowCount }
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({ inserted: 1, deleted: 1 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user