* feat(invoices): disclose invoice-register coverage gaps + amount search After a SIE migration or verifikat backfill, customer invoices exist only as journal entries: the invoice list, kundreskontran, /api/invoices, v1 invoices.list, and MCP list_invoices all looked complete while silently omitting everything before the register's first invoice (user report: two invoiced fees nearly re-invoiced as "uninvoiced"). - lib/invoices/invoice-register-coverage.ts: coverage boundary = earliest register invoice; flags posted non-invoice-engine AR verifikat (1510/1513) before it. AR-keyed, not source_type='import'-keyed, so manual/API backfills are caught too. - Invoice list page: one attn line disclosing the boundary (sv+en). - Kundreskontra: register_coverage in the report payload, rendered in the summary card and as an explanation under "Ej avstamd". - /api/invoices GET: invoice_register_coverage in the response. - v1 invoices.list: meta.coverage + registry pitfall documenting it. - MCP gnubok_list_invoices: invoice_register_coverage + coverage_note on the first page, pointing agents at gnubok_query_journal. - Search: lib/invoices/invoice-search.ts matches net (subtotal) and gross amounts with sv-SE formatting, alongside number/customer matching; a known net amount like 14 000 now finds the 17 500 kr row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(invoices): harden register-coverage probe, period-gate reconciliation note, regen api skill Skeptic + CI findings folded into one pass: - Coverage probe: a failed AR lookup now degrades to UNKNOWN (NO_INVOICE_REGISTER_COVERAGE), never to a confident "complete". - Probe driven from journal_entries (company-indexed) with the AR line condition as an inner embed, instead of the lines-table-with-embed-filters shape that lateral-scans every tenant (lib/bookkeeping/entry-lines.ts). - DEBIT-only 1510/1513 lines; excludes every invoice-engine source type (invoice_created, invoice_paid, invoice_cash_payment, credit_note, reminder_fee, rot_rut_payout, storno, correction): an advance payment crediting 1510 or a re-dated rattelse of an engine entry no longer flags. - covers_from ignores drafts so a backdated draft cannot move the boundary. - Kundreskontra "Ej avstamd" explanation is now gated on pre-register AR debits existing IN the reconciled period (new ARReconciliationResult.pre_register_ar_in_period): prior-period migration history cannot explain this period's difference and must not excuse a real felbokning. Wording no longer says "snarare an felbokning". - MCP coverage_note states the earliest register invoice date rather than claiming the register "covers" from it. - Amount search compares magnitudes so credit notes (negative totals) are findable; "-17500" parses; null amounts never match "0". - skills/accounted-api regenerated from the registry (apiskill:check). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * chore(api-skill): regenerate accounted-api skill after merging origin/main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(invoices): round-2 review fixes for register-coverage disclosure - covers_from now anchors on real invoices only (document_type='invoice', non-draft): proformas/delivery notes cannot move the boundary. - INVOICE_ENGINE_SOURCE_TYPES exported + a test scans the engine writers (invoice-entries, reminder-fee, rot-rut, storno-service) so a future source_type cannot silently become false pre-register evidence. - Kundreskontra guidance names both 1510 and 1513. - MCP gnubok_list_invoices outputSchema declares invoice_register_coverage and coverage_note. - v1 reports.ar-ledger documents data.register_coverage; invoices.list example made internally consistent; api skill regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(mcp): keep gnubok_list_invoices outputSchema minimal to hold the tools/list token budget The expanded schema from the round-2 review pushed tools/list to 61 726 tokens against the held 61 600 ceiling (payload-size.bench.test.ts). The ceiling is policy, not a baseline to bump: the description already tells agents to read invoice_register_coverage/coverage_note, and paginatedSchema has no additionalProperties:false, so the fields stay schema-valid. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
60 lines
2.4 KiB
TypeScript
60 lines
2.4 KiB
TypeScript
/**
|
|
* Invoice-list search predicate. Extracted from the page filter so the
|
|
* matching rules are testable and documented in one place.
|
|
*
|
|
* Matches, in order of what users actually paste into the box:
|
|
* - invoice number / external (self-billed) number, substring
|
|
* - customer name, substring
|
|
* - an amount, against BOTH the net (subtotal) and gross (total): a user
|
|
* checking an avtalad avgift knows the net ("14 000"); the list shows
|
|
* the gross ("17 500 kr"). Amount terms accept Swedish formatting:
|
|
* spaces (incl. NBSP/thin NBSP) as thousand separators, comma or dot
|
|
* decimals. Exact-amount semantics with an öre tolerance; substring
|
|
* digit matching would drown "1400" in false hits.
|
|
*/
|
|
|
|
export interface SearchableInvoice {
|
|
invoice_number?: string | null
|
|
external_invoice_number?: string | null
|
|
customer?: { name?: string | null } | null
|
|
subtotal?: number | string | null
|
|
total?: number | string | null
|
|
}
|
|
|
|
/**
|
|
* Parse "14 000", "17 500,50", "17500.50", "-17500" → magnitude (absolute
|
|
* value); null when not an amount. \s covers NBSP/thin-NBSP thousand
|
|
* separators from sv-SE formatting. Sign is discarded: credit notes store
|
|
* negative totals, and a user searching a belopp thinks in magnitudes.
|
|
*/
|
|
export function parseAmountTerm(term: string): number | null {
|
|
const compact = term.replace(/\s/g, '').replace(',', '.')
|
|
if (!/^-?\d+(\.\d{1,2})?$/.test(compact)) return null
|
|
const value = Number(compact)
|
|
return Number.isFinite(value) ? Math.abs(value) : null
|
|
}
|
|
|
|
const amountEquals = (candidate: number | string | null | undefined, target: number): boolean => {
|
|
if (candidate == null || candidate === '') return false
|
|
const value = Number(candidate)
|
|
// Magnitude comparison so 17500 finds the -17500 kreditfaktura row too.
|
|
return Number.isFinite(value) && Math.abs(Math.abs(value) - target) < 0.005
|
|
}
|
|
|
|
export function matchesInvoiceSearch(invoice: SearchableInvoice, rawTerm: string): boolean {
|
|
const term = rawTerm.trim().toLocaleLowerCase('sv-SE')
|
|
if (!term) return true
|
|
|
|
if (
|
|
(invoice.invoice_number ?? '').toLocaleLowerCase('sv-SE').includes(term) ||
|
|
(invoice.external_invoice_number ?? '').toLocaleLowerCase('sv-SE').includes(term) ||
|
|
(invoice.customer?.name ?? '').toLocaleLowerCase('sv-SE').includes(term)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
const amount = parseAmountTerm(rawTerm)
|
|
if (amount === null) return false
|
|
return amountEquals(invoice.subtotal, amount) || amountEquals(invoice.total, amount)
|
|
}
|