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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
fcda4a75dd
commit
6776cb4fc6
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user