* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT. - /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut, refused to book, ready to request) and one row per begaran with mark uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor button links here, ?rot-rut=1 still opens the file dialog. - Beslutsfil import from the UI through the existing import route. - Reclaim of the share Skatteverket refused: one voucher debit 1510 / credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to the begaran and guarded by a partial unique index; the invoice reopens for the refused share via invoices.deduction_reclaimed_total, with the customer-share formula and its SQL twin gaining the same term. The payment dialog and bank match then settle the reopened remaining as a plain 1510 clearing; a booked kontantmetod invoice is proposed accrual- shaped so revenue is never recognised twice. Unknown per-invoice split of a partial beslut is refused, never allocated. - MCP: gnubok_list_rot_rut_payout_requests (search-only read) and gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout) sharing one pre-flight + settle with the dashboard match route. - Migrations 20260907140000 (reclaim state, source_type, INSERT guard), 20260907140100/140101 (pending_operations op type). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * chore(rot-rut): renumber migrations after merging main Main already carries 20260907143000 and 20260907150000, so the three rot-rut migrations move to 20260907160000/160100/160101 to keep the applied order monotonic (see memory: migration-version-collisions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): close the reclaim gaps found by skeptics, CI and review Skeptic refutations (#2397): - payment-sync recomputes remaining with deduction_reclaimed_total, so a storno of a payment on a reopened invoice no longer strands the refused share (R1). - Reclaim refused while an invoice sits in a later live begäran (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide the action for the same case (C2). - A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed (R2/C3). - Storno of the reclaim voucher syncs the invoices and the begäran back (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3). - A paid invoice with NULL paid_amount counts its customer share as paid (C4). Crediting an invoice with a reclaimed share is refused on the dashboard, v1 and MCP paths (R4). CI and review: - Build: custom-coded MCP errors via Object.assign, not codedError. - pg-real: column default for default_voucher_series_per_source_type re-stated with rot_rut_reclaim (20260907160200); the default test now re-applies the latest default migration. - Checks: accounted-api skill regenerated (journal-entries source types). - CodeRabbit/Superagent: per-item refused shares must reconcile with the request-level beslut; per-invoice reopen through the idempotent RPC apply_rot_rut_reclaim_invoice (20260907160300) with a resume path; update-stage settle failures keep the voucher id (failed_partial); Stockholm calendar date for the booking; existing-voucher tab uses the same proposal method; MCP stage checks bank_line junction rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut - The shared match outcome now declares journalEntryId on update-stage errors, matching the settle service (Core Build TS2339 on 2d6cece1a). - The reclaim voucher is dated on the Swedish calendar day of Skatteverkets beslut (decided_at), today only when no decision date is recorded, and the confirm dialog states the date (Swedish accounting review: BFL 5 kap 6-7 §, datum for affarshandelsen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column - apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300 signature) takes only the refused share, validates it against the locked item, request and invoice, and derives remaining_amount and status from the INSERT-guard formula (review: caller-supplied accounting values, CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher; the request link is cleared only after every leg. - v1 credit route projection includes deduction_reclaimed_total so the reclaim guard actually fires there. - Overview keeps "Bokfor nekat belopp" available while legs are pending (resume after a partial failure). - Match and settle routes attach journal_entry_id on update-stage errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
522 lines
21 KiB
TypeScript
522 lines
21 KiB
TypeScript
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
|
import { isPaymentSourceType, syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { JournalEntry } from '@/types'
|
|
|
|
/**
|
|
* A Supabase mock that records the table + method + args of every chained call
|
|
* (the shared createQueuedMockSupabase only records `from()` table names). Lets
|
|
* us assert on the actual UPDATE/DELETE payloads, which is what the reversal
|
|
* restore (remaining_amount reset, payment-row delete, tx release) hinges on.
|
|
*/
|
|
type RecordedCall = {
|
|
table: string
|
|
ops: Array<{ method: string; args: unknown[] }>
|
|
}
|
|
function createRecordingSupabase(queue: Array<{ data?: unknown; error?: unknown }>) {
|
|
const calls: RecordedCall[] = []
|
|
let i = 0
|
|
const from = vi.fn((table: string) => {
|
|
const result = queue[i++] ?? { data: null, error: null }
|
|
const rec: RecordedCall = { table, ops: [] }
|
|
calls.push(rec)
|
|
const chain: unknown = new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
|
|
return (...args: unknown[]) => {
|
|
rec.ops.push({ method: String(prop), args })
|
|
return chain
|
|
}
|
|
},
|
|
},
|
|
)
|
|
return chain
|
|
})
|
|
const updatePayload = (table: string): Record<string, unknown> | undefined => {
|
|
const rec = calls.find((c) => c.table === table && c.ops.some((o) => o.method === 'update'))
|
|
return rec?.ops.find((o) => o.method === 'update')?.args[0] as Record<string, unknown> | undefined
|
|
}
|
|
const tablesUpdated = (table: string) => calls.filter((c) => c.table === table && c.ops.some((o) => o.method === 'update'))
|
|
const wasDeleted = (table: string) => calls.some((c) => c.table === table && c.ops.some((o) => o.method === 'delete'))
|
|
return { supabase: { from } as never, calls, updatePayload, tablesUpdated, wasDeleted }
|
|
}
|
|
|
|
describe('isPaymentSourceType', () => {
|
|
it.each([
|
|
'invoice_paid',
|
|
'invoice_cash_payment',
|
|
'supplier_invoice_paid',
|
|
'supplier_invoice_cash_payment',
|
|
])('recognises %s as payment', (sourceType) => {
|
|
expect(isPaymentSourceType(sourceType)).toBe(true)
|
|
})
|
|
|
|
it.each(['manual', 'invoice_created', 'supplier_invoice_registered', '', null, undefined])(
|
|
'rejects %s',
|
|
(sourceType) => {
|
|
expect(isPaymentSourceType(sourceType)).toBe(false)
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('syncInvoiceStatusFromPaymentEntry', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
function entry(overrides: Partial<JournalEntry> = {}): Pick<JournalEntry, 'id' | 'source_type' | 'source_id'> {
|
|
return {
|
|
id: 'entry-1',
|
|
source_type: 'supplier_invoice_paid',
|
|
source_id: 'supplier-invoice-1',
|
|
...overrides,
|
|
} as Pick<JournalEntry, 'id' | 'source_type' | 'source_id'>
|
|
}
|
|
|
|
it('is a no-op when source_type is not a payment', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase as never,
|
|
'co-1',
|
|
entry({ source_type: 'manual' as JournalEntry['source_type'] })
|
|
)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('is a no-op when source_id is missing', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase as never,
|
|
'co-1',
|
|
entry({ source_id: null })
|
|
)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reverts a fully-paid supplier invoice back to approved', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: { amount: 1000 } },
|
|
// Fully paid before deletion: paid_amount === total
|
|
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } },
|
|
{ data: null }, // UPDATE result
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
|
|
|
|
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
// After the status update the helper now also deletes the stale payment row
|
|
// and releases any linked bank transaction back to the inbox.
|
|
expect(fromCalls).toEqual([
|
|
'supplier_invoice_payments', // select amount
|
|
'supplier_invoices', // select
|
|
'supplier_invoices', // update status/paid/remaining
|
|
'supplier_invoice_payments', // select transaction_id
|
|
'supplier_invoice_payments', // delete payment row
|
|
'transactions', // release linked bank line
|
|
])
|
|
})
|
|
|
|
it('reverts a partially-paid supplier invoice to partially_paid when paid_amount remains', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: { amount: 500 } }, // payment being reversed
|
|
// Started with 1000 paid (multiple payments), reversing 500
|
|
{ data: { paid_amount: 1000, total: 1500, due_date: '2099-12-31' } },
|
|
{ data: null },
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
|
|
|
|
// select payment, select invoice, update invoice, select payment tx,
|
|
// delete payment row, release linked transaction.
|
|
expect((supabase.from as ReturnType<typeof vi.fn>).mock.calls.length).toBe(6)
|
|
})
|
|
|
|
it('routes customer invoice entries through the invoices table', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: { amount: 1000 } },
|
|
{ data: { paid_amount: 1000, due_date: '2099-12-31' } },
|
|
{ data: null },
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase as never,
|
|
'co-1',
|
|
entry({ source_type: 'invoice_paid', source_id: 'invoice-1' })
|
|
)
|
|
|
|
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls).toEqual([
|
|
'invoice_payments', // select amount
|
|
'invoices', // select
|
|
'invoices', // update status/paid/remaining
|
|
'invoice_payments', // select transaction_id
|
|
'invoice_payments', // delete payment row
|
|
'transactions', // release linked bank line
|
|
])
|
|
})
|
|
|
|
it('handles invoice_cash_payment the same way as invoice_paid', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: { amount: 500 } },
|
|
{ data: { paid_amount: 500, due_date: '2099-12-31' } },
|
|
{ data: null },
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase as never,
|
|
'co-1',
|
|
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' })
|
|
)
|
|
|
|
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls[0]).toBe('invoice_payments')
|
|
expect(fromCalls[1]).toBe('invoices')
|
|
})
|
|
|
|
it('handles supplier_invoice_cash_payment the same way as supplier_invoice_paid', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: { amount: 1000 } },
|
|
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } },
|
|
{ data: null },
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase as never,
|
|
'co-1',
|
|
entry({ source_type: 'supplier_invoice_cash_payment' })
|
|
)
|
|
|
|
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
|
|
expect(fromCalls[0]).toBe('supplier_invoice_payments')
|
|
expect(fromCalls[1]).toBe('supplier_invoices')
|
|
})
|
|
|
|
it('does not error when no payment row exists for the supplier entry', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
enqueueMany([
|
|
{ data: null }, // no payment row
|
|
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } },
|
|
])
|
|
|
|
await expect(
|
|
syncInvoiceStatusFromPaymentEntry(supabase as never, 'co-1', entry())
|
|
).resolves.toBeUndefined()
|
|
})
|
|
|
|
// Regression for the stuck-invoice deadlock (F-2026080): reversing a cash
|
|
// payment left the invoice at status='paid' / remaining_amount=total because
|
|
// the customer branch never reset remaining_amount. The cash path has no
|
|
// invoice_payments row, so the full paid_amount is reverted.
|
|
it('customer cash-payment reversal resets paid_amount, remaining_amount and status', async () => {
|
|
const { supabase, updatePayload, wasDeleted } = createRecordingSupabase([
|
|
{ data: null }, // invoice_payments select amount → none (cash entry)
|
|
{ data: { paid_amount: 5212.5, total: 5212.5, due_date: '2099-12-31' } }, // invoices select
|
|
{ data: null }, // invoices update
|
|
{ data: [] }, // invoice_payments select transaction_id
|
|
{ data: null }, // invoice_payments delete
|
|
{ data: null }, // transactions update
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase,
|
|
'co-1',
|
|
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(updatePayload('invoices')).toEqual({
|
|
status: 'sent',
|
|
paid_at: null,
|
|
paid_amount: 0,
|
|
remaining_amount: 5212.5,
|
|
})
|
|
expect(wasDeleted('invoice_payments')).toBe(true)
|
|
})
|
|
|
|
// ROT/RUT: remaining_amount is the CUSTOMER share (total - deduction_total),
|
|
// as build-invoice-write stores it. Recomputing it gross on reversal used to
|
|
// inflate remaining to total, after which the net customer settlement could
|
|
// never reach it again and the invoice was permanently un-payable.
|
|
it('customer cash-payment reversal keeps remaining net of the ROT/RUT deduction', async () => {
|
|
const { supabase, updatePayload } = createRecordingSupabase([
|
|
{ data: null }, // invoice_payments select amount → none (cash entry)
|
|
{ data: { paid_amount: 86800, total: 124000, deduction_total: 37200, due_date: '2099-12-31' } }, // invoices select
|
|
{ data: null }, // invoices update
|
|
{ data: [] }, // invoice_payments select transaction_id
|
|
{ data: null }, // invoice_payments delete
|
|
{ data: null }, // transactions update
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase,
|
|
'co-1',
|
|
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(updatePayload('invoices')).toEqual({
|
|
status: 'sent',
|
|
paid_at: null,
|
|
paid_amount: 0,
|
|
remaining_amount: 86800,
|
|
})
|
|
})
|
|
|
|
// Partial reversal (clearing entry with a payment row): only the reversed
|
|
// amount comes off, remaining = total - newPaid, status stays partially_paid.
|
|
it('customer partial reversal keeps remaining_amount = total - newPaid', async () => {
|
|
const { supabase, updatePayload } = createRecordingSupabase([
|
|
{ data: { amount: 500 } }, // invoice_payments select amount
|
|
{ data: { paid_amount: 1500, total: 2000, due_date: '2099-12-31' } }, // invoices select
|
|
{ data: null }, // invoices update
|
|
{ data: [] }, // invoice_payments select transaction_id
|
|
{ data: null }, // invoice_payments delete
|
|
{ data: null }, // transactions update
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase,
|
|
'co-1',
|
|
entry({ source_type: 'invoice_paid', source_id: 'invoice-1' }),
|
|
)
|
|
|
|
expect(updatePayload('invoices')).toEqual({
|
|
status: 'partially_paid',
|
|
paid_at: null,
|
|
paid_amount: 1000,
|
|
remaining_amount: 1000,
|
|
})
|
|
})
|
|
|
|
// The bank line that paid the (now reversed) voucher must be detached so it
|
|
// returns to the inbox and is re-matchable: cleared both by journal_entry_id
|
|
// and by the transaction id captured from the payment row.
|
|
it('releases the linked bank transaction (clears journal_entry_id, invoice_id, category)', async () => {
|
|
const { supabase, tablesUpdated } = createRecordingSupabase([
|
|
{ data: null }, // invoice_payments select amount
|
|
{ data: { paid_amount: 5212.5, total: 5212.5, due_date: '2099-12-31' } }, // invoices select
|
|
{ data: null }, // invoices update
|
|
{ data: [{ transaction_id: 'tx-9' }] }, // invoice_payments select transaction_id
|
|
{ data: null }, // invoice_payments delete
|
|
{ data: null }, // transactions update by journal_entry_id
|
|
{ data: null }, // transactions update by id
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase,
|
|
'co-1',
|
|
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' }),
|
|
)
|
|
|
|
const txUpdates = tablesUpdated('transactions')
|
|
// Once by journal_entry_id, once by the captured payment transaction_id.
|
|
expect(txUpdates.length).toBe(2)
|
|
const resetPayload = txUpdates[0].ops.find((o) => o.method === 'update')?.args[0]
|
|
expect(resetPayload).toEqual({
|
|
journal_entry_id: null,
|
|
invoice_id: null,
|
|
is_business: null,
|
|
category: null,
|
|
})
|
|
// Second update targets the captured tx id.
|
|
const byId = txUpdates[1].ops.find((o) => o.method === 'in')
|
|
expect(byId?.args).toEqual(['id', ['tx-9']])
|
|
})
|
|
|
|
// Supplier-side parity: remaining_amount was already reset; now the payment
|
|
// row is deleted and the bank line released too.
|
|
it('supplier reversal deletes the payment row and releases the bank line', async () => {
|
|
const { supabase, updatePayload, wasDeleted, tablesUpdated } = createRecordingSupabase([
|
|
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
|
|
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
|
|
{ data: null }, // supplier_invoices update
|
|
{ data: [{ transaction_id: 'tx-7' }] }, // supplier_invoice_payments select transaction_id
|
|
{ data: null }, // supplier_invoice_payments delete
|
|
{ data: null }, // transactions update by journal_entry_id
|
|
{ data: null }, // transactions update by id
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase,
|
|
'co-1',
|
|
entry({ source_type: 'supplier_invoice_paid', source_id: 'supplier-invoice-1' }),
|
|
)
|
|
|
|
expect(updatePayload('supplier_invoices')).toMatchObject({
|
|
status: 'approved',
|
|
paid_amount: 0,
|
|
remaining_amount: 1000, // total - 0 paid = full amount owed again
|
|
})
|
|
expect(wasDeleted('supplier_invoice_payments')).toBe(true)
|
|
const resetPayload = tablesUpdated('transactions')[0].ops.find((o) => o.method === 'update')?.args[0]
|
|
expect(resetPayload).toEqual({
|
|
journal_entry_id: null,
|
|
supplier_invoice_id: null,
|
|
is_business: null,
|
|
category: null,
|
|
})
|
|
})
|
|
|
|
// Regression for the Greptile finding on PR #666: the supplier branch
|
|
// required a payment row before restoring status/amounts, so reversing a
|
|
// supplier_invoice_cash_payment (which books NO payment row: cash entries
|
|
// are only ever full payments) deleted nothing visible but left the invoice
|
|
// permanently at status='paid' / remaining_amount=0: the same deadlock the
|
|
// customer branch fix closed.
|
|
it('supplier cash-payment reversal restores status without a payment row', async () => {
|
|
const { supabase, updatePayload } = createRecordingSupabase([
|
|
{ data: null }, // supplier_invoice_payments select amount → none (cash entry)
|
|
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
|
|
{ data: null }, // supplier_invoices update
|
|
{ data: [] }, // supplier_invoice_payments select transaction_id
|
|
{ data: null }, // supplier_invoice_payments delete
|
|
{ data: null }, // transactions update by journal_entry_id
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase,
|
|
'co-1',
|
|
entry({ source_type: 'supplier_invoice_cash_payment', source_id: 'supplier-invoice-1' }),
|
|
)
|
|
|
|
expect(updatePayload('supplier_invoices')).toMatchObject({
|
|
status: 'approved',
|
|
paid_amount: 0,
|
|
remaining_amount: 1000,
|
|
paid_at: null,
|
|
payment_journal_entry_id: null,
|
|
})
|
|
})
|
|
|
|
// Regression: the supplier branch selected `total_amount`, a column
|
|
// supplier_invoices has never had (the real one is `total`). PostgREST
|
|
// rejected the whole select, so the restore was skipped while the payment-row
|
|
// delete and the bank-line release still ran: the invoice stayed 'paid' with
|
|
// a stale paid_amount and nothing behind it. Asserted on the projection
|
|
// string because a queued mock happily returns rows for columns that do not
|
|
// exist, which is how the bug survived the earlier tests.
|
|
it('selects supplier_invoices.total, never the non-existent total_amount', async () => {
|
|
const { supabase, calls } = createRecordingSupabase([
|
|
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
|
|
{ data: { paid_amount: 1000, total: 1000, due_date: '2099-12-31' } }, // supplier_invoices select
|
|
{ data: null }, // supplier_invoices update
|
|
{ data: [] }, // supplier_invoice_payments select transaction_id
|
|
{ data: null }, // supplier_invoice_payments delete
|
|
{ data: null }, // transactions update
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
|
|
|
|
const projection = calls
|
|
.find((c) => c.table === 'supplier_invoices')
|
|
?.ops.find((o) => o.method === 'select')?.args[0] as string
|
|
expect(projection).toBe('paid_amount, total, due_date')
|
|
expect(projection).not.toContain('total_amount')
|
|
})
|
|
|
|
// The state-level half of the same regression: with the wrong column the row
|
|
// carries no `total`, so remaining_amount was computed from undefined (NaN)
|
|
// and the AP ledger lost the amount still owed.
|
|
it('recomputes remaining_amount from total on a partial supplier reversal', async () => {
|
|
const { supabase, updatePayload } = createRecordingSupabase([
|
|
{ data: { amount: 500 } }, // supplier_invoice_payments select amount
|
|
{ data: { paid_amount: 1500, total: 2000, due_date: '2099-12-31' } }, // supplier_invoices select
|
|
{ data: null }, // supplier_invoices update
|
|
{ data: [] }, // supplier_invoice_payments select transaction_id
|
|
{ data: null }, // supplier_invoice_payments delete
|
|
{ data: null }, // transactions update
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
|
|
|
|
expect(updatePayload('supplier_invoices')).toMatchObject({
|
|
status: 'partially_paid',
|
|
paid_amount: 1000,
|
|
remaining_amount: 1000,
|
|
})
|
|
})
|
|
|
|
// If the supplier invoice cannot be read we do not know the state we are
|
|
// about to overwrite, so nothing destructive may run: deleting the payment
|
|
// row and releasing the bank line would strand the invoice on 'paid' with no
|
|
// payment behind it. Bail out and leave the reversal safely re-runnable.
|
|
it('aborts the whole sync when the supplier invoice read errors', async () => {
|
|
const { supabase, calls, wasDeleted, tablesUpdated } = createRecordingSupabase([
|
|
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
|
|
{
|
|
data: null,
|
|
error: { code: '42703', message: 'column supplier_invoices.total_amount does not exist' },
|
|
},
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
|
|
|
|
expect(calls.map((c) => c.table)).toEqual(['supplier_invoice_payments', 'supplier_invoices'])
|
|
expect(tablesUpdated('supplier_invoices').length).toBe(0)
|
|
expect(wasDeleted('supplier_invoice_payments')).toBe(false)
|
|
expect(tablesUpdated('transactions').length).toBe(0)
|
|
})
|
|
|
|
// "No row" is not a read failure: the invoice is genuinely gone, so there is
|
|
// nothing to restore and the orphan payment row plus the bank line still have
|
|
// to be cleaned up.
|
|
it('still cleans up when the supplier invoice row no longer exists (PGRST116)', async () => {
|
|
const { supabase, wasDeleted, tablesUpdated } = createRecordingSupabase([
|
|
{ data: { amount: 1000 } }, // supplier_invoice_payments select amount
|
|
{ data: null, error: { code: 'PGRST116', message: 'no rows returned' } },
|
|
{ data: [{ transaction_id: 'tx-3' }] }, // supplier_invoice_payments select transaction_id
|
|
{ data: null }, // supplier_invoice_payments delete
|
|
{ data: null }, // transactions update by journal_entry_id
|
|
{ data: null }, // transactions update by id
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(supabase, 'co-1', entry())
|
|
|
|
expect(tablesUpdated('supplier_invoices').length).toBe(0)
|
|
expect(wasDeleted('supplier_invoice_payments')).toBe(true)
|
|
expect(tablesUpdated('transactions').length).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('syncInvoiceStatusFromPaymentEntry: reclaimed ROT/RUT share (rot_rut_reclaim)', () => {
|
|
// 25 000 invoice, 7 500 deduction, Skatteverket refused 2 500 and the
|
|
// reclaim moved it onto the customer; customer paid 17 500 + 2 500. The
|
|
// storno of the 2 500 payment must leave 2 500 open, not 0 (#2397 R1).
|
|
it('keeps the refused share in the remaining after a payment storno', async () => {
|
|
const { supabase, updatePayload } = createRecordingSupabase([
|
|
{ data: { amount: 2500 } }, // invoice_payments select amount
|
|
{
|
|
data: {
|
|
paid_amount: 20000,
|
|
total: 25000,
|
|
deduction_total: 7500,
|
|
deduction_reclaimed_total: 2500,
|
|
due_date: '2099-12-31',
|
|
},
|
|
}, // invoices select
|
|
{ data: null }, // invoices update
|
|
{ data: [] }, // invoice_payments select transaction_id
|
|
{ data: null }, // invoice_payments delete
|
|
{ data: null }, // transactions update
|
|
])
|
|
|
|
await syncInvoiceStatusFromPaymentEntry(
|
|
supabase,
|
|
'co-1',
|
|
{ id: 'entry-1', source_type: 'invoice_paid', source_id: 'invoice-1' } as JournalEntry,
|
|
)
|
|
|
|
expect(updatePayload('invoices')).toEqual({
|
|
status: 'partially_paid',
|
|
paid_at: null,
|
|
paid_amount: 17500,
|
|
remaining_amount: 2500,
|
|
})
|
|
})
|
|
})
|