feat(reconciliation): residual booking + junction-aware bridge (#1862)
When the worksheet selection (N bank rows vs one verifikat) misses by a few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad / Ränteintäkt / Öresavrundning och koppla' books the remainder on 6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (Skatteverket posts ränta and avgifter as rows of their own), capped at 5 000 kr, direction-checked against the kind; links are made first and undone if the booking is refused. Dashboard + v1 doors (transactions:write, Idempotency-Key, dry run), API skill regenerated. The bridge now treats transaction_voucher_links as links on both sides: migration 20260824190000 re-creates get_unlinked_gl_lines and get_account_gl_lines_for_matching to count junction-linked verifikat as matched (pg-real test), and the TS engine + items do the same for the transactions. This also stops bulk-booked samlingsverifikat from polluting the open buckets. 'Koppla bort' drops the junction rows too. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
0a3cb1a31a
commit
d88df74b85
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `134`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `135`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
@@ -106,6 +106,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"POST /api/v1/companies/:companyId/journal-entries/batch-create",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/items/:itemId/ignore",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen",
|
||||
"POST /api/v1/companies/:companyId/reconciliation/bank/run",
|
||||
|
||||
@@ -162,6 +162,7 @@ import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route'
|
||||
|
||||
// Dimensions PR2: registry list + value creation (kostnadsställe/projekt).
|
||||
import '@/app/api/v1/companies/[companyId]/dimensions/route'
|
||||
|
||||
@@ -145,6 +145,7 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'GET /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:read',
|
||||
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:signoff',
|
||||
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen': 'reconciliation:signoff',
|
||||
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual': 'transactions:write',
|
||||
|
||||
// Phase 5 PR-3: Reports + import async. Reports are read-only over
|
||||
// existing lib/reports/* generators; imports are async over the Phase 4
|
||||
|
||||
@@ -2178,7 +2178,7 @@ describe('unscoped cash-account diagnostic', () => {
|
||||
expect(firstFrom?.args[0]).toBe('transactions')
|
||||
const firstSelect = calls.find((c) => c.method === 'select')
|
||||
expect(firstSelect?.args[0]).toBe(
|
||||
'date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id',
|
||||
'id, date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const skvStatusMock = vi.fn()
|
||||
const fetchUnlinkedMock = vi.fn()
|
||||
const junctionMock = vi.fn()
|
||||
|
||||
vi.mock('../skattekonto-reconciliation', () => ({
|
||||
getSkattekontoReconciliationStatus: (...args: unknown[]) => skvStatusMock(...args),
|
||||
}))
|
||||
vi.mock('../bank-reconciliation', () => ({
|
||||
fetchUnlinkedGLLines: (...args: unknown[]) => fetchUnlinkedMock(...args),
|
||||
fetchJunctionLinkedTxIds: (...args: unknown[]) => junctionMock(...args),
|
||||
scopeTransactionsToAccount: (q: unknown) => q,
|
||||
}))
|
||||
|
||||
@@ -26,6 +28,8 @@ describe('listAccountItems', () => {
|
||||
vi.clearAllMocks()
|
||||
skvStatusMock.mockReset()
|
||||
fetchUnlinkedMock.mockReset()
|
||||
junctionMock.mockReset()
|
||||
junctionMock.mockResolvedValue(new Set())
|
||||
})
|
||||
|
||||
it('returns null for an invalid key', async () => {
|
||||
@@ -69,8 +73,11 @@ describe('listAccountItems', () => {
|
||||
{ id: 't-link', date: '2026-08-03', description: 'Lön', merchant_name: null, amount: -31200, currency: 'SEK', journal_entry_id: 'e-1', potential_journal_entry_id: null, potential_match_method: 'manual', potential_match_confidence: null, is_ignored: false, reconciliation_method: 'manual' },
|
||||
{ id: 't-prop', date: '2026-08-02', description: 'Swish', merchant_name: 'Swish 123', amount: 2400, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: 'e-2', potential_match_method: 'auto_fuzzy', potential_match_confidence: '0.82', is_ignored: false, reconciliation_method: null },
|
||||
{ id: 't-open', date: '2026-08-01', description: 'Elgiganten', merchant_name: null, amount: -1046, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null },
|
||||
// Anchored only through transaction_voucher_links (bulk-book / residual): matched, pointer NULL.
|
||||
{ id: 't-junc', date: '2026-07-31', description: 'Samlingsverifikat', merchant_name: null, amount: -500, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null },
|
||||
],
|
||||
})
|
||||
junctionMock.mockResolvedValue(new Set(['t-junc']))
|
||||
fetchUnlinkedMock.mockResolvedValue([
|
||||
{ line_id: 'l1', journal_entry_id: 'e-3', debit_amount: 0, credit_amount: 600, line_description: null, entry_date: '2026-08-18', voucher_number: 231, voucher_series: 'A', entry_description: 'Elgiganten', source_type: 'manual' },
|
||||
{ line_id: 'l2', journal_entry_id: 'e-3', debit_amount: 0, credit_amount: 400, line_description: null, entry_date: '2026-08-18', voucher_number: 231, voucher_series: 'A', entry_description: 'Elgiganten', source_type: 'manual' },
|
||||
@@ -83,10 +90,11 @@ describe('listAccountItems', () => {
|
||||
expect(byId['t-open']).toMatchObject({ bucket: 'unmatched_external', actions: ['book', 'match', 'ignore'] })
|
||||
expect(byId['t-link']).toMatchObject({ bucket: 'matched', linked_journal_entry_id: 'e-1', actions: ['unmatch'] })
|
||||
expect(byId['t-ign']).toMatchObject({ bucket: 'ignored', actions: ['unignore'] })
|
||||
expect(byId['t-junc']).toMatchObject({ bucket: 'matched', actions: ['unmatch'] })
|
||||
expect(byId['e-3']).toMatchObject({ bucket: 'unmatched_ledger', side: 'ledger', amount: -1000, voucher_number: 231 })
|
||||
// Work order: proposed, unmatched external, unmatched ledger, ignored, upcoming, matched
|
||||
expect(result?.items.map((i) => i.bucket)).toEqual(['proposed', 'unmatched_external', 'unmatched_ledger', 'ignored', 'matched'])
|
||||
expect(result?.total_count).toBe(5)
|
||||
expect(result?.items.map((i) => i.bucket)).toEqual(['proposed', 'unmatched_external', 'unmatched_ledger', 'ignored', 'matched', 'matched'])
|
||||
expect(result?.total_count).toBe(6)
|
||||
})
|
||||
|
||||
it('returns null for an unknown cash account', async () => {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const createEntryMock = vi.fn()
|
||||
const findPeriodMock = vi.fn()
|
||||
const matchMock = vi.fn()
|
||||
const unmatchMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => createEntryMock(...args),
|
||||
findFiscalPeriod: (...args: unknown[]) => findPeriodMock(...args),
|
||||
}))
|
||||
vi.mock('../actions', () => ({
|
||||
matchPairs: (...args: unknown[]) => matchMock(...args),
|
||||
unmatchLink: (...args: unknown[]) => unmatchMock(...args),
|
||||
}))
|
||||
|
||||
import { bookResidualAndLink, ReconciliationResidualError } from '../residual'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
const CASH = '11111111-1111-4111-8111-111111111111'
|
||||
const KEY = `bank:${CASH}`
|
||||
const T1 = '22222222-2222-4222-8222-222222222222'
|
||||
const E1 = '44444444-4444-4444-8444-444444444444'
|
||||
const RES = '55555555-5555-4555-8555-555555555555'
|
||||
|
||||
function tx(overrides: Record<string, unknown> = {}) {
|
||||
return { id: T1, date: '2026-08-05', amount: -1010, description: 'Leverantör AB', journal_entry_id: null, is_ignored: false, cash_account_id: CASH, ...overrides }
|
||||
}
|
||||
function entry(net = -1000) {
|
||||
return {
|
||||
id: E1,
|
||||
status: 'posted',
|
||||
description: 'Faktura 1234',
|
||||
lines: [
|
||||
{ account_number: '2440', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: net > 0 ? net : 0, credit_amount: net < 0 ? -net : 0 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('bookResidualAndLink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
createEntryMock.mockReset()
|
||||
findPeriodMock.mockReset()
|
||||
matchMock.mockReset()
|
||||
unmatchMock.mockReset()
|
||||
findPeriodMock.mockResolvedValue('fp-1')
|
||||
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [{ external_id: T1, journal_entry_id: E1 }], skipped: [] })
|
||||
createEntryMock.mockResolvedValue({ id: RES })
|
||||
})
|
||||
|
||||
it('returns null for an unknown account, refuses the skattekonto, refuses an unknown kind', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
expect(await bookResidualAndLink(supabase as never, COMPANY, USER, 'nope', { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' })).toBeNull()
|
||||
await expect(
|
||||
bookResidualAndLink(supabase as never, COMPANY, USER, 'skattekonto', { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
|
||||
).rejects.toMatchObject({ code: 'RESIDUAL_UNSUPPORTED_KIND' })
|
||||
enqueue({ data: null }) // cash account lookup: unknown for this company
|
||||
expect(await bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' })).toBeNull()
|
||||
})
|
||||
|
||||
it('previews a bank fee: expense on 6570 against the bank account, dated on the transaction', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx()] })
|
||||
enqueue({ data: entry(-1000) })
|
||||
const result = await bookResidualAndLink(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
KEY,
|
||||
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' },
|
||||
{ dryRun: true },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
dry_run: true,
|
||||
would_book: {
|
||||
residual_amount: -10,
|
||||
counter_account: '6570',
|
||||
entry_date: '2026-08-05',
|
||||
description: 'Bankavgift: Faktura 1234',
|
||||
lines: [
|
||||
{ account_number: '6570', debit_amount: 10, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 10 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(matchMock).not.toHaveBeenCalled()
|
||||
expect(createEntryMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('links first, then books the residual, then anchors it through the junction', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx()] })
|
||||
enqueue({ data: entry(-1000) })
|
||||
enqueue({ data: null }) // junction insert
|
||||
const result = await bookResidualAndLink(supabase as never, COMPANY, USER, KEY, {
|
||||
external_ids: [T1],
|
||||
journal_entry_id: E1,
|
||||
kind: 'bank_fee',
|
||||
})
|
||||
expect(result).toMatchObject({ dry_run: false, residual_journal_entry_id: RES, residual_amount: -10 })
|
||||
expect(matchMock).toHaveBeenCalledWith(supabase, COMPANY, USER, KEY, { pairs: [{ external_ids: [T1], journal_entry_ids: [E1] }] }, { dryRun: false })
|
||||
expect(createEntryMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
USER,
|
||||
expect.objectContaining({ fiscal_period_id: 'fp-1', entry_date: '2026-08-05', source_type: 'manual' }),
|
||||
)
|
||||
expect(findCall('transaction_voucher_links', 'insert')?.[0]).toMatchObject({
|
||||
transaction_id: T1,
|
||||
journal_entry_id: RES,
|
||||
allocated_amount: -10,
|
||||
role: 'other',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a zero residual, a residual above the cap, and a kind in the wrong direction', async () => {
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx({ amount: -1000 })] })
|
||||
enqueue({ data: entry(-1000) })
|
||||
await expect(
|
||||
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
|
||||
).rejects.toMatchObject({ code: 'RESIDUAL_ZERO' })
|
||||
|
||||
reset()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx({ amount: -9000 })] })
|
||||
enqueue({ data: entry(-1000) })
|
||||
await expect(
|
||||
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
|
||||
).rejects.toMatchObject({ code: 'RESIDUAL_TOO_LARGE' })
|
||||
|
||||
reset()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx({ amount: 1005 })] })
|
||||
enqueue({ data: entry(1000) })
|
||||
// The bank received MORE than booked: a bank fee (expense) is the wrong kind.
|
||||
await expect(
|
||||
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
|
||||
).rejects.toMatchObject({ code: 'RESIDUAL_DIRECTION' })
|
||||
expect(matchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books interest income on the income side when the bank received more', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx({ amount: 1002.5 })] })
|
||||
enqueue({ data: entry(1000) })
|
||||
const result = await bookResidualAndLink(
|
||||
supabase as never,
|
||||
COMPANY,
|
||||
USER,
|
||||
KEY,
|
||||
{ external_ids: [T1], journal_entry_id: E1, kind: 'interest_income' },
|
||||
{ dryRun: true },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
dry_run: true,
|
||||
would_book: {
|
||||
residual_amount: 2.5,
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 2.5, credit_amount: 0 },
|
||||
{ account_number: '8310', debit_amount: 0, credit_amount: 2.5 },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('undoes the links when the residual booking is refused', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx()] })
|
||||
enqueue({ data: entry(-1000) })
|
||||
createEntryMock.mockRejectedValue(new Error('Perioden är låst'))
|
||||
await expect(
|
||||
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
|
||||
).rejects.toThrow(/låst/)
|
||||
expect(unmatchMock).toHaveBeenCalledWith(supabase, COMPANY, USER, KEY, T1)
|
||||
})
|
||||
|
||||
it('reports a failed link without booking anything', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
|
||||
enqueue({ data: [tx()] })
|
||||
enqueue({ data: entry(-1000) })
|
||||
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [], skipped: [{ pair: { external_ids: [T1], journal_entry_ids: [E1] }, code: 'PAIR_NOT_CLOSED', message: 'Verifikationen saknar rad på 1930' }] })
|
||||
await expect(
|
||||
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
|
||||
).rejects.toBeInstanceOf(ReconciliationResidualError)
|
||||
expect(createEntryMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -731,6 +731,7 @@ export async function getReconciliationStatus(
|
||||
// manufacture a phantom, unexplainable difference. Ordered on id (unique) so
|
||||
// pages never duplicate or skip rows across boundaries.
|
||||
type StatusTxRow = {
|
||||
id?: string | null
|
||||
date: string | null
|
||||
amount: number | string | null
|
||||
journal_entry_id: string | null
|
||||
@@ -741,7 +742,7 @@ export async function getReconciliationStatus(
|
||||
const transactions = await fetchAllRows<StatusTxRow>(({ from, to }) => {
|
||||
let txQuery = supabase
|
||||
.from('transactions')
|
||||
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
|
||||
.select('id, date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
|
||||
.eq('company_id', companyId)
|
||||
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
|
||||
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
|
||||
@@ -756,6 +757,16 @@ export async function getReconciliationStatus(
|
||||
currency,
|
||||
})
|
||||
|
||||
// Transactions anchored through transaction_voucher_links (bulk-booked
|
||||
// samlingsverifikat, residual bookings) carry journal_entry_id = NULL on the
|
||||
// row itself. They are settled all the same, so the matched/unmatched split
|
||||
// below must see them; is_transaction_booked() is the SQL twin of this.
|
||||
const junctionLinkedTxIds = await fetchJunctionLinkedTxIds(
|
||||
supabase,
|
||||
companyId,
|
||||
transactions.map((tx) => tx.id).filter((id): id is string => typeof id === 'string'),
|
||||
)
|
||||
|
||||
// Get GL bank-account lines. We fetch posted AND reversed entries and count
|
||||
// them TOGETHER: the exact inclusion rule the trial balance and balance sheet
|
||||
// use (see lib/reports/trial-balance.ts, which sums `['posted','reversed']`).
|
||||
@@ -926,13 +937,15 @@ export async function getReconciliationStatus(
|
||||
// Matched/unmatched partition the RECONCILABLE (non-ignored) set, so
|
||||
// matched_count + unmatched_transaction_count always equals the number of
|
||||
// rows behind bank_transaction_total.
|
||||
const matchedCount = reconcilableTx.filter((tx) => tx.journal_entry_id !== null).length
|
||||
const isLinked = (tx: StatusTxRow): boolean =>
|
||||
tx.journal_entry_id !== null || (typeof tx.id === 'string' && junctionLinkedTxIds.has(tx.id))
|
||||
const matchedCount = reconcilableTx.filter(isLinked).length
|
||||
// The gross split behind the net: what the user actually recognises as
|
||||
// "what moved on the bank", so the page never has to explain "netto".
|
||||
const bankInflow = reconcilableTx.reduce((sum, tx) => sum + Math.max(Number(tx.amount) || 0, 0), 0)
|
||||
const bankOutflow = reconcilableTx.reduce((sum, tx) => sum + Math.min(Number(tx.amount) || 0, 0), 0)
|
||||
|
||||
const unmatchedTx = reconcilableTx.filter((tx) => tx.journal_entry_id === null)
|
||||
const unmatchedTx = reconcilableTx.filter((tx) => !isLinked(tx))
|
||||
const unmatchedTransactionCount = unmatchedTx.length
|
||||
const unmatchedTransactionTotal = unmatchedTx.reduce(
|
||||
(sum, tx) => sum + (Number(tx.amount) || 0),
|
||||
@@ -1207,6 +1220,14 @@ export async function unlinkReconciliation(
|
||||
return { success: false, error: 'Failed to unlink transaction' }
|
||||
}
|
||||
|
||||
// A residual booking (or a bulk-book) anchors the same transaction through
|
||||
// transaction_voucher_links as well; "koppla bort" means every anchor goes.
|
||||
await supabase
|
||||
.from('transaction_voucher_links')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('transaction_id', transactionId)
|
||||
|
||||
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
|
||||
previousState: {
|
||||
journal_entry_id: tx.journal_entry_id,
|
||||
@@ -1467,6 +1488,33 @@ export async function fetchUnlinkedGLLines(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of the given transactions that are anchored to a verifikat through
|
||||
* transaction_voucher_links (journal_entry_id NULL on the row itself). Chunked
|
||||
* on the id list so a busy window never pushes the .in() past URL limits;
|
||||
* a failed read returns the empty set rather than throwing, mirroring
|
||||
* fetchUnlinkedGLLines' legacy contract.
|
||||
*/
|
||||
export async function fetchJunctionLinkedTxIds(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transactionIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const out = new Set<string>()
|
||||
const CHUNK = 150
|
||||
for (let i = 0; i < transactionIds.length; i += CHUNK) {
|
||||
const chunk = transactionIds.slice(i, i + CHUNK)
|
||||
const { data, error } = await supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('transaction_id', chunk)
|
||||
if (error) return out
|
||||
for (const row of (data ?? []) as Array<{ transaction_id: string }>) out.add(row.transaction_id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A match candidate that carries how many transactions already point at it. */
|
||||
export interface GLLineForMatching extends UnlinkedGLLine {
|
||||
/** Transactions settling this entry ON THE REQUESTED ACCOUNT (plus legacy
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation'
|
||||
import { fetchJunctionLinkedTxIds, fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation'
|
||||
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
|
||||
import {
|
||||
parseAccountKey,
|
||||
@@ -147,11 +147,18 @@ export async function listAccountItems(
|
||||
const { data, error: txError } = await query.order('date', { ascending: false }).order('id', { ascending: true })
|
||||
if (txError) throw new Error(`Kunde inte hämta transaktioner: ${txError.message}`)
|
||||
const rows = (data ?? []) as BankTxRow[]
|
||||
// Rows anchored only through transaction_voucher_links (bulk-book,
|
||||
// residual bookings) are matched too; their pointer column is NULL.
|
||||
const junctionLinked = await fetchJunctionLinkedTxIds(
|
||||
supabase,
|
||||
companyId,
|
||||
rows.filter((tx) => !tx.journal_entry_id && !tx.is_ignored).map((tx) => tx.id),
|
||||
)
|
||||
{
|
||||
for (const tx of rows) {
|
||||
const bucket: ReconciliationItemBucket = tx.is_ignored
|
||||
? 'ignored'
|
||||
: tx.journal_entry_id
|
||||
: tx.journal_entry_id || junctionLinked.has(tx.id)
|
||||
? 'matched'
|
||||
: tx.potential_journal_entry_id
|
||||
? 'proposed'
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { matchPairs, unmatchLink, type AppliedLink, type SkippedPair } from './actions'
|
||||
import { parseAccountKey } from './schemas'
|
||||
|
||||
const log = createLogger('reconciliation/residual')
|
||||
|
||||
/**
|
||||
* Residual booking: the worksheet selection (N bank rows against one
|
||||
* verifikat) misses by a few kronor, and the remainder is a real event the
|
||||
* books lack: a bank fee, interest, öresavrundning. This books that remainder
|
||||
* as its own small verifikat on the bank account and settles the selection in
|
||||
* the same gesture: the rows point at the main verifikat as usual, and the
|
||||
* residual verifikat is anchored to the first row through
|
||||
* transaction_voucher_links, which the bridge treats as a link.
|
||||
*
|
||||
* Bank accounts only: Skatteverket posts ränta and avgifter as rows of their
|
||||
* own on the skattekonto, so a skattekonto pair that does not close is a
|
||||
* wrong booking, never a missing fee.
|
||||
*/
|
||||
|
||||
export type ResidualKind = 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense'
|
||||
|
||||
export const RESIDUAL_KINDS: Record<
|
||||
ResidualKind,
|
||||
{ account: string; label_sv: string; label_en: string; direction: 'out' | 'in' | 'any' }
|
||||
> = {
|
||||
// Money that left the bank without a booking: expenses.
|
||||
bank_fee: { account: '6570', label_sv: 'Bankavgift', label_en: 'Bank fee', direction: 'out' },
|
||||
interest_expense: { account: '8410', label_sv: 'Räntekostnad', label_en: 'Interest expense', direction: 'out' },
|
||||
// Money that reached the bank without a booking: income.
|
||||
interest_income: { account: '8310', label_sv: 'Ränteintäkt', label_en: 'Interest income', direction: 'in' },
|
||||
// Either way, öre-level.
|
||||
rounding: { account: '3740', label_sv: 'Öresavrundning', label_en: 'Rounding', direction: 'any' },
|
||||
}
|
||||
|
||||
export type ResidualErrorCode =
|
||||
| 'RESIDUAL_UNSUPPORTED_KIND'
|
||||
| 'RESIDUAL_ROWS_NOT_FOUND'
|
||||
| 'RESIDUAL_ROW_NOT_OPEN'
|
||||
| 'RESIDUAL_ENTRY_NOT_FOUND'
|
||||
| 'RESIDUAL_ENTRY_NOT_POSTED'
|
||||
| 'RESIDUAL_ZERO'
|
||||
| 'RESIDUAL_TOO_LARGE'
|
||||
| 'RESIDUAL_DIRECTION'
|
||||
| 'RESIDUAL_NO_PERIOD'
|
||||
| 'RESIDUAL_LINK_FAILED'
|
||||
| 'RESIDUAL_INVALID_DATE'
|
||||
|
||||
export class ReconciliationResidualError extends Error {
|
||||
readonly code: ResidualErrorCode
|
||||
constructor(message: string, code: ResidualErrorCode) {
|
||||
super(message)
|
||||
this.name = 'ReconciliationResidualError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** Above this, a "residual" is a missing booking, not a fee: refuse so the user books it properly. */
|
||||
export const RESIDUAL_MAX_AMOUNT = 5000
|
||||
|
||||
export interface ResidualInput {
|
||||
external_ids: string[]
|
||||
journal_entry_id: string
|
||||
kind: ResidualKind
|
||||
/** Defaults to the latest selected transaction's date. */
|
||||
entry_date?: string
|
||||
/** Defaults to "<kind label>: <main verifikat description>". */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ResidualPreview {
|
||||
account_key: string
|
||||
kind: ResidualKind
|
||||
counter_account: string
|
||||
ledger_account: string
|
||||
currency: string
|
||||
transactions_total: number
|
||||
entry_net: number
|
||||
/** transactions_total - entry_net: negative = the bank paid more than booked. */
|
||||
residual_amount: number
|
||||
entry_date: string
|
||||
description: string
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
|
||||
export type ResidualResult =
|
||||
| { dry_run: true; would_book: ResidualPreview }
|
||||
| {
|
||||
dry_run: false
|
||||
residual_journal_entry_id: string
|
||||
residual_amount: number
|
||||
applied: AppliedLink[]
|
||||
skipped: SkippedPair[]
|
||||
}
|
||||
|
||||
interface TxRow {
|
||||
id: string
|
||||
date: string
|
||||
amount: number | string
|
||||
description: string | null
|
||||
journal_entry_id: string | null
|
||||
is_ignored: boolean | null
|
||||
cash_account_id: string | null
|
||||
}
|
||||
|
||||
export async function bookResidualAndLink(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
accountKey: string,
|
||||
input: ResidualInput,
|
||||
options: { dryRun?: boolean } = {},
|
||||
): Promise<ResidualResult | null> {
|
||||
const parsed = parseAccountKey(accountKey)
|
||||
if (!parsed) return null
|
||||
if (parsed.kind !== 'bank') {
|
||||
throw new ReconciliationResidualError(
|
||||
'Restbelopp bokförs bara på bankkonton. På skattekontot är ränta och avgifter egna händelser: koppla dem i stället.',
|
||||
'RESIDUAL_UNSUPPORTED_KIND',
|
||||
)
|
||||
}
|
||||
const spec = RESIDUAL_KINDS[input.kind]
|
||||
if (!spec) {
|
||||
throw new ReconciliationResidualError('Okänd typ av restbelopp.', 'RESIDUAL_UNSUPPORTED_KIND')
|
||||
}
|
||||
if (input.entry_date && !ISO_DATE_RE.test(input.entry_date)) {
|
||||
throw new ReconciliationResidualError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'RESIDUAL_INVALID_DATE')
|
||||
}
|
||||
|
||||
const { data: account } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, ledger_account, currency')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', parsed.cashAccountId)
|
||||
.maybeSingle<{ id: string; ledger_account: string; currency: string | null }>()
|
||||
if (!account) return null
|
||||
const ledgerAccount = account.ledger_account
|
||||
const currency = account.currency ?? 'SEK'
|
||||
|
||||
const ids = [...new Set(input.external_ids)]
|
||||
if (ids.length === 0 || ids.length > 50) {
|
||||
throw new ReconciliationResidualError('Välj mellan 1 och 50 banktransaktioner.', 'RESIDUAL_ROWS_NOT_FOUND')
|
||||
}
|
||||
const { data: txRows, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, description, journal_entry_id, is_ignored, cash_account_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', ids)
|
||||
if (txError || !txRows || txRows.length !== ids.length) {
|
||||
throw new ReconciliationResidualError('Någon av banktransaktionerna hittades inte.', 'RESIDUAL_ROWS_NOT_FOUND')
|
||||
}
|
||||
const txs = txRows as TxRow[]
|
||||
if (txs.some((t) => t.journal_entry_id || t.is_ignored)) {
|
||||
throw new ReconciliationResidualError(
|
||||
'En av transaktionerna är redan kopplad eller ignorerad.',
|
||||
'RESIDUAL_ROW_NOT_OPEN',
|
||||
)
|
||||
}
|
||||
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status, description, lines:journal_entry_lines ( account_number, debit_amount, credit_amount )')
|
||||
.eq('id', input.journal_entry_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle<{
|
||||
id: string
|
||||
status: string
|
||||
description: string | null
|
||||
lines: Array<{ account_number: string; debit_amount: number | string; credit_amount: number | string }> | null
|
||||
}>()
|
||||
if (entryError || !entry) {
|
||||
throw new ReconciliationResidualError('Verifikatet hittades inte.', 'RESIDUAL_ENTRY_NOT_FOUND')
|
||||
}
|
||||
if (entry.status !== 'posted') {
|
||||
throw new ReconciliationResidualError('Verifikatet är inte bokfört.', 'RESIDUAL_ENTRY_NOT_POSTED')
|
||||
}
|
||||
const entryNet = roundOre(
|
||||
(entry.lines ?? [])
|
||||
.filter((l) => l.account_number === ledgerAccount)
|
||||
.reduce((s, l) => s + Number(l.debit_amount || 0) - Number(l.credit_amount || 0), 0),
|
||||
)
|
||||
const txTotal = roundOre(txs.reduce((s, t) => s + Number(t.amount || 0), 0))
|
||||
const residual = roundOre(txTotal - entryNet)
|
||||
|
||||
if (Math.abs(residual) < 0.005) {
|
||||
throw new ReconciliationResidualError(
|
||||
'Summorna stämmer redan: koppla utan restbelopp.',
|
||||
'RESIDUAL_ZERO',
|
||||
)
|
||||
}
|
||||
if (Math.abs(residual) > RESIDUAL_MAX_AMOUNT) {
|
||||
throw new ReconciliationResidualError(
|
||||
`Restbeloppet är större än ${RESIDUAL_MAX_AMOUNT} kr. Det är en saknad bokföring, inte en avgift: bokför den som ett eget verifikat.`,
|
||||
'RESIDUAL_TOO_LARGE',
|
||||
)
|
||||
}
|
||||
const direction: 'out' | 'in' = residual < 0 ? 'out' : 'in'
|
||||
if (spec.direction !== 'any' && spec.direction !== direction) {
|
||||
throw new ReconciliationResidualError(
|
||||
direction === 'out'
|
||||
? 'Banken har betalat ut mer än vad som är bokfört: restbeloppet är en kostnad (avgift eller räntekostnad), inte en intäkt.'
|
||||
: 'Banken har tagit emot mer än vad som är bokfört: restbeloppet är en intäkt (ränteintäkt), inte en kostnad.',
|
||||
'RESIDUAL_DIRECTION',
|
||||
)
|
||||
}
|
||||
|
||||
const amount = roundOre(Math.abs(residual))
|
||||
// Expense: the bank line is a credit (money left), the counter account a debit.
|
||||
// Income: the bank line is a debit (money arrived), the counter a credit.
|
||||
const lines =
|
||||
direction === 'out'
|
||||
? [
|
||||
{ account_number: spec.account, debit_amount: amount, credit_amount: 0 },
|
||||
{ account_number: ledgerAccount, debit_amount: 0, credit_amount: amount },
|
||||
]
|
||||
: [
|
||||
{ account_number: ledgerAccount, debit_amount: amount, credit_amount: 0 },
|
||||
{ account_number: spec.account, debit_amount: 0, credit_amount: amount },
|
||||
]
|
||||
|
||||
const latestTxDate = txs.map((t) => t.date).sort().at(-1) ?? new Date().toISOString().slice(0, 10)
|
||||
const entryDate = input.entry_date ?? latestTxDate
|
||||
const description =
|
||||
input.description?.trim() ||
|
||||
`${spec.label_sv}: ${entry.description?.trim() || txs[0].description?.trim() || 'avstämning'}`
|
||||
|
||||
const preview: ResidualPreview = {
|
||||
account_key: accountKey,
|
||||
kind: input.kind,
|
||||
counter_account: spec.account,
|
||||
ledger_account: ledgerAccount,
|
||||
currency,
|
||||
transactions_total: txTotal,
|
||||
entry_net: entryNet,
|
||||
residual_amount: residual,
|
||||
entry_date: entryDate,
|
||||
description,
|
||||
lines,
|
||||
}
|
||||
if (options.dryRun) return { dry_run: true, would_book: preview }
|
||||
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
||||
if (!fiscalPeriodId) {
|
||||
throw new ReconciliationResidualError(
|
||||
`Det finns inget räkenskapsår för ${entryDate}.`,
|
||||
'RESIDUAL_NO_PERIOD',
|
||||
)
|
||||
}
|
||||
|
||||
// Link first (reversible), then book. If the booking is refused (a lock, a
|
||||
// trigger), the links are undone so the selection is back where it was.
|
||||
const linkResult = await matchPairs(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
accountKey,
|
||||
{ pairs: [{ external_ids: ids, journal_entry_ids: [entry.id] }] },
|
||||
{ dryRun: false },
|
||||
)
|
||||
if (!linkResult || linkResult.applied.length !== ids.length) {
|
||||
// Undo whatever did link before reporting.
|
||||
for (const a of linkResult?.applied ?? []) {
|
||||
try {
|
||||
await unmatchLink(supabase, companyId, userId, accountKey, a.external_id)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
const first = linkResult?.skipped[0]
|
||||
throw new ReconciliationResidualError(
|
||||
first ? first.message : 'Kunde inte koppla transaktionerna till verifikatet.',
|
||||
'RESIDUAL_LINK_FAILED',
|
||||
)
|
||||
}
|
||||
|
||||
let residualEntry: { id: string }
|
||||
try {
|
||||
residualEntry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: entryDate,
|
||||
description,
|
||||
source_type: 'manual',
|
||||
lines,
|
||||
})
|
||||
} catch (err) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await unmatchLink(supabase, companyId, userId, accountKey, id)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Anchor the residual verifikat to the selection through the junction (the
|
||||
// rows' pointer column already holds the main verifikat). One row carries
|
||||
// the whole residual so Sum(allocated_amount) per verifikat equals its bank
|
||||
// side, the invariant bulk-book keeps.
|
||||
const { error: linkError } = await supabase.from('transaction_voucher_links').insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
transaction_id: ids[0],
|
||||
journal_entry_id: residualEntry.id,
|
||||
allocated_amount: residual,
|
||||
role: 'other',
|
||||
})
|
||||
if (linkError) {
|
||||
log.error('residual verifikat booked but the junction link failed', linkError, {
|
||||
companyId,
|
||||
accountKey,
|
||||
residualEntryId: residualEntry.id,
|
||||
})
|
||||
}
|
||||
|
||||
log.info('residual booked and linked', {
|
||||
companyId,
|
||||
accountKey,
|
||||
kind: input.kind,
|
||||
residual,
|
||||
residualEntryId: residualEntry.id,
|
||||
linked: ids.length,
|
||||
})
|
||||
|
||||
return {
|
||||
dry_run: false,
|
||||
residual_journal_entry_id: residualEntry.id,
|
||||
residual_amount: residual,
|
||||
applied: linkResult.applied,
|
||||
skipped: linkResult.skipped,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user