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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
67e878fb23
commit
8265b5d166
@@ -118,6 +118,44 @@ describe('GET /api/invoices', () => {
|
||||
expect(body.data[0].customer).toBeNull()
|
||||
})
|
||||
|
||||
it('reports invoice-register coverage alongside the list', async () => {
|
||||
// List, then the coverage helper's two lookups: earliest register
|
||||
// invoice + a posted AR verifikat predating it (migrated/backfilled
|
||||
// invoice history living only as journal entries).
|
||||
enqueue({ data: [makeInvoice()], error: null, count: 1 })
|
||||
enqueue({ data: { invoice_date: '2026-07-19' }, error: null })
|
||||
enqueue({ data: { id: 'line-1' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
invoice_register_coverage: { covers_from: string | null; has_pre_register_invoices: boolean }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.invoice_register_coverage).toEqual({
|
||||
covers_from: '2026-07-19',
|
||||
has_pre_register_invoices: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades to no coverage instead of failing the list', async () => {
|
||||
enqueue({ data: [makeInvoice()], error: null, count: 1 })
|
||||
// Coverage lookups resolve to nothing (empty queue → null data).
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request, { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
invoice_register_coverage: { covers_from: string | null; has_pre_register_invoices: boolean }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.invoice_register_coverage).toEqual({
|
||||
covers_from: null,
|
||||
has_pre_register_invoices: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('applies status filter', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { maskEmbeddedCustomer } from '@/lib/customers/protect-personal-number'
|
||||
import {
|
||||
fetchInvoiceRegisterCoverage,
|
||||
NO_INVOICE_REGISTER_COVERAGE,
|
||||
} from '@/lib/invoices/invoice-register-coverage'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -49,9 +53,24 @@ export const GET = withRouteContext(
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
|
||||
// Coverage disclosure: the register only holds invoices created in
|
||||
// Accounted, so for a migrated/backfilled company this list is silently
|
||||
// incomplete before its first invoice. Non-fatal: a failed lookup
|
||||
// degrades to "no marker", never to a failed list.
|
||||
let coverage = NO_INVOICE_REGISTER_COVERAGE
|
||||
try {
|
||||
coverage = await fetchInvoiceRegisterCoverage(supabase, companyId)
|
||||
} catch {
|
||||
// keep NO_INVOICE_REGISTER_COVERAGE
|
||||
}
|
||||
|
||||
// Mask the embedded customer's personnummer: the customers(*) join
|
||||
// carries the stored ciphertext, which has no business reaching a client.
|
||||
return NextResponse.json({ data: (data ?? []).map(maskEmbeddedCustomer), count })
|
||||
return NextResponse.json({
|
||||
data: (data ?? []).map(maskEmbeddedCustomer),
|
||||
count,
|
||||
invoice_register_coverage: coverage,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
type SelfBilledSaleFailure,
|
||||
} from '@/lib/invoices/self-billed-sale'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import {
|
||||
fetchInvoiceRegisterCoverage,
|
||||
NO_INVOICE_REGISTER_COVERAGE,
|
||||
} from '@/lib/invoices/invoice-register-coverage'
|
||||
import type { Customer, Invoice, InvoiceDocumentType } from '@/types'
|
||||
|
||||
// Map a self-billed-sale service failure onto the v1 invoice error envelope.
|
||||
@@ -160,6 +164,7 @@ registerEndpoint({
|
||||
'Ordering is by created_at (registration time), not invoice_date. Backdated invoices therefore appear where they were created, not where their date falls: filter on ?date_from / ?date_to when you care about the business date.',
|
||||
'Cursor pagination: pass ?cursor=<next_cursor> from the previous response. A stale or tampered cursor is ignored and the first page is returned again.',
|
||||
'Quotes (document_type=quote, offert) carry valid_until and quote_status (open | accepted | declined | expired). "expired" is derived: an open quote past valid_until; filter with ?quote_status=expired. Quotes never book and are never payable: convert an accepted quote to an invoice in the dashboard first.',
|
||||
'The register only contains invoices created in Accounted. A company migrated or backfilled mid-year has real customer invoices that exist only as journal entries and are NOT in this list. Check meta.coverage: when has_pre_register_invoices is true, treat periods before covers_from as not answered by this endpoint (query journal entries instead).',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
@@ -182,7 +187,12 @@ registerEndpoint({
|
||||
created_at: '2026-05-01T09:14:33Z',
|
||||
},
|
||||
],
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
|
||||
meta: {
|
||||
request_id: 'req_…',
|
||||
api_version: '2026-05-12',
|
||||
next_cursor: null,
|
||||
coverage: { covers_from: '2026-05-01', has_pre_register_invoices: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
scope: 'invoices:read',
|
||||
@@ -366,9 +376,20 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
|
||||
: null
|
||||
|
||||
// Register-coverage disclosure (meta.coverage): without it, an agent
|
||||
// reading this list for a migrated company gets a confidently
|
||||
// incomplete answer to "which invoices exist". Non-fatal on failure.
|
||||
let coverage = NO_INVOICE_REGISTER_COVERAGE
|
||||
try {
|
||||
coverage = await fetchInvoiceRegisterCoverage(ctx.supabase, ctx.companyId!)
|
||||
} catch {
|
||||
// keep NO_INVOICE_REGISTER_COVERAGE
|
||||
}
|
||||
|
||||
return paginated(invoices, {
|
||||
requestId: ctx.requestId,
|
||||
nextCursor: nextCursor ?? undefined,
|
||||
coverage: { ...coverage },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ registerEndpoint({
|
||||
pitfalls: [
|
||||
'`as_of_date` is optional; format `YYYY-MM-DD`. Defaults to today (UTC).',
|
||||
'Only invoices in `sent`/`overdue`/`partially_paid` status appear. Drafts and credited invoices are excluded.',
|
||||
'The ledger is built from the invoice register only. `data.register_coverage` ({ covers_from, has_pre_register_invoices }) discloses when posted AR verifikat predate the register\'s earliest invoice (migrated or backfilled invoice history): those receivables are NOT in this ledger. When has_pre_register_invoices is true, treat periods before covers_from as unanswered here and query journal entries on 1510/1513 instead.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
|
||||
Reference in New Issue
Block a user