Files
8265b5d166 feat(invoices): disclose invoice-register coverage gaps + net-amount search (#2122)
* 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>
2026-09-04 09:39:14 +02:00

74 lines
3.0 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest'
import { matchesInvoiceSearch, parseAmountTerm } from '../invoice-search'
const invoice = {
invoice_number: '2627',
external_invoice_number: null,
customer: { name: 'Testbrand AB' },
subtotal: 14000,
total: 17500,
}
describe('parseAmountTerm', () => {
it('parses plain, spaced, and decimal amounts', () => {
expect(parseAmountTerm('14000')).toBe(14000)
expect(parseAmountTerm('14 000')).toBe(14000)
expect(parseAmountTerm('17 500')).toBe(17500) // NBSP thousand separator
expect(parseAmountTerm('17 500,50')).toBe(17500.5)
expect(parseAmountTerm('17500.50')).toBe(17500.5)
})
it('rejects non-amounts', () => {
expect(parseAmountTerm('Testbrand')).toBeNull()
expect(parseAmountTerm('26-27')).toBeNull()
expect(parseAmountTerm('14000,123')).toBeNull()
expect(parseAmountTerm('')).toBeNull()
})
})
describe('matchesInvoiceSearch', () => {
it('matches invoice number, external number, and customer name (case-insensitive)', () => {
expect(matchesInvoiceSearch(invoice, '2627')).toBe(true)
expect(matchesInvoiceSearch(invoice, 'testbrand')).toBe(true)
expect(
matchesInvoiceSearch({ ...invoice, external_invoice_number: 'SB-17' }, 'sb-17'),
).toBe(true)
expect(matchesInvoiceSearch(invoice, 'annat bolag')).toBe(false)
})
it('matches the net amount, not only the gross (the user-report case)', () => {
// The list displays 17 500 kr; the user knows the avtalad avgift is 14 000.
expect(matchesInvoiceSearch(invoice, '14 000')).toBe(true)
expect(matchesInvoiceSearch(invoice, '14000')).toBe(true)
expect(matchesInvoiceSearch(invoice, '17500')).toBe(true)
})
it('uses exact-amount semantics with an öre tolerance', () => {
expect(matchesInvoiceSearch(invoice, '1400')).toBe(false)
expect(matchesInvoiceSearch({ ...invoice, total: 17500.004 }, '17500')).toBe(true)
expect(matchesInvoiceSearch({ ...invoice, total: 17500.5 }, '17500')).toBe(false)
})
it('still string-matches digits against numbers before falling back to amounts', () => {
// '262' is a substring of invoice_number 2627: prefix typing keeps working.
expect(matchesInvoiceSearch(invoice, '262')).toBe(true)
})
it('finds credit notes by magnitude (stored totals are negative)', () => {
const creditNote = { ...invoice, subtotal: -14000, total: -17500 }
expect(matchesInvoiceSearch(creditNote, '17500')).toBe(true)
expect(matchesInvoiceSearch(creditNote, '14 000')).toBe(true)
expect(matchesInvoiceSearch(creditNote, '-17500')).toBe(true)
})
it('does not let a zero term match rows with missing amounts', () => {
expect(matchesInvoiceSearch({ subtotal: null, total: null }, '0')).toBe(false)
})
it('handles empty terms and missing fields', () => {
expect(matchesInvoiceSearch(invoice, '')).toBe(true)
expect(matchesInvoiceSearch({}, 'x')).toBe(false)
expect(matchesInvoiceSearch({ subtotal: null, total: null }, '14000')).toBe(false)
})
})