8265b5d166
* 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>
149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
/**
|
|
* v1 REST response envelopes.
|
|
*
|
|
* ok(data) → 200 { data, meta: { request_id, api_version } }
|
|
* paginated(data, next_cursor) → 200 { data, meta: { request_id, api_version, next_cursor } }
|
|
* accepted(operationId, type) → 202 { data: { operation_id, status, poll_url, webhook_event }, meta }
|
|
* created(data) → 201 same shape as ok
|
|
*
|
|
* Every helper stamps X-Request-Id + Gnubok-Version on the response and
|
|
* accepts an optional `audit` block for write responses (per the architectural
|
|
* decision in the plan that writes return their voucher_number / audit_url
|
|
* inline so the agent doesn't need a second round-trip).
|
|
*/
|
|
|
|
import { NextResponse } from 'next/server'
|
|
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
|
|
|
|
export interface AuditBlock {
|
|
voucher_number?: string
|
|
voucher_url?: string
|
|
audit_trail_url?: string
|
|
immutable_at?: string
|
|
}
|
|
|
|
export interface ResponseMeta {
|
|
request_id: string
|
|
api_version: string
|
|
next_cursor?: string
|
|
audit?: AuditBlock
|
|
/**
|
|
* Names of `?expand=` keys whose underlying data fetch failed during a
|
|
* soft-degraded response. Present only when at least one expansion was
|
|
* requested AND failed. Agents that need transactional guarantees can
|
|
* detect a degraded response without parsing the body.
|
|
*/
|
|
partial_expansions?: string[]
|
|
/**
|
|
* Registry-coverage disclosure for list endpoints whose backing register
|
|
* may not span all of the company's bookkeeping (e.g. the invoice
|
|
* register after a mid-year migration). Shape is endpoint-specific and
|
|
* documented in the endpoint's registry entry. Present only when the
|
|
* endpoint computes it.
|
|
*/
|
|
coverage?: Record<string, unknown>
|
|
}
|
|
|
|
interface ResponseOptions {
|
|
requestId: string
|
|
status?: number
|
|
headers?: Record<string, string>
|
|
audit?: AuditBlock
|
|
/** Cursor for the *next* page; omitted when this is the last page. */
|
|
nextCursor?: string
|
|
/** Names of `?expand=` keys whose data fetch failed (soft-degrade). */
|
|
partialExpansions?: string[]
|
|
/** Endpoint-specific register-coverage disclosure (see ResponseMeta.coverage). */
|
|
coverage?: Record<string, unknown>
|
|
/** Marks the response as a replay of a previously-cached idempotent call. */
|
|
idempotentReplay?: boolean
|
|
/** Marks the response as a dry-run preview rather than a committed write. */
|
|
dryRun?: boolean
|
|
/** Rate-limit headers, when known. */
|
|
rateLimit?: { limit: number; remaining: number; resetAt?: Date }
|
|
}
|
|
|
|
function applyStandardHeaders(res: NextResponse, opts: ResponseOptions): NextResponse {
|
|
res.headers.set('X-Request-Id', opts.requestId)
|
|
res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
|
|
if (opts.idempotentReplay) res.headers.set('Idempotent-Replayed', 'true')
|
|
if (opts.dryRun) res.headers.set('X-Dry-Run', 'true')
|
|
if (opts.rateLimit) {
|
|
res.headers.set('X-RateLimit-Limit', String(opts.rateLimit.limit))
|
|
res.headers.set('X-RateLimit-Remaining', String(opts.rateLimit.remaining))
|
|
if (opts.rateLimit.resetAt) {
|
|
res.headers.set('X-RateLimit-Reset', String(Math.floor(opts.rateLimit.resetAt.getTime() / 1000)))
|
|
}
|
|
}
|
|
if (opts.headers) {
|
|
for (const [k, v] of Object.entries(opts.headers)) {
|
|
res.headers.set(k, v)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
function buildMeta(opts: ResponseOptions): ResponseMeta {
|
|
const meta: ResponseMeta = {
|
|
request_id: opts.requestId,
|
|
api_version: API_V1_VERSION,
|
|
}
|
|
if (opts.nextCursor) meta.next_cursor = opts.nextCursor
|
|
if (opts.audit) meta.audit = opts.audit
|
|
if (opts.coverage) meta.coverage = opts.coverage
|
|
if (opts.partialExpansions && opts.partialExpansions.length > 0) {
|
|
meta.partial_expansions = opts.partialExpansions
|
|
}
|
|
return meta
|
|
}
|
|
|
|
/**
|
|
* 200 OK with `{ data, meta }`.
|
|
*/
|
|
export function ok<T>(data: T, opts: ResponseOptions): NextResponse {
|
|
const res = NextResponse.json({ data, meta: buildMeta(opts) }, { status: opts.status ?? 200 })
|
|
return applyStandardHeaders(res, opts)
|
|
}
|
|
|
|
/**
|
|
* 200 OK with `{ data: T[], meta: { next_cursor } }`. Use for list endpoints.
|
|
*/
|
|
export function paginated<T>(data: T[], opts: ResponseOptions): NextResponse {
|
|
const res = NextResponse.json({ data, meta: buildMeta(opts) }, { status: 200 })
|
|
return applyStandardHeaders(res, opts)
|
|
}
|
|
|
|
/**
|
|
* 201 Created. Mirror of ok() with status 201 for POST that creates a resource.
|
|
*/
|
|
export function created<T>(data: T, opts: ResponseOptions): NextResponse {
|
|
return ok(data, { ...opts, status: 201 })
|
|
}
|
|
|
|
/**
|
|
* 202 Accepted for async long-running operations. Returns the operation_id +
|
|
* polling URL + the webhook event the caller can subscribe to for completion.
|
|
*/
|
|
export function accepted(
|
|
operationId: string,
|
|
operationType: string,
|
|
opts: ResponseOptions,
|
|
): NextResponse {
|
|
const data = {
|
|
operation_id: operationId,
|
|
type: operationType,
|
|
status: 'queued' as const,
|
|
poll_url: `/api/v1/operations/${operationId}`,
|
|
webhook_event: 'operation.completed',
|
|
}
|
|
return ok(data, { ...opts, status: 202 })
|
|
}
|
|
|
|
/**
|
|
* 204 No Content. Used for DELETE responses. No body.
|
|
*/
|
|
export function noContent(opts: ResponseOptions): NextResponse {
|
|
const res = new NextResponse(null, { status: 204 })
|
|
return applyStandardHeaders(res, opts)
|
|
}
|