feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2359)
* feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2293) The bridge table said "ej matchad" and steered to Bokför when a Bankgirot aggregate was already booked as two or three unlinked vouchers. The booking doors have refused that double booking since #2300 and #2346 with detectExplainingVoucherSet; the view never ran it. - duplicate-payment-detection: split the set detector into fetch and pure steps and add detectExplainingVoucherSets, the batch form (one ledger scan, one anchor lookup, per-row verdict identical to the single detector; a voucher explains at most one row per call). ExplainingVoucher now also carries voucher_series and voucher_number. - reconciliation/covering-set-candidate (new): maps sets to proposals (0.95 same date, 0.85 within seven days), SEK accounts only, fails open. - items: open bank rows nothing explains 1:1 are searched before they land in unmatched_external; a hit lands in proposed with proposal.vouchers. - schemas: ReconciliationProposal.vouchers (optional, set proposals only). - AccountOverview: "= A57 + A58" with the legs' amounts, one Koppla that posts every voucher as a 1:N pair to the existing links route. - i18n: reconciliation.proposal_set_title and proposal_set_same_day (sv, en). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * chore(skill): regenerate accounted-api for ReconciliationProposal.vouchers (#2293) The set proposal field added to the reconciliation items response shape flows into the generated agent skill; regenerated with `npm run apiskill:generate`, which changes one line of references/banking.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -191,8 +191,13 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
||||
if (!item.proposal) return
|
||||
setBusy(item.item_id)
|
||||
try {
|
||||
// A set proposal (#2293) links the row to every verifikat in it (1:N,
|
||||
// all or nothing, slices revalidated server-side); a 1:1 proposal to one.
|
||||
const journalEntryIds = item.proposal.vouchers?.map((v) => v.journal_entry_id) ?? [
|
||||
item.proposal.journal_entry_id,
|
||||
]
|
||||
const data = await postJson(`${base}/links`, {
|
||||
pairs: [{ external_ids: [item.item_id], journal_entry_ids: [item.proposal.journal_entry_id] }],
|
||||
pairs: [{ external_ids: [item.item_id], journal_entry_ids: journalEntryIds }],
|
||||
})
|
||||
if (data) {
|
||||
const skipped = data.skipped as Array<{ message: string }>
|
||||
@@ -918,6 +923,43 @@ function ItemRow({
|
||||
{item.awaiting_external && <Chip>{t('chip_awaiting', { source: sourceLabel })}</Chip>}
|
||||
</span>
|
||||
)
|
||||
} else if (item.proposal && item.proposal.vouchers && item.proposal.vouchers.length > 1) {
|
||||
// An explaining set (#2293): "= A57 + A58", the legs' amounts underneath,
|
||||
// one Koppla that links the row to all of them.
|
||||
const p = item.proposal
|
||||
const setVouchers = p.vouchers ?? []
|
||||
const sameDay = setVouchers.every((v) => v.entry_date === item.date)
|
||||
voucherCell = (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"
|
||||
title={t('proposal_set_title', { count: setVouchers.length })}
|
||||
>
|
||||
<span className="text-muted-foreground" aria-hidden>
|
||||
=
|
||||
</span>
|
||||
{setVouchers.map((v, i) => (
|
||||
<Fragment key={v.journal_entry_id}>
|
||||
{i > 0 && (
|
||||
<span className="text-muted-foreground" aria-hidden>
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
<Link href={`/bookkeeping/${v.journal_entry_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
|
||||
{voucherOf(v) ?? v.journal_entry_id.slice(0, 8)}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('confidence', { percent: Math.round(p.confidence * 100) })}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-[12px] tabular-nums text-muted-foreground" data-ph-mask>
|
||||
{setVouchers.map((v) => formatCurrency(v.amount, currency)).join(' + ')}
|
||||
{sameDay ? ` · ${t('proposal_set_same_day')}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
} else if (item.proposal) {
|
||||
const p = item.proposal
|
||||
voucherCell = (
|
||||
|
||||
@@ -39,8 +39,8 @@ const TX_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const set = {
|
||||
vouchers: [
|
||||
{ journal_entry_id: JE_A, voucher_label: 'A57', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
|
||||
{ journal_entry_id: JE_B, voucher_label: 'A58', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
|
||||
{ journal_entry_id: JE_A, voucher_label: 'A57', voucher_series: 'A', voucher_number: 57, entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
|
||||
{ journal_entry_id: JE_B, voucher_label: 'A58', voucher_series: 'A', voucher_number: 58, entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
|
||||
],
|
||||
total: 88250,
|
||||
bank_account_number: '1930',
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Tests for detectExplainingVoucherSets, the batch form of the set detector
|
||||
* that the reconciliation view runs (#2293).
|
||||
*
|
||||
* The contract: one ledger scan over the union of the rows' windows, one
|
||||
* link-anchor lookup, and per row exactly the single detector's verdict
|
||||
* (exact öre sum of unlinked legs in the row's direction on the account,
|
||||
* within ±7 days, at most four vouchers); a voucher explains at most one row
|
||||
* per call; anything that cannot be judged fails open (no proposal).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { detectExplainingVoucherSets } from '../duplicate-payment-detection'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
type Leg = {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
journal_entry: {
|
||||
id: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
voucher_series: string
|
||||
voucher_number: number
|
||||
status: string
|
||||
source_type: string | null
|
||||
company_id: string
|
||||
}
|
||||
}
|
||||
|
||||
function leg(opts: {
|
||||
id: string
|
||||
date: string
|
||||
debit?: number
|
||||
credit?: number
|
||||
account?: string
|
||||
source_type?: string | null
|
||||
}): Leg {
|
||||
return {
|
||||
account_number: opts.account ?? '1930',
|
||||
debit_amount: opts.debit ?? 0,
|
||||
credit_amount: opts.credit ?? 0,
|
||||
journal_entry: {
|
||||
id: opts.id,
|
||||
entry_date: opts.date,
|
||||
description: `Voucher ${opts.id}`,
|
||||
voucher_series: opts.id[0],
|
||||
voucher_number: parseInt(opts.id.slice(1), 10) || 1,
|
||||
status: 'posted',
|
||||
source_type: opts.source_type === undefined ? 'invoice_paid' : opts.source_type,
|
||||
company_id: 'company-1',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** entries page, lines page, then the four link lookups (empty unless given). */
|
||||
function enqueueScan(
|
||||
legs: Leg[],
|
||||
links: { transactions?: unknown[]; junction?: unknown[]; invoicePayments?: unknown[] } = {},
|
||||
) {
|
||||
const entries = [...new Map(legs.map((l) => [l.journal_entry.id, l.journal_entry])).values()]
|
||||
enqueue({ data: entries })
|
||||
if (entries.length === 0) return
|
||||
enqueue({
|
||||
data: legs.map((l, i) => ({
|
||||
id: `line-${i}`,
|
||||
journal_entry_id: l.journal_entry.id,
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
})),
|
||||
})
|
||||
enqueue({ data: links.invoicePayments ?? [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: links.transactions ?? [] })
|
||||
enqueue({ data: links.junction ?? [] })
|
||||
}
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const row = (id: string, date: string, amount: number) => ({ id, date, amount, currency: 'SEK' })
|
||||
|
||||
async function run(transactions: ReturnType<typeof row>[]) {
|
||||
return detectExplainingVoucherSets(supabase as never, {
|
||||
companyId: COMPANY,
|
||||
bankAccountNumber: '1930',
|
||||
transactions,
|
||||
})
|
||||
}
|
||||
|
||||
describe('detectExplainingVoucherSets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
it('explains the Bankgirot aggregate with the two mark-paid vouchers in ONE scan', async () => {
|
||||
enqueueScan([
|
||||
leg({ id: 'A57', date: '2026-07-31', debit: 62500 }),
|
||||
leg({ id: 'A58', date: '2026-07-31', debit: 25750 }),
|
||||
leg({ id: 'A56', date: '2026-07-20', debit: 150 }),
|
||||
])
|
||||
|
||||
const sets = await run([row('tx-bg', '2026-07-31', 88250), row('tx-other', '2026-07-30', -999)])
|
||||
|
||||
const set = sets.get('tx-bg')
|
||||
expect(set).toBeDefined()
|
||||
expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A58'])
|
||||
expect(set!.vouchers[0]).toMatchObject({ voucher_series: 'A', voucher_number: 57, voucher_label: 'A57' })
|
||||
expect(set!.same_date).toBe(true)
|
||||
expect(set!.total).toBe(88250)
|
||||
expect(sets.has('tx-other')).toBe(false)
|
||||
// One entries query, one lines query, four anchor lookups: never per row.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(6)
|
||||
// The scan covers the union of the rows' ±7-day windows and the account.
|
||||
expect(findCalls('journal_entries', 'gte')).toEqual([['entry_date', '2026-07-23']])
|
||||
expect(findCalls('journal_entries', 'lte')).toEqual([['entry_date', '2026-08-07']])
|
||||
expect(findCalls('journal_entry_lines', 'eq')).toEqual([['account_number', '1930']])
|
||||
})
|
||||
|
||||
it('returns nothing when no set of vouchers sums exactly to a row', async () => {
|
||||
enqueueScan([leg({ id: 'A1', date: '2026-07-31', debit: 62500 }), leg({ id: 'A2', date: '2026-07-31', debit: 25000 })])
|
||||
|
||||
const sets = await run([row('tx-bg', '2026-07-31', 88250)])
|
||||
|
||||
expect(sets.size).toBe(0)
|
||||
})
|
||||
|
||||
it('sums only legs in the row direction and inside the row window', async () => {
|
||||
enqueueScan([
|
||||
// Right amount, wrong direction (a credit cannot explain money in).
|
||||
leg({ id: 'A1', date: '2026-07-31', credit: 62500 }),
|
||||
leg({ id: 'A2', date: '2026-07-31', debit: 25750 }),
|
||||
// Right amount, eight days off: outside this row's ±7-day window even
|
||||
// though the batch scan fetched it for another row.
|
||||
leg({ id: 'A3', date: '2026-08-08', debit: 62500 }),
|
||||
// Within the window: explains the row together with A2.
|
||||
leg({ id: 'A4', date: '2026-08-06', debit: 62500 }),
|
||||
])
|
||||
|
||||
const sets = await run([row('tx-bg', '2026-07-31', 88250), row('tx-late', '2026-08-10', 1)])
|
||||
|
||||
const set = sets.get('tx-bg')
|
||||
expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A2', 'A4'])
|
||||
expect(set!.same_date).toBe(false)
|
||||
})
|
||||
|
||||
it('stops at four vouchers per set', async () => {
|
||||
enqueueScan([
|
||||
leg({ id: 'A1', date: '2026-07-31', debit: 100 }),
|
||||
leg({ id: 'A2', date: '2026-07-31', debit: 200 }),
|
||||
leg({ id: 'A3', date: '2026-07-31', debit: 300 }),
|
||||
leg({ id: 'A4', date: '2026-07-31', debit: 400 }),
|
||||
leg({ id: 'A5', date: '2026-07-31', debit: 5000 }),
|
||||
])
|
||||
|
||||
// 6000 needs all five legs; 1000 is exactly the four small ones.
|
||||
const sets = await run([row('tx-five', '2026-07-31', 6000), row('tx-four', '2026-07-31', 1000)])
|
||||
|
||||
expect(sets.has('tx-five')).toBe(false)
|
||||
expect(sets.get('tx-four')!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A1', 'A2', 'A3', 'A4'])
|
||||
})
|
||||
|
||||
it('lets a voucher explain at most one row, same-date claims first', async () => {
|
||||
enqueueScan([leg({ id: 'A1', date: '2026-07-31', debit: 1000 }), leg({ id: 'A2', date: '2026-07-28', debit: 1000 })])
|
||||
|
||||
// Both rows could take A1; the same-date row gets it, the other re-searches and takes A2.
|
||||
const sets = await run([row('tx-early', '2026-07-29', 1000), row('tx-same', '2026-07-31', 1000)])
|
||||
|
||||
expect(sets.get('tx-same')!.vouchers.map((v) => v.journal_entry_id)).toEqual(['A1'])
|
||||
expect(sets.get('tx-early')!.vouchers.map((v) => v.journal_entry_id)).toEqual(['A2'])
|
||||
})
|
||||
|
||||
it('drops a voucher when nothing is left for the second claim', async () => {
|
||||
enqueueScan([leg({ id: 'A1', date: '2026-07-31', debit: 1000 })])
|
||||
|
||||
const sets = await run([row('tx-a', '2026-07-31', 1000), row('tx-b', '2026-07-31', 1000)])
|
||||
|
||||
expect(sets.size).toBe(1)
|
||||
expect(sets.get('tx-a')!.vouchers.map((v) => v.journal_entry_id)).toEqual(['A1'])
|
||||
})
|
||||
|
||||
it('drops vouchers a bank transaction already explains, but never the rows being explained', async () => {
|
||||
enqueueScan(
|
||||
[
|
||||
leg({ id: 'A57', date: '2026-07-31', debit: 62500 }),
|
||||
leg({ id: 'A58', date: '2026-07-31', debit: 25750 }),
|
||||
leg({ id: 'A59', date: '2026-07-31', debit: 25750 }),
|
||||
],
|
||||
{
|
||||
// A58 is settled by another row; A59 is "linked" only from a row in
|
||||
// this batch (a stale pointer the batch is explaining), which does not count.
|
||||
transactions: [
|
||||
{ id: 'tx-elsewhere', journal_entry_id: 'A58' },
|
||||
{ id: 'tx-bg', journal_entry_id: 'A59' },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const sets = await run([row('tx-bg', '2026-07-31', 88250)])
|
||||
|
||||
expect(sets.get('tx-bg')!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A59'])
|
||||
})
|
||||
|
||||
it('never sums storno, correction or opening-balance entries', async () => {
|
||||
enqueueScan([
|
||||
leg({ id: 'A1', date: '2026-07-31', debit: 500, source_type: 'storno' }),
|
||||
leg({ id: 'A2', date: '2026-07-31', debit: 500, source_type: 'correction' }),
|
||||
leg({ id: 'A3', date: '2026-07-31', debit: 1000, source_type: 'opening_balance' }),
|
||||
])
|
||||
|
||||
const sets = await run([row('tx', '2026-07-31', 1000)])
|
||||
|
||||
expect(sets.size).toBe(0)
|
||||
// Scaffolding never reaches the anchor lookups either.
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('skips rows that cannot be stated in SEK or carry no amount, without scanning for them', async () => {
|
||||
const sets = await detectExplainingVoucherSets(supabase as never, {
|
||||
companyId: COMPANY,
|
||||
bankAccountNumber: '1930',
|
||||
transactions: [
|
||||
{ id: 'tx-eur', date: '2026-07-31', amount: 100, currency: 'EUR' },
|
||||
{ id: 'tx-zero', date: '2026-07-31', amount: 0, currency: 'SEK' },
|
||||
{ id: 'tx-bad-date', date: 'not-a-date', amount: 100, currency: 'SEK' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(sets.size).toBe(0)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails open (nothing) when a link lookup resolves with an error', async () => {
|
||||
const legs = [leg({ id: 'A1', date: '2026-07-31', debit: 1000 })]
|
||||
const entries = legs.map((l) => l.journal_entry)
|
||||
enqueue({ data: entries })
|
||||
enqueue({
|
||||
data: legs.map((l, i) => ({
|
||||
id: `line-${i}`,
|
||||
journal_entry_id: l.journal_entry.id,
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
})),
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const sets = await run([row('tx', '2026-07-31', 1000)])
|
||||
|
||||
expect(sets.size).toBe(0)
|
||||
})
|
||||
|
||||
it('fails open (nothing) when the ledger scan throws', async () => {
|
||||
supabase.from.mockImplementationOnce(() => {
|
||||
throw new Error('db down')
|
||||
})
|
||||
|
||||
const sets = await run([row('tx', '2026-07-31', 1000)])
|
||||
|
||||
expect(sets.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -287,9 +287,16 @@ const SET_DATE_WINDOW_DAYS = 7
|
||||
/** Largest set of vouchers offered as the explanation of one bank row. */
|
||||
export const EXPLAINING_SET_MAX_VOUCHERS = 4
|
||||
|
||||
const DAY_MS = 24 * 3600 * 1000
|
||||
|
||||
/** Max journal_entry ids per link-anchor `.in()` filter: keeps the request URL short. */
|
||||
const LINK_LOOKUP_CHUNK = 100
|
||||
|
||||
export interface ExplainingVoucher {
|
||||
journal_entry_id: string
|
||||
voucher_label: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string | null
|
||||
source_type: string | null
|
||||
@@ -318,42 +325,63 @@ export interface DetectSetArgs extends DetectArgs {
|
||||
bankAccountNumber?: string | null
|
||||
}
|
||||
|
||||
/** A bank leg with its parent entry, as {@link fetchBankLegs} returns it. */
|
||||
interface BankLegRow {
|
||||
account_number: string
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
journal_entry: {
|
||||
id: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
status: string
|
||||
source_type: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type SetCandidate = ExplainingVoucher & { dateDistanceDays: number; id: string }
|
||||
|
||||
type PaymentLinkRow = { journal_entry_id: string | null; transaction_id: string | null }
|
||||
|
||||
/** The row's SEK target, direction and ±window; null when it cannot take part. */
|
||||
interface SetTarget {
|
||||
targetSek: number
|
||||
/** Money in: the voucher DEBITS the bank account. Money out: it CREDITS it. */
|
||||
inbound: boolean
|
||||
dateMs: number
|
||||
lowDate: string
|
||||
highDate: string
|
||||
}
|
||||
|
||||
function isoDateOffset(dateMs: number, days: number): string {
|
||||
return new Date(dateMs + days * DAY_MS).toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reversals, corrections and opening balances are bookkeeping scaffolding,
|
||||
* never the payment itself (the reconciliation RPCs drop the same three).
|
||||
*/
|
||||
function isScaffoldingEntry(sourceType: string | null): boolean {
|
||||
return sourceType === 'storno' || sourceType === 'correction' || sourceType === 'opening_balance'
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the vouchers that already book this bank row, allowing the row to be
|
||||
* explained by SEVERAL of them.
|
||||
*
|
||||
* The 1:1 detector above answers "is there one voucher of this amount?". A
|
||||
* bank feed regularly delivers one row for several affärshändelser (a
|
||||
* Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row
|
||||
* with no payer and no reference), and each of those may already be booked on
|
||||
* its own: "Markera som betald" per invoice, one salary voucher per employee.
|
||||
* Nothing on the account then equals the row, the 1:1 check passes, and the
|
||||
* next door (a batch allocation, a fresh categorisation) books the same
|
||||
* money a second time. That is exactly the double booking this catches.
|
||||
*
|
||||
* Deterministic on purpose: the only signal is an exact öre sum of unlinked
|
||||
* bank legs in the row's direction, on the row's account, within ±7 days.
|
||||
* No counterparty text is consulted: the bank rows this exists for carry
|
||||
* none. A voucher counts as linked (and drops out) when a transaction points
|
||||
* at it, a payment row with a bank transaction references it, or a
|
||||
* transaction_voucher_links row anchors it: the same three storage
|
||||
* locations isTransactionBooked reads, seen from the voucher side. A payment
|
||||
* row WITHOUT a bank transaction is a manual settlement (#2019) and keeps the
|
||||
* voucher in play: its bank line is precisely what has not been matched yet.
|
||||
*
|
||||
* Returns null when the row cannot be stated in SEK (a foreign row with no
|
||||
* stored rate): a set cannot be summed in an unknown unit, and the 1:1
|
||||
* detector's `amount_verified: false` path already surfaces that case.
|
||||
*/
|
||||
export async function detectExplainingVoucherSet(
|
||||
supabase: SupabaseClient,
|
||||
args: DetectSetArgs,
|
||||
): Promise<ExplainingVoucherSet | null> {
|
||||
const { companyId, transactionId, transactionDate, transactionAmount } = args
|
||||
if (Math.round(Math.abs(transactionAmount) * 100) === 0) return null
|
||||
|
||||
function prepareSetTarget(args: {
|
||||
transactionDate: string
|
||||
transactionAmount: number
|
||||
transactionCurrency: string | null
|
||||
transactionAmountSek?: number | null
|
||||
transactionExchangeRate?: number | null
|
||||
}): SetTarget | null {
|
||||
if (Math.round(Math.abs(args.transactionAmount) * 100) === 0) return null
|
||||
const signedSek = resolveTransactionAmountSek({
|
||||
amount: transactionAmount,
|
||||
amount: args.transactionAmount,
|
||||
currency: args.transactionCurrency,
|
||||
amount_sek: args.transactionAmountSek,
|
||||
exchange_rate: args.transactionExchangeRate,
|
||||
@@ -361,83 +389,73 @@ export async function detectExplainingVoucherSet(
|
||||
if (signedSek === null) return null
|
||||
const targetSek = roundOre(Math.abs(signedSek))
|
||||
if (targetSek === 0) return null
|
||||
|
||||
const dateMs = new Date(transactionDate).getTime()
|
||||
const dateMs = new Date(args.transactionDate).getTime()
|
||||
if (Number.isNaN(dateMs)) return null
|
||||
const lowDate = new Date(dateMs - SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const highDate = new Date(dateMs + SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
return {
|
||||
targetSek,
|
||||
inbound: args.transactionAmount > 0,
|
||||
dateMs,
|
||||
lowDate: isoDateOffset(dateMs, -SET_DATE_WINDOW_DAYS),
|
||||
highDate: isoDateOffset(dateMs, SET_DATE_WINDOW_DAYS),
|
||||
}
|
||||
}
|
||||
|
||||
// Money in: the voucher DEBITS the bank account. Money out: it CREDITS it.
|
||||
const inbound = transactionAmount > 0
|
||||
/**
|
||||
* Posted legs on the settlement account (or the whole 19xx range) dated
|
||||
* within [lowDate, highDate]. `inbound` true fetches debits, false credits,
|
||||
* null both (the batch path filters direction per row in memory). Throws
|
||||
* when the read fails; callers fail open.
|
||||
*/
|
||||
async function fetchBankLegs(
|
||||
supabase: SupabaseClient,
|
||||
args: {
|
||||
companyId: string
|
||||
lowDate: string
|
||||
highDate: string
|
||||
bankAccountNumber: string | null
|
||||
inbound: boolean | null
|
||||
},
|
||||
): Promise<BankLegRow[]> {
|
||||
const account = args.bankAccountNumber?.trim() || null
|
||||
return fetchEntryLines<BankLegRow>({
|
||||
supabase,
|
||||
entryColumns:
|
||||
'id, entry_date, description, voucher_series, voucher_number, status, source_type, company_id',
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', args.companyId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', args.lowDate)
|
||||
.lte('entry_date', args.highDate),
|
||||
filterLines: (q: EntryLinesQuery) => {
|
||||
const scoped = account
|
||||
? q.eq('account_number', account)
|
||||
: q.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH))
|
||||
if (args.inbound === null) return scoped
|
||||
return args.inbound ? scoped.gt('debit_amount', 0) : scoped.gt('credit_amount', 0)
|
||||
},
|
||||
attachEntriesAs: 'journal_entry',
|
||||
})
|
||||
}
|
||||
|
||||
type SetLineRow = {
|
||||
account_number: string
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
journal_entry: {
|
||||
id: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
status: string
|
||||
source_type: string | null
|
||||
}
|
||||
}
|
||||
|
||||
let lines: SetLineRow[]
|
||||
try {
|
||||
lines = await fetchEntryLines<SetLineRow>({
|
||||
supabase,
|
||||
entryColumns:
|
||||
'id, entry_date, description, voucher_series, voucher_number, status, source_type, company_id',
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', lowDate)
|
||||
.lte('entry_date', highDate),
|
||||
filterLines: (q: EntryLinesQuery) => {
|
||||
const scoped = account
|
||||
? q.eq('account_number', account)
|
||||
: q.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH))
|
||||
return inbound ? scoped.gt('debit_amount', 0) : scoped.gt('credit_amount', 0)
|
||||
},
|
||||
attachEntriesAs: 'journal_entry',
|
||||
})
|
||||
} catch {
|
||||
// Fail-open like the 1:1 detector: a detection failure must not block a
|
||||
// booking. Callers log the miss.
|
||||
return null
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
|
||||
// Reversals, corrections and opening balances are bookkeeping scaffolding,
|
||||
// never the payment itself (the reconciliation RPCs drop the same three).
|
||||
const legs = lines.filter(
|
||||
(l) =>
|
||||
l.journal_entry.source_type !== 'storno' &&
|
||||
l.journal_entry.source_type !== 'correction' &&
|
||||
l.journal_entry.source_type !== 'opening_balance',
|
||||
)
|
||||
if (legs.length === 0) return null
|
||||
|
||||
// One candidate per voucher and account: a voucher with two legs on the
|
||||
// same account (a split payment line) is summed, a voucher touching two
|
||||
// bank accounts (a transfer) keeps its largest leg so it can appear once.
|
||||
type Candidate = ExplainingVoucher & { dateDistanceDays: number; id: string }
|
||||
const byEntry = new Map<string, Candidate>()
|
||||
/**
|
||||
* Pure: the candidates for ONE bank row from legs already fetched. One
|
||||
* candidate per voucher and account: a voucher with two legs on the same
|
||||
* account (a split payment line) is summed, a voucher touching two bank
|
||||
* accounts (a transfer) keeps its largest leg so it can appear once. Legs
|
||||
* outside the row's window, in the wrong direction, or on scaffolding
|
||||
* entries never take part.
|
||||
*/
|
||||
function collectSetCandidates(legs: BankLegRow[], target: SetTarget): Map<string, SetCandidate> {
|
||||
const byEntry = new Map<string, SetCandidate>()
|
||||
for (const leg of legs) {
|
||||
const raw = inbound ? leg.debit_amount : leg.credit_amount
|
||||
const entry = leg.journal_entry
|
||||
if (isScaffoldingEntry(entry.source_type)) continue
|
||||
if (entry.entry_date < target.lowDate || entry.entry_date > target.highDate) continue
|
||||
const raw = target.inbound ? leg.debit_amount : leg.credit_amount
|
||||
const amount = roundOre(Number(raw))
|
||||
if (!(amount > 0)) continue
|
||||
const entry = leg.journal_entry
|
||||
const existing = byEntry.get(entry.id)
|
||||
if (existing && existing.bank_account_number === leg.account_number) {
|
||||
existing.amount = roundOre(existing.amount + amount)
|
||||
@@ -449,6 +467,8 @@ export async function detectExplainingVoucherSet(
|
||||
id: entry.id,
|
||||
journal_entry_id: entry.id,
|
||||
voucher_label: `${entry.voucher_series ?? 'A'}${entry.voucher_number ?? ''}`,
|
||||
voucher_series: entry.voucher_series,
|
||||
voucher_number: entry.voucher_number,
|
||||
entry_date: entry.entry_date,
|
||||
description: entry.description,
|
||||
source_type: entry.source_type,
|
||||
@@ -456,66 +476,91 @@ export async function detectExplainingVoucherSet(
|
||||
bank_account_number: leg.account_number,
|
||||
dateDistanceDays: Number.isNaN(entryMs)
|
||||
? SET_DATE_WINDOW_DAYS
|
||||
: Math.round(Math.abs(entryMs - dateMs) / (24 * 3600 * 1000)),
|
||||
: Math.round(Math.abs(entryMs - target.dateMs) / DAY_MS),
|
||||
})
|
||||
}
|
||||
if (byEntry.size === 0) return null
|
||||
|
||||
// Drop vouchers a bank transaction already explains, through any of the
|
||||
// three anchors. All four lookups are company-scoped (defense in depth).
|
||||
const entryIds = Array.from(byEntry.keys())
|
||||
const [paymentLinksRes, supplierPaymentLinksRes, txLinksRes, junctionLinksRes] =
|
||||
await Promise.all([
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('journal_entry_id, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('journal_entry_id, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
])
|
||||
// A PostgREST failure resolves with { data: null, error } rather than
|
||||
// throwing. Reading that as "no links" would offer a voucher a bank row
|
||||
// already settles, so a failed lookup fails open (null) like a thrown one:
|
||||
// the guard stays advisory and the booking RPC keeps the last word.
|
||||
if (paymentLinksRes.error || supplierPaymentLinksRes.error || txLinksRes.error || junctionLinksRes.error) {
|
||||
return null
|
||||
}
|
||||
const paymentLinks = paymentLinksRes.data
|
||||
const supplierPaymentLinks = supplierPaymentLinksRes.data
|
||||
const txLinks = txLinksRes.data
|
||||
const junctionLinks = junctionLinksRes.data
|
||||
return byEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* Voucher ids a bank transaction already explains, through any of the three
|
||||
* anchors isTransactionBooked reads (transactions.journal_entry_id, a payment
|
||||
* row carrying a bank transaction, a transaction_voucher_links row). A payment
|
||||
* row WITHOUT a bank transaction is a manual settlement (#2019) and keeps the
|
||||
* voucher in play: its bank line is precisely what has not been matched yet.
|
||||
* Rows in `ownTransactionIds` never count as links: the guard runs before the
|
||||
* caller's row is linked, and the batch path passes the very rows it is
|
||||
* explaining. Every lookup is company-scoped (defense in depth) and chunked
|
||||
* so a wide window never pushes the .in() past URL limits.
|
||||
*
|
||||
* Returns null when a lookup resolves with an error: a PostgREST failure
|
||||
* resolves with { data: null, error } rather than throwing, and reading that
|
||||
* as "no links" would offer a voucher a bank row already settles. Null fails
|
||||
* open like a thrown scan: the guard stays advisory and the booking RPC
|
||||
* keeps the last word.
|
||||
*/
|
||||
async function fetchExplainedVoucherIds(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
entryIds: string[],
|
||||
ownTransactionIds: ReadonlySet<string>,
|
||||
): Promise<Set<string> | null> {
|
||||
const linkedIds = new Set<string>()
|
||||
for (const row of [...((paymentLinks ?? []) as PaymentLinkRow[]), ...((supplierPaymentLinks ?? []) as PaymentLinkRow[])]) {
|
||||
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 }[]) {
|
||||
// The caller's own row is never a link: the guard runs before it is linked.
|
||||
if (row.journal_entry_id && row.id !== transactionId) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
for (const row of (junctionLinks ?? []) as { journal_entry_id: string | null }[]) {
|
||||
if (row.journal_entry_id) linkedIds.add(row.journal_entry_id)
|
||||
for (let i = 0; i < entryIds.length; i += LINK_LOOKUP_CHUNK) {
|
||||
const chunk = entryIds.slice(i, i + LINK_LOOKUP_CHUNK)
|
||||
const [paymentLinksRes, supplierPaymentLinksRes, txLinksRes, junctionLinksRes] =
|
||||
await Promise.all([
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('journal_entry_id, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', chunk),
|
||||
supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('journal_entry_id, transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', chunk),
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', chunk),
|
||||
supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', chunk),
|
||||
])
|
||||
if (paymentLinksRes.error || supplierPaymentLinksRes.error || txLinksRes.error || junctionLinksRes.error) {
|
||||
return null
|
||||
}
|
||||
for (const row of [
|
||||
...((paymentLinksRes.data ?? []) as PaymentLinkRow[]),
|
||||
...((supplierPaymentLinksRes.data ?? []) as PaymentLinkRow[]),
|
||||
]) {
|
||||
if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
for (const row of (txLinksRes.data ?? []) as { id: string; journal_entry_id: string | null }[]) {
|
||||
if (row.journal_entry_id && !ownTransactionIds.has(row.id)) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
for (const row of (junctionLinksRes.data ?? []) as { journal_entry_id: string | null }[]) {
|
||||
if (row.journal_entry_id) linkedIds.add(row.journal_entry_id)
|
||||
}
|
||||
}
|
||||
return linkedIds
|
||||
}
|
||||
|
||||
const pool = Array.from(byEntry.values()).filter((c) => !linkedIds.has(c.journal_entry_id))
|
||||
if (pool.length === 0) return null
|
||||
|
||||
// Sets never mix accounts: the link that resolves the warning is made on
|
||||
// one settlement account. Search per account, closest account first.
|
||||
/**
|
||||
* Pure: the best set on one settlement account. Sets never mix accounts: the
|
||||
* link that resolves the warning is made on one account. Closest account
|
||||
* first, then findExactCoveringSet's order (smallest set, then closest in
|
||||
* date).
|
||||
*/
|
||||
function pickExplainingSet(
|
||||
pool: SetCandidate[],
|
||||
targetSek: number,
|
||||
transactionDate: string,
|
||||
): ExplainingVoucherSet | null {
|
||||
const accounts = Array.from(new Set(pool.map((c) => c.bank_account_number))).sort()
|
||||
for (const accountNumber of accounts) {
|
||||
const set = findExactCoveringSet(
|
||||
@@ -537,7 +582,181 @@ export async function detectExplainingVoucherSet(
|
||||
return null
|
||||
}
|
||||
|
||||
type PaymentLinkRow = { journal_entry_id: string | null; transaction_id: string | null }
|
||||
/**
|
||||
* Find the vouchers that already book this bank row, allowing the row to be
|
||||
* explained by SEVERAL of them.
|
||||
*
|
||||
* The 1:1 detector above answers "is there one voucher of this amount?". A
|
||||
* bank feed regularly delivers one row for several affärshändelser (a
|
||||
* Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row
|
||||
* with no payer and no reference), and each of those may already be booked on
|
||||
* its own: "Markera som betald" per invoice, one salary voucher per employee.
|
||||
* Nothing on the account then equals the row, the 1:1 check passes, and the
|
||||
* next door (a batch allocation, a fresh categorisation) books the same
|
||||
* money a second time. That is exactly the double booking this catches.
|
||||
*
|
||||
* Deterministic on purpose: the only signal is an exact öre sum of unlinked
|
||||
* bank legs in the row's direction, on the row's account, within ±7 days.
|
||||
* No counterparty text is consulted: the bank rows this exists for carry
|
||||
* none. A voucher counts as linked (and drops out) when a transaction points
|
||||
* at it, a payment row with a bank transaction references it, or a
|
||||
* transaction_voucher_links row anchors it: the same three storage
|
||||
* locations isTransactionBooked reads, seen from the voucher side
|
||||
* (fetchExplainedVoucherIds).
|
||||
*
|
||||
* Composed of the same steps the batch form (detectExplainingVoucherSets)
|
||||
* runs per row, so the booking doors and the reconciliation view share one
|
||||
* definition of "explained by the ledger".
|
||||
*/
|
||||
export async function detectExplainingVoucherSet(
|
||||
supabase: SupabaseClient,
|
||||
args: DetectSetArgs,
|
||||
): Promise<ExplainingVoucherSet | null> {
|
||||
const target = prepareSetTarget(args)
|
||||
if (!target) return null
|
||||
|
||||
let legs: BankLegRow[]
|
||||
try {
|
||||
legs = await fetchBankLegs(supabase, {
|
||||
companyId: args.companyId,
|
||||
lowDate: target.lowDate,
|
||||
highDate: target.highDate,
|
||||
bankAccountNumber: args.bankAccountNumber ?? null,
|
||||
inbound: target.inbound,
|
||||
})
|
||||
} catch {
|
||||
// Fail-open like the 1:1 detector: a detection failure must not block a
|
||||
// booking. Callers log the miss.
|
||||
return null
|
||||
}
|
||||
if (legs.length === 0) return null
|
||||
|
||||
const candidates = collectSetCandidates(legs, target)
|
||||
if (candidates.size === 0) return null
|
||||
|
||||
const linkedIds = await fetchExplainedVoucherIds(
|
||||
supabase,
|
||||
args.companyId,
|
||||
Array.from(candidates.keys()),
|
||||
new Set([args.transactionId]),
|
||||
)
|
||||
if (!linkedIds) return null
|
||||
|
||||
const pool = Array.from(candidates.values()).filter((c) => !linkedIds.has(c.journal_entry_id))
|
||||
if (pool.length === 0) return null
|
||||
return pickExplainingSet(pool, target.targetSek, args.transactionDate)
|
||||
}
|
||||
|
||||
/** The transaction columns the batch detector needs. */
|
||||
export interface ExplainingSetBatchRow {
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string | null
|
||||
amount_sek?: number | null
|
||||
exchange_rate?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The set detector over MANY rows of one settlement account with one ledger
|
||||
* scan: the read path's form (#2293). The bridge table asks "is any open row
|
||||
* explained by the ledger?" for every row it lists; running the single
|
||||
* detector per row would cost five queries a row. This fetches the account's
|
||||
* legs once over the union of the rows' windows and the link anchors once,
|
||||
* then evaluates each row exactly as detectExplainingVoucherSet does (same
|
||||
* candidates, same anchors, same search).
|
||||
*
|
||||
* A voucher explains at most ONE row per call. The 1:N link that confirms a
|
||||
* proposal does not refuse a voucher another row already settles (manualLink
|
||||
* documents why N:1 is allowed), so two rows proposing the same voucher would
|
||||
* both confirm cleanly and settle it twice. Rows are assigned greedily:
|
||||
* same-date sets first, then smaller sets, then older rows; a row whose set
|
||||
* was partly consumed is searched again against what is left.
|
||||
*
|
||||
* Rows that cannot be stated in SEK or carry no amount are skipped. Fails
|
||||
* open (empty map) when the scan or a link lookup fails, like the single
|
||||
* detector; callers log the miss.
|
||||
*/
|
||||
export async function detectExplainingVoucherSets(
|
||||
supabase: SupabaseClient,
|
||||
args: { companyId: string; bankAccountNumber: string | null; transactions: ExplainingSetBatchRow[] },
|
||||
): Promise<Map<string, ExplainingVoucherSet>> {
|
||||
const result = new Map<string, ExplainingVoucherSet>()
|
||||
|
||||
const prepared: Array<{ row: ExplainingSetBatchRow; target: SetTarget }> = []
|
||||
for (const row of args.transactions) {
|
||||
const target = prepareSetTarget({
|
||||
transactionDate: row.date,
|
||||
transactionAmount: Number(row.amount),
|
||||
transactionCurrency: row.currency,
|
||||
transactionAmountSek: row.amount_sek ?? null,
|
||||
transactionExchangeRate: row.exchange_rate ?? null,
|
||||
})
|
||||
if (target) prepared.push({ row, target })
|
||||
}
|
||||
if (prepared.length === 0) return result
|
||||
|
||||
let lowDate = prepared[0].target.lowDate
|
||||
let highDate = prepared[0].target.highDate
|
||||
for (const { target } of prepared) {
|
||||
if (target.lowDate < lowDate) lowDate = target.lowDate
|
||||
if (target.highDate > highDate) highDate = target.highDate
|
||||
}
|
||||
|
||||
let legs: BankLegRow[]
|
||||
try {
|
||||
legs = await fetchBankLegs(supabase, {
|
||||
companyId: args.companyId,
|
||||
lowDate,
|
||||
highDate,
|
||||
bankAccountNumber: args.bankAccountNumber,
|
||||
inbound: null,
|
||||
})
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
const entryIds = Array.from(
|
||||
new Set(legs.filter((l) => !isScaffoldingEntry(l.journal_entry.source_type)).map((l) => l.journal_entry.id)),
|
||||
)
|
||||
if (entryIds.length === 0) return result
|
||||
|
||||
const linkedIds = await fetchExplainedVoucherIds(
|
||||
supabase,
|
||||
args.companyId,
|
||||
entryIds,
|
||||
new Set(prepared.map((p) => p.row.id)),
|
||||
)
|
||||
if (!linkedIds) return result
|
||||
|
||||
const consumed = new Set<string>()
|
||||
const search = ({ row, target }: { row: ExplainingSetBatchRow; target: SetTarget }) => {
|
||||
const pool = Array.from(collectSetCandidates(legs, target).values()).filter(
|
||||
(c) => !linkedIds.has(c.journal_entry_id) && !consumed.has(c.journal_entry_id),
|
||||
)
|
||||
return pool.length === 0 ? null : pickExplainingSet(pool, target.targetSek, row.date)
|
||||
}
|
||||
|
||||
// Every row against the whole pool first, then the strongest claims settle first.
|
||||
const claims: Array<{ row: ExplainingSetBatchRow; target: SetTarget; set: ExplainingVoucherSet }> = []
|
||||
for (const p of prepared) {
|
||||
const set = search(p)
|
||||
if (set) claims.push({ ...p, set })
|
||||
}
|
||||
claims.sort(
|
||||
(a, b) =>
|
||||
Number(b.set.same_date) - Number(a.set.same_date) ||
|
||||
a.set.vouchers.length - b.set.vouchers.length ||
|
||||
a.row.date.localeCompare(b.row.date) ||
|
||||
a.row.id.localeCompare(b.row.id),
|
||||
)
|
||||
for (const claim of claims) {
|
||||
const set = claim.set.vouchers.some((v) => consumed.has(v.journal_entry_id)) ? search(claim) : claim.set
|
||||
if (!set) continue
|
||||
result.set(claim.row.id, set)
|
||||
for (const v of set.vouchers) consumed.add(v.journal_entry_id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The transaction columns the set detector needs; a caller that already holds the row passes it. */
|
||||
export interface TransactionForExplaining {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Tests for the covering-set candidate source of the bridge table (#2293):
|
||||
* the adapter between the batch set detector and the proposal shape the
|
||||
* reconciliation surface renders and confirms.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { ExplainingVoucherSet } from '@/lib/invoices/duplicate-payment-detection'
|
||||
|
||||
const detectMock = vi.fn()
|
||||
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
|
||||
detectExplainingVoucherSets: (...args: unknown[]) => detectMock(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
coveringSetProposal,
|
||||
proposeCoveringSets,
|
||||
COVERING_SET_SAME_DATE_CONFIDENCE,
|
||||
COVERING_SET_WINDOW_CONFIDENCE,
|
||||
} from '../covering-set-candidate'
|
||||
|
||||
const supabase = {} as never
|
||||
const COMPANY = 'company-1'
|
||||
const SEK_ACCOUNT = { ledger_account: '1930', currency: 'SEK' }
|
||||
|
||||
function voucher(id: string, amount: number, date: string, description: string | null = `Voucher ${id}`) {
|
||||
return {
|
||||
journal_entry_id: id,
|
||||
voucher_label: id,
|
||||
voucher_series: id[0],
|
||||
voucher_number: parseInt(id.slice(1), 10),
|
||||
entry_date: date,
|
||||
description,
|
||||
source_type: 'invoice_paid',
|
||||
amount,
|
||||
bank_account_number: '1930',
|
||||
}
|
||||
}
|
||||
|
||||
function set(vouchers: ReturnType<typeof voucher>[], sameDate: boolean): ExplainingVoucherSet {
|
||||
return {
|
||||
vouchers,
|
||||
total: vouchers.reduce((s, v) => s + v.amount, 0),
|
||||
bank_account_number: '1930',
|
||||
same_date: sameDate,
|
||||
}
|
||||
}
|
||||
|
||||
describe('coveringSetProposal', () => {
|
||||
it('renders a same-date set as strong as an exact 1:1 match, with every voucher listed', () => {
|
||||
const proposal = coveringSetProposal(
|
||||
set([voucher('A57', 62500, '2026-07-31', 'Inbetalning 063'), voucher('A58', 25750, '2026-07-31', 'Inbetalning 064')], true),
|
||||
)
|
||||
|
||||
expect(proposal).toEqual({
|
||||
journal_entry_id: 'A57',
|
||||
voucher_number: 57,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-07-31',
|
||||
description: 'Inbetalning 063 + Inbetalning 064',
|
||||
entry_status: 'posted',
|
||||
confidence: COVERING_SET_SAME_DATE_CONFIDENCE,
|
||||
reasons: ['exact_sum_same_date'],
|
||||
vouchers: [
|
||||
{ journal_entry_id: 'A57', voucher_number: 57, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 063', amount: 62500 },
|
||||
{ journal_entry_id: 'A58', voucher_number: 58, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 064', amount: 25750 },
|
||||
],
|
||||
})
|
||||
expect(COVERING_SET_SAME_DATE_CONFIDENCE).toBe(0.95)
|
||||
})
|
||||
|
||||
it('keeps a within-window set below the unattended auto-apply floor', () => {
|
||||
const proposal = coveringSetProposal(set([voucher('A3', 1000, '2026-07-28', null)], false))
|
||||
|
||||
expect(proposal.confidence).toBe(COVERING_SET_WINDOW_CONFIDENCE)
|
||||
expect(COVERING_SET_WINDOW_CONFIDENCE).toBeLessThan(0.9)
|
||||
expect(proposal.reasons).toEqual(['exact_sum_within_window'])
|
||||
expect(proposal.description).toBe('')
|
||||
expect(proposal.vouchers).toEqual([
|
||||
{ journal_entry_id: 'A3', voucher_number: 3, voucher_series: 'A', entry_date: '2026-07-28', description: '', amount: 1000 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposeCoveringSets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
detectMock.mockResolvedValue(new Map())
|
||||
})
|
||||
|
||||
it('runs the batch detector on the account and maps every set to a proposal', async () => {
|
||||
detectMock.mockResolvedValue(
|
||||
new Map([['tx-bg', set([voucher('A57', 62500, '2026-07-31'), voucher('A58', 25750, '2026-07-31')], true)]]),
|
||||
)
|
||||
|
||||
const proposals = await proposeCoveringSets(supabase, COMPANY, SEK_ACCOUNT, [
|
||||
{ id: 'tx-bg', date: '2026-07-31', amount: 88250, currency: 'SEK' },
|
||||
{ id: 'tx-other', date: '2026-07-30', amount: -120, currency: 'SEK' },
|
||||
])
|
||||
|
||||
expect(detectMock).toHaveBeenCalledWith(supabase, {
|
||||
companyId: COMPANY,
|
||||
bankAccountNumber: '1930',
|
||||
transactions: [
|
||||
{ id: 'tx-bg', date: '2026-07-31', amount: 88250, currency: 'SEK' },
|
||||
{ id: 'tx-other', date: '2026-07-30', amount: -120, currency: 'SEK' },
|
||||
],
|
||||
})
|
||||
expect([...proposals.keys()]).toEqual(['tx-bg'])
|
||||
expect(proposals.get('tx-bg')).toMatchObject({
|
||||
journal_entry_id: 'A57',
|
||||
confidence: 0.95,
|
||||
vouchers: [{ journal_entry_id: 'A57' }, { journal_entry_id: 'A58' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('never searches a non-SEK account: the 1:N link compares in the account currency', async () => {
|
||||
const proposals = await proposeCoveringSets(supabase, COMPANY, { ledger_account: '1932', currency: 'EUR' }, [
|
||||
{ id: 'tx-eur', date: '2026-07-31', amount: 100, currency: 'EUR' },
|
||||
])
|
||||
|
||||
expect(proposals.size).toBe(0)
|
||||
expect(detectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing for an empty row list', async () => {
|
||||
const proposals = await proposeCoveringSets(supabase, COMPANY, SEK_ACCOUNT, [])
|
||||
|
||||
expect(proposals.size).toBe(0)
|
||||
expect(detectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails open when the detector throws: the row stays unmatched, the table stays up', async () => {
|
||||
detectMock.mockRejectedValue(new Error('db down'))
|
||||
|
||||
const proposals = await proposeCoveringSets(supabase, COMPANY, SEK_ACCOUNT, [
|
||||
{ id: 'tx', date: '2026-07-31', amount: 100, currency: 'SEK' },
|
||||
])
|
||||
|
||||
expect(proposals.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,10 @@ vi.mock('../bank-reconciliation', () => ({
|
||||
fetchJunctionLinkedTxIds: (...args: unknown[]) => junctionMock(...args),
|
||||
scopeTransactionsToAccount: (q: unknown) => q,
|
||||
}))
|
||||
const coveringSetsMock = vi.fn()
|
||||
vi.mock('../covering-set-candidate', () => ({
|
||||
proposeCoveringSets: (...args: unknown[]) => coveringSetsMock(...args),
|
||||
}))
|
||||
|
||||
import { listAccountItems } from '../items'
|
||||
|
||||
@@ -30,6 +34,8 @@ describe('listAccountItems', () => {
|
||||
fetchUnlinkedMock.mockReset()
|
||||
junctionMock.mockReset()
|
||||
junctionMock.mockResolvedValue(new Set())
|
||||
coveringSetsMock.mockReset()
|
||||
coveringSetsMock.mockResolvedValue(new Map())
|
||||
})
|
||||
|
||||
it('returns null for an invalid key', async () => {
|
||||
@@ -95,6 +101,67 @@ describe('listAccountItems', () => {
|
||||
// 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', 'matched'])
|
||||
expect(result?.total_count).toBe(6)
|
||||
// Only the rows nothing explains yet are searched for a covering set: not
|
||||
// the ignored, linked, junction-anchored or already-proposed ones.
|
||||
expect(coveringSetsMock).toHaveBeenCalledTimes(1)
|
||||
expect(coveringSetsMock.mock.calls[0][2]).toMatchObject({ ledger_account: '1930', currency: 'SEK' })
|
||||
expect((coveringSetsMock.mock.calls[0][3] as Array<{ id: string }>).map((r) => r.id)).toEqual(['t-open'])
|
||||
})
|
||||
|
||||
it('moves a bank row a covering set explains into proposed, with every voucher on the proposal (#2293)', async () => {
|
||||
const openRow = (id: string, date: string, amount: number) => ({
|
||||
id, date, description: 'BGGIRERING 03447786', merchant_name: null, amount, 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,
|
||||
})
|
||||
const setProposal = {
|
||||
journal_entry_id: 'e-57',
|
||||
voucher_number: 57,
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-07-31',
|
||||
description: 'Inbetalning 063 + Inbetalning 064',
|
||||
entry_status: 'posted' as const,
|
||||
confidence: 0.95,
|
||||
reasons: ['exact_sum_same_date'],
|
||||
vouchers: [
|
||||
{ journal_entry_id: 'e-57', voucher_number: 57, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 063', amount: 62500 },
|
||||
{ journal_entry_id: 'e-58', voucher_number: 58, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 064', amount: 25750 },
|
||||
],
|
||||
}
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK', is_primary: true } })
|
||||
enqueue({ data: [openRow('t-bg', '2026-07-31', 88250), openRow('t-lonely', '2026-07-30', -999)] })
|
||||
fetchUnlinkedMock.mockResolvedValue([])
|
||||
coveringSetsMock.mockResolvedValue(new Map([['t-bg', setProposal]]))
|
||||
|
||||
const result = await listAccountItems(supabase as never, COMPANY, `bank:${CASH}`, { limit: 50 })
|
||||
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.item_id, i]))
|
||||
|
||||
expect(byId['t-bg']).toMatchObject({ bucket: 'proposed', proposal: setProposal, actions: ['match', 'book', 'ignore'] })
|
||||
expect(byId['t-lonely']).toMatchObject({ bucket: 'unmatched_external', proposal: null })
|
||||
expect(result?.items.map((i) => i.item_id)).toEqual(['t-bg', 't-lonely'])
|
||||
})
|
||||
|
||||
it('keeps a set-explained row out of unmatched_external and skips the search when neither open bucket is wanted', async () => {
|
||||
const openRow = {
|
||||
id: 't-bg', date: '2026-07-31', description: 'BGGIRERING', merchant_name: null, amount: 88250, 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,
|
||||
}
|
||||
const proposal = { journal_entry_id: 'e-57', voucher_number: 57, voucher_series: 'A', entry_date: '2026-07-31', description: '', entry_status: 'posted' as const, confidence: 0.95, reasons: ['exact_sum_same_date'], vouchers: [] }
|
||||
coveringSetsMock.mockResolvedValue(new Map([['t-bg', proposal]]))
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK', is_primary: true } })
|
||||
enqueue({ data: [openRow] })
|
||||
const unmatched = await listAccountItems(supabase as never, COMPANY, `bank:${CASH}`, { bucket: 'unmatched_external' })
|
||||
expect(unmatched?.items).toEqual([])
|
||||
expect(coveringSetsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const other = createQueuedMockSupabase()
|
||||
other.enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK', is_primary: true } })
|
||||
other.enqueue({ data: [openRow] })
|
||||
const matched = await listAccountItems(other.supabase as never, COMPANY, `bank:${CASH}`, { bucket: 'matched' })
|
||||
expect(matched?.items).toEqual([])
|
||||
expect(coveringSetsMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns null for an unknown cash account', async () => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
detectExplainingVoucherSets,
|
||||
type ExplainingVoucherSet,
|
||||
} from '@/lib/invoices/duplicate-payment-detection'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { ReconciliationProposal } from './schemas'
|
||||
|
||||
const log = createLogger('reconciliation.covering-set-candidate')
|
||||
|
||||
/**
|
||||
* Covering-set proposals for the bridge table (#2293): a bank row nothing
|
||||
* matches 1:1 is searched for the unlinked verifikat on its account whose
|
||||
* bank legs sum exactly to it, BEFORE the row is offered as unmatched.
|
||||
*
|
||||
* Why: a Bankgirot daily aggregate arrives as one row for several
|
||||
* affärshändelser, each often already booked on its own ("Markera som
|
||||
* betald" per invoice, one salary voucher per employee). The 1:1 matcher
|
||||
* sees no voucher of the row's amount, the row reads "ej matchad", and the
|
||||
* next door (Bokför) books the money a second time. The booking doors have
|
||||
* refused that since #2300 and #2346 by running detectExplainingVoucherSet;
|
||||
* this runs the SAME detector (its batch form) in the read path so the view
|
||||
* stops steering users to the door the guard slams. One definition of
|
||||
* "explained by the ledger", at the doors and in the view.
|
||||
*
|
||||
* Read-time only, never persisted: the pool is the account's open rows
|
||||
* against its unlinked legs, and a stored hint would add a stale-pointer
|
||||
* class for no saving (the utlägg pairing precedent, DECISIONS 2026-09-05).
|
||||
* Never auto-applied: the unattended sweep persists and applies 1:1 matches
|
||||
* only, and use_proposals reads the persisted column. A set proposal is
|
||||
* confirmed per row through the ordinary 1:N link (linkTransactionToVouchers),
|
||||
* which revalidates every slice at click time.
|
||||
*
|
||||
* SEK accounts only. The detector sums in SEK; the 1:N link that confirms
|
||||
* the proposal compares in the account's own currency (ledgerLineAmountIn),
|
||||
* so on a EUR account a set that sums in SEK could be refused at confirm. A
|
||||
* proposal that cannot be confirmed is worse than none.
|
||||
*/
|
||||
|
||||
/** Exact öre sum, every voucher dated on the row's date: as strong as auto_exact. */
|
||||
export const COVERING_SET_SAME_DATE_CONFIDENCE = 0.95
|
||||
/** Exact öre sum within ±7 days: as strong as auto_date_range, below the 0.9 unattended floor. */
|
||||
export const COVERING_SET_WINDOW_CONFIDENCE = 0.85
|
||||
|
||||
/** The columns of an open bank row the search needs. */
|
||||
export interface CoveringSetRow {
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
/** Pure: the proposal the bridge table renders for an explaining set. */
|
||||
export function coveringSetProposal(set: ExplainingVoucherSet): ReconciliationProposal {
|
||||
const [first] = set.vouchers
|
||||
const descriptions = set.vouchers
|
||||
.map((v) => (v.description ?? '').trim())
|
||||
.filter((d) => d.length > 0)
|
||||
return {
|
||||
journal_entry_id: first.journal_entry_id,
|
||||
voucher_number: first.voucher_number,
|
||||
voucher_series: first.voucher_series,
|
||||
entry_date: first.entry_date,
|
||||
description: descriptions.join(' + '),
|
||||
entry_status: 'posted',
|
||||
confidence: set.same_date ? COVERING_SET_SAME_DATE_CONFIDENCE : COVERING_SET_WINDOW_CONFIDENCE,
|
||||
reasons: [set.same_date ? 'exact_sum_same_date' : 'exact_sum_within_window'],
|
||||
vouchers: set.vouchers.map((v) => ({
|
||||
journal_entry_id: v.journal_entry_id,
|
||||
voucher_number: v.voucher_number,
|
||||
voucher_series: v.voucher_series,
|
||||
entry_date: v.entry_date,
|
||||
description: v.description ?? '',
|
||||
amount: v.amount,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposals keyed by transaction id for the open rows of one bank account.
|
||||
* Rows the detector cannot explain are simply absent. Advisory: a detector
|
||||
* failure logs and returns nothing rather than taking the bridge table down;
|
||||
* the row stays unmatched and the booking doors keep their own guard.
|
||||
*/
|
||||
export async function proposeCoveringSets(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
account: { ledger_account: string; currency: string | null },
|
||||
rows: CoveringSetRow[],
|
||||
): Promise<Map<string, ReconciliationProposal>> {
|
||||
const proposals = new Map<string, ReconciliationProposal>()
|
||||
if (rows.length === 0 || (account.currency ?? 'SEK') !== 'SEK') return proposals
|
||||
try {
|
||||
const sets = await detectExplainingVoucherSets(supabase, {
|
||||
companyId,
|
||||
bankAccountNumber: account.ledger_account,
|
||||
transactions: rows.map((r) => ({ id: r.id, date: r.date, amount: r.amount, currency: r.currency })),
|
||||
})
|
||||
for (const [transactionId, set] of sets) {
|
||||
proposals.set(transactionId, coveringSetProposal(set))
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('covering-set proposals skipped', {
|
||||
companyId,
|
||||
entityType: 'cash_account',
|
||||
details: {
|
||||
account: account.ledger_account,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
})
|
||||
}
|
||||
return proposals
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { fetchJunctionLinkedTxIds, fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation'
|
||||
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
|
||||
import { proposeCoveringSets } from './covering-set-candidate'
|
||||
import {
|
||||
parseAccountKey,
|
||||
type ReconciliationItem,
|
||||
type ReconciliationItemBucket,
|
||||
type ReconciliationProposal,
|
||||
} from './schemas'
|
||||
|
||||
/**
|
||||
@@ -160,13 +162,30 @@ export async function listAccountItems(
|
||||
companyId,
|
||||
rows.filter((tx) => !tx.journal_entry_id && !tx.is_ignored).map((tx) => tx.id),
|
||||
)
|
||||
// Rows nothing explains 1:1 are searched for a set of unlinked verifikat
|
||||
// summing exactly to them (#2293) before they are offered as unmatched:
|
||||
// "Bokför" is the door only when the ledger has nothing for the row.
|
||||
const coveringSets = buckets.some((b) => b === 'proposed' || b === 'unmatched_external')
|
||||
? await proposeCoveringSets(
|
||||
supabase,
|
||||
companyId,
|
||||
account,
|
||||
rows
|
||||
.filter(
|
||||
(tx) =>
|
||||
!tx.is_ignored && !tx.journal_entry_id && !junctionLinked.has(tx.id) && !tx.potential_journal_entry_id,
|
||||
)
|
||||
.map((tx) => ({ id: tx.id, date: tx.date, amount: Number(tx.amount), currency: tx.currency })),
|
||||
)
|
||||
: new Map<string, ReconciliationProposal>()
|
||||
{
|
||||
for (const tx of rows) {
|
||||
const coveringSet = coveringSets.get(tx.id) ?? null
|
||||
const bucket: ReconciliationItemBucket = tx.is_ignored
|
||||
? 'ignored'
|
||||
: tx.journal_entry_id || junctionLinked.has(tx.id)
|
||||
? 'matched'
|
||||
: tx.potential_journal_entry_id
|
||||
: tx.potential_journal_entry_id || coveringSet
|
||||
? 'proposed'
|
||||
: 'unmatched_external'
|
||||
if (!buckets.includes(bucket)) continue
|
||||
@@ -191,7 +210,7 @@ export async function listAccountItems(
|
||||
confidence: Number(tx.potential_match_confidence ?? 0.75),
|
||||
reasons: [tx.potential_match_method ?? 'föreslagen av matcharen'],
|
||||
}
|
||||
: null,
|
||||
: coveringSet,
|
||||
actions:
|
||||
bucket === 'matched'
|
||||
? ['unmatch']
|
||||
|
||||
@@ -182,6 +182,16 @@ export const ReconciliationItemActionSchema = z.enum([
|
||||
'review',
|
||||
])
|
||||
|
||||
/** One verifikat of a set proposal: its bank leg in the row's direction, positive, in the account currency. */
|
||||
export const ReconciliationProposalVoucherSchema = z.object({
|
||||
journal_entry_id: z.string(),
|
||||
voucher_number: z.number().int().nullable(),
|
||||
voucher_series: z.string().nullable(),
|
||||
entry_date: z.string(),
|
||||
description: z.string(),
|
||||
amount: z.number(),
|
||||
})
|
||||
|
||||
export const ReconciliationProposalSchema = z.object({
|
||||
journal_entry_id: z.string(),
|
||||
voucher_number: z.number().int().nullable(),
|
||||
@@ -191,6 +201,16 @@ export const ReconciliationProposalSchema = z.object({
|
||||
entry_status: z.enum(['draft', 'posted', 'reversed']),
|
||||
confidence: z.number().min(0).max(1),
|
||||
reasons: z.array(z.string()),
|
||||
/**
|
||||
* Present when the proposal is an explaining SET of unlinked verifikat whose
|
||||
* bank legs sum exactly to the row (#2293, computed at read time): the row
|
||||
* is linked to all of them (1:N) and journal_entry_id is the first. Length 1
|
||||
* is one voucher of the exact amount the 1:1 matcher missed (dated 4 to 7
|
||||
* days off). Absent on persisted 1:1 proposals. Apply with explicit pairs
|
||||
* (journal_entry_ids = every voucher); use_proposals reads the persisted
|
||||
* column only.
|
||||
*/
|
||||
vouchers: z.array(ReconciliationProposalVoucherSchema).min(1).optional(),
|
||||
})
|
||||
export type ReconciliationProposal = z.infer<typeof ReconciliationProposalSchema>
|
||||
|
||||
|
||||
+3
-1
@@ -8124,7 +8124,9 @@
|
||||
"manual_spec_hint": "The specification comes from the system: {label}. The difference should be zero before you sign; otherwise sign with a note.",
|
||||
"signoff_external_balance": "Balance per supporting documents",
|
||||
"signoff_external_balance_help": "Same sign as in the books, liabilities with a minus. Booked: {amount}.",
|
||||
"signoff_external_balance_optional": "Leave empty to sign with a note only."
|
||||
"signoff_external_balance_optional": "Leave empty to sign with a note only.",
|
||||
"proposal_set_title": "{count} vouchers that together make up the amount",
|
||||
"proposal_set_same_day": "same day"
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
|
||||
|
||||
+3
-1
@@ -8124,7 +8124,9 @@
|
||||
"manual_spec_hint": "Specifikationen kommer från systemet: {label}. Differensen ska vara noll innan du signerar, annars signerar du med en notering.",
|
||||
"signoff_external_balance": "Saldo enligt underlag",
|
||||
"signoff_external_balance_help": "Samma tecken som i bokföringen, skulder anges med minus. Bokfört: {amount}.",
|
||||
"signoff_external_balance_optional": "Lämna tomt om du bara vill signera med en notering."
|
||||
"signoff_external_balance_optional": "Lämna tomt om du bara vill signera med en notering.",
|
||||
"proposal_set_title": "{count} verifikat som tillsammans ger beloppet",
|
||||
"proposal_set_same_day": "samma dag"
|
||||
},
|
||||
"skattekonto": {
|
||||
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
|
||||
|
||||
@@ -573,7 +573,7 @@ Response `200`:
|
||||
```ts
|
||||
{
|
||||
data: {
|
||||
items: { item_id: string, item_type: "skattekonto_transaction" | "transaction" | "journal_entry", side: "external" | "ledger", bucket: "proposed" | "unmatched_external" | "unmatched_ledger" | "matched" | "ignored" | "upcoming", date: string, description: string, amount: number, currency: string, voucher_number?: number, voucher_series?: string, entry_status?: "draft" | "posted" | "reversed", linked_journal_entry_id?: string, link_problem?: "entry_reversed" | "entry_draft" | "entry_missing", proposal?: { journal_entry_id: string, voucher_number: number, voucher_series: string, entry_date: string, description: string, entry_status: "draft" | "posted" | "reversed", confidence: number, reasons: string[] }, awaiting_external?: boolean, actions: ("match" | "unmatch" | "book" | "ignore" | "unignore" | "review")[] }[],
|
||||
items: { item_id: string, item_type: "skattekonto_transaction" | "transaction" | "journal_entry", side: "external" | "ledger", bucket: "proposed" | "unmatched_external" | "unmatched_ledger" | "matched" | "ignored" | "upcoming", date: string, description: string, amount: number, currency: string, voucher_number?: number, voucher_series?: string, entry_status?: "draft" | "posted" | "reversed", linked_journal_entry_id?: string, link_problem?: "entry_reversed" | "entry_draft" | "entry_missing", proposal?: { journal_entry_id: string, voucher_number: number, voucher_series: string, entry_date: string, description: string, entry_status: "draft" | "posted" | "reversed", confidence: number, reasons: string[], vouchers?: { journal_entry_id: {...}, voucher_number: {...}, voucher_series: {...}, entry_date: {...}, description: {...}, amount: {...} }[] }, awaiting_external?: boolean, actions: ("match" | "unmatch" | "book" | "ignore" | "unignore" | "review")[] }[],
|
||||
count: number,
|
||||
total_count: number,
|
||||
has_more: boolean,
|
||||
|
||||
Reference in New Issue
Block a user