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:
Mattsson
2026-09-04 09:39:14 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 67e878fb23
commit 8265b5d166
30 changed files with 967 additions and 150 deletions
+3
View File
@@ -46,6 +46,9 @@ export const ResponseMetaSchema = z.object({
next_cursor: z.string().nullable().optional(),
audit: ResponseAuditSchema.optional(),
partial_expansions: z.array(z.string()).optional(),
// Endpoint-specific register-coverage disclosure (e.g. invoices.list:
// { covers_from, has_pre_register_invoices }). Documented per endpoint.
coverage: z.record(z.string(), z.unknown()).optional(),
})
/**
+11
View File
@@ -34,6 +34,14 @@ export interface ResponseMeta {
* 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 {
@@ -45,6 +53,8 @@ interface ResponseOptions {
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. */
@@ -80,6 +90,7 @@ function buildMeta(opts: ResponseOptions): ResponseMeta {
}
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
}
@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
fetchInvoiceRegisterCoverage,
hasPreRegisterArInPeriod,
INVOICE_ENGINE_SOURCE_TYPES,
NO_INVOICE_REGISTER_COVERAGE,
} from '../invoice-register-coverage'
const { supabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const client = supabase as unknown as SupabaseClient
describe('fetchInvoiceRegisterCoverage', () => {
beforeEach(() => {
reset()
})
it('returns no coverage when the register is empty, without probing the journal', async () => {
enqueue({ data: null, error: null })
const coverage = await fetchInvoiceRegisterCoverage(client, 'company-1')
expect(coverage).toEqual(NO_INVOICE_REGISTER_COVERAGE)
expect(findCall('journal_entries', 'select')).toBeUndefined()
// Drafts must not anchor the boundary: a backdated draft would suppress
// the disclosure for exactly the period it exists to cover.
expect(findCall('invoices', 'neq')).toEqual(['status', 'draft'])
expect(findCalls('invoices', 'eq')).toContainEqual(['document_type', 'invoice'])
})
it('flags pre-register invoices when a posted non-engine AR debit predates the first invoice', async () => {
enqueue({ data: { invoice_date: '2026-07-19' }, error: null })
enqueue({ data: { id: 'entry-1' }, error: null })
const coverage = await fetchInvoiceRegisterCoverage(client, 'company-1')
expect(coverage).toEqual({
covers_from: '2026-07-19',
has_pre_register_invoices: true,
})
// Driven from journal_entries (company-indexed), never from the lines
// table with entry filters on an embed (the lateral-scan shape
// lib/bookkeeping/entry-lines.ts exists to prevent).
const eqCalls = findCalls('journal_entries', 'eq')
expect(eqCalls).toContainEqual(['company_id', 'company-1'])
expect(eqCalls).toContainEqual(['status', 'posted'])
expect(findCall('journal_entries', 'lt')).toEqual(['entry_date', '2026-07-19'])
// Every invoice-engine source type is excluded, storno/correction
// included (a rättelse can be re-dated before the boundary).
expect(findCall('journal_entries', 'not')).toEqual([
'source_type',
'in',
'("invoice_created","invoice_paid","invoice_cash_payment","credit_note","reminder_fee","rot_rut_payout","storno","correction")',
])
// AR-scoped and DEBIT-only: an advance payment crediting 1510 before the
// first invoice is not evidence of register-external invoices.
expect(findCall('journal_entries', 'in')).toEqual([
'journal_entry_lines.account_number',
['1510', '1513'],
])
expect(findCall('journal_entries', 'gt')).toEqual([
'journal_entry_lines.debit_amount',
0,
])
})
it('reports full coverage when no AR debit predates the first invoice', async () => {
enqueue({ data: { invoice_date: '2026-01-02' }, error: null })
enqueue({ data: null, error: null })
const coverage = await fetchInvoiceRegisterCoverage(client, 'company-1')
expect(coverage).toEqual({
covers_from: '2026-01-02',
has_pre_register_invoices: false,
})
})
it('degrades to UNKNOWN when the AR probe errors, never to a confident "complete"', async () => {
enqueue({ data: { invoice_date: '2026-07-19' }, error: null })
enqueue({ data: null, error: { message: 'statement timeout' } })
const coverage = await fetchInvoiceRegisterCoverage(client, 'company-1')
// { covers_from: '2026-07-19', has_pre_register_invoices: false } here
// would tell an agent the register is complete on the strength of a
// failed query: the exact double-invoicing incident this module exists
// to prevent.
expect(coverage).toEqual(NO_INVOICE_REGISTER_COVERAGE)
})
it('degrades to UNKNOWN when the register lookup errors', async () => {
enqueue({ data: null, error: { message: 'connection reset' } })
const coverage = await fetchInvoiceRegisterCoverage(client, 'company-1')
expect(coverage).toEqual(NO_INVOICE_REGISTER_COVERAGE)
})
})
describe('hasPreRegisterArInPeriod', () => {
beforeEach(() => {
reset()
})
it('requires pre-register AR activity inside the given period', async () => {
// Coverage lookups: first invoice + global probe (flagged) ...
enqueue({ data: { invoice_date: '2026-07-19' }, error: null })
enqueue({ data: { id: 'entry-1' }, error: null })
// ... then the period-scoped probe finds nothing in THIS period.
enqueue({ data: null, error: null })
expect(await hasPreRegisterArInPeriod(client, 'company-1', 'period-1')).toBe(false)
const eqCalls = findCalls('journal_entries', 'eq')
expect(eqCalls).toContainEqual(['fiscal_period_id', 'period-1'])
})
it('is true when flagged activity exists inside the period', async () => {
enqueue({ data: { invoice_date: '2026-07-19' }, error: null })
enqueue({ data: { id: 'entry-1' }, error: null })
enqueue({ data: { id: 'entry-1' }, error: null })
expect(await hasPreRegisterArInPeriod(client, 'company-1', 'period-1')).toBe(true)
})
it('skips the period probe entirely when the company is not flagged', async () => {
enqueue({ data: { invoice_date: '2026-01-02' }, error: null })
enqueue({ data: null, error: null })
expect(await hasPreRegisterArInPeriod(client, 'company-1', 'period-1')).toBe(false)
})
})
describe('INVOICE_ENGINE_SOURCE_TYPES', () => {
// Maintenance guard (Swedish compliance review, PR #2122): every source_type
// the invoice engine and its correction paths write must be excluded from
// the pre-register probe, or the engine's own AR debits would be read as
// evidence of register-external invoices. Scans the writers instead of
// trusting the list.
it('covers every source_type the invoice engine writes', () => {
const writers = [
'lib/bookkeeping/invoice-entries.ts',
'lib/bookkeeping/reminder-fee-entries.ts',
'lib/bookkeeping/rot-rut-entries.ts',
'lib/core/bookkeeping/storno-service.ts',
]
const written = new Set<string>()
for (const rel of writers) {
const src = readFileSync(join(process.cwd(), rel), 'utf8')
for (const m of src.matchAll(/source_type: '([a-z_]+)'/g)) written.add(m[1])
}
expect(written.size).toBeGreaterThan(0)
const missing = [...written].filter(
(t) => !(INVOICE_ENGINE_SOURCE_TYPES as readonly string[]).includes(t),
)
expect(missing).toEqual([])
})
})
@@ -0,0 +1,73 @@
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)
})
})
+161
View File
@@ -0,0 +1,161 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Coverage of the invoice register (the `invoices` table) relative to the
* company's bookkeeping. The register only holds invoices created in
* Accounted: a company migrated mid-year (SIE import) or backfilled through
* manual/API verifikat has real customer invoices that exist ONLY as journal
* entries. Every surface that answers "which invoices do we have" from the
* register alone looks complete while silently omitting that period; this
* helper computes the disclosure those surfaces show.
*
* Detection is AR-based, not import-based: `fetchMigrationCoverageEnd()`
* keys on source_type='import', which misses invoice history recreated as
* plain manual verifikat (the 2026-09-01 user report: a full spring of
* "Kundfakturor" with source_type='manual'). An invoice booked as a
* verifikat DEBITS the receivable on 1510/1513, so posted pre-boundary AR
* debit lines from outside the invoice engine are the signal. Credits are
* deliberately not counted (a manually booked advance payment before the
* first invoice credits 1510 and says nothing about register coverage), and
* every invoice-engine source type is excluded, storno/correction included:
* a rättelse of an engine entry may be re-dated before the boundary
* (storno-service newEntryDate) and must not flag its own register invoice
* as external. Kontantmetod invoice history booked straight against 1930
* stays undetected; accepted, because widening to all class-3 revenue would
* flag cash sales (webshop orders, kassa) that were never register material.
*
* The probe is driven from journal_entries (company-indexed) with the line
* condition as an inner embed, never from journal_entry_lines with entry
* filters on the embed: that inverted shape compiles to a lateral scan of
* the whole lines table across tenants (see lib/bookkeeping/entry-lines.ts).
*/
export interface InvoiceRegisterCoverage {
/**
* Earliest invoice_date of a real, non-draft invoice in the register, i.e.
* where register-backed answers start being complete. Drafts, proformas
* and delivery notes are excluded: a backdated one would move the boundary
* and suppress the disclosure for exactly the period it exists to cover.
* Null when the register is empty (the empty state already routes the
* user to migration) OR when the lookup failed: null always means
* "unknown", never "complete".
*/
covers_from: string | null
/**
* True when posted non-invoice-engine verifikat carry AR (1510/1513)
* debit lines dated before covers_from: invoices likely exist outside the
* register for that period. False only when the probe RAN and found
* nothing; a failed probe returns NO_INVOICE_REGISTER_COVERAGE instead,
* so a DB error can never assert completeness.
*/
has_pre_register_invoices: boolean
}
export const NO_INVOICE_REGISTER_COVERAGE: InvoiceRegisterCoverage = {
covers_from: null,
has_pre_register_invoices: false,
}
/**
* Every source_type the invoice engine (and its correction paths) writes.
* Pre-boundary AR debits from these are the register's own bookkeeping, not
* evidence of register-external invoices.
*/
export const INVOICE_ENGINE_SOURCE_TYPES = [
'invoice_created',
'invoice_paid',
'invoice_cash_payment',
'credit_note',
'reminder_fee',
'rot_rut_payout',
'storno',
'correction',
] as const
/** PostgREST `in` literal for the NOT-IN filter. */
const INVOICE_ENGINE_SOURCE_TYPES_FILTER =
'(' + INVOICE_ENGINE_SOURCE_TYPES.map((t) => `"${t}"`).join(',') + ')'
const AR_ACCOUNTS = ['1510', '1513']
/**
* True when a posted, non-engine verifikat with an AR debit line exists
* before `coversFrom`. Optionally scoped to one fiscal period (used by the
* 1510 reconciliation, which compares period-scoped balances: a settled
* pre-boundary residual in an EARLIER period cannot explain this period's
* difference and must not be offered as an explanation).
* Returns null when the probe itself failed (unknown, not false).
*/
async function probePreRegisterArDebits(
supabase: SupabaseClient,
companyId: string,
coversFrom: string,
fiscalPeriodId?: string,
): Promise<boolean | null> {
let query = supabase
.from('journal_entries')
.select('id, journal_entry_lines!inner(id)')
.eq('company_id', companyId)
.eq('status', 'posted')
.lt('entry_date', coversFrom)
.not('source_type', 'in', INVOICE_ENGINE_SOURCE_TYPES_FILTER)
.in('journal_entry_lines.account_number', AR_ACCOUNTS)
.gt('journal_entry_lines.debit_amount', 0)
if (fiscalPeriodId) query = query.eq('fiscal_period_id', fiscalPeriodId)
const { data, error } = await query.limit(1).maybeSingle()
if (error) return null
return data != null
}
export async function fetchInvoiceRegisterCoverage(
supabase: SupabaseClient,
companyId: string,
): Promise<InvoiceRegisterCoverage> {
const { data: firstInvoice, error: firstError } = await supabase
.from('invoices')
.select('invoice_date')
.eq('company_id', companyId)
.neq('status', 'draft')
// Proformas and delivery notes are not register invoices (no AR posting);
// an early one would move the boundary without covering anything.
.eq('document_type', 'invoice')
.order('invoice_date', { ascending: true })
.limit(1)
.maybeSingle()
if (firstError) return NO_INVOICE_REGISTER_COVERAGE
const coversFrom = (firstInvoice as { invoice_date?: string } | null)?.invoice_date ?? null
if (!coversFrom) return NO_INVOICE_REGISTER_COVERAGE
const hasPreRegister = await probePreRegisterArDebits(supabase, companyId, coversFrom)
// Probe failure means UNKNOWN: never report a confident "complete".
if (hasPreRegister === null) return NO_INVOICE_REGISTER_COVERAGE
return {
covers_from: coversFrom,
has_pre_register_invoices: hasPreRegister,
}
}
/**
* Period-scoped variant for the 1510/1513 reconciliation: does pre-boundary
* non-engine AR debit activity exist INSIDE the given fiscal period? Only
* then may the reconciliation offer "migrated/backfilled invoices" as an
* explanation for its difference. Returns false on any lookup failure (no
* explanation is the safe degrade: the red badge stands unqualified).
*/
export async function hasPreRegisterArInPeriod(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<boolean> {
const coverage = await fetchInvoiceRegisterCoverage(supabase, companyId)
if (!coverage.has_pre_register_invoices || !coverage.covers_from) return false
const inPeriod = await probePreRegisterArDebits(
supabase,
companyId,
coverage.covers_from,
fiscalPeriodId,
)
return inPeriod === true
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 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)
}
+24
View File
@@ -3,6 +3,11 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { roundOre } from '@/lib/money'
import { fetchPaymentsAsOf, outstandingAsOf, todayIsoDate, type PaymentsAsOf } from './reskontra-payments'
import {
fetchInvoiceRegisterCoverage,
NO_INVOICE_REGISTER_COVERAGE,
type InvoiceRegisterCoverage,
} from '@/lib/invoices/invoice-register-coverage'
export interface ARInvoiceDetail {
invoice_id: string
@@ -48,6 +53,14 @@ export interface ARLedgerReport {
* outstanding_sek = null) so the user can see them.
*/
unconverted_fx_count: number
/**
* Coverage of the invoice register this ledger is built from. A migrated
* or backfilled company has invoices that exist only as journal entries;
* this ledger cannot list them, and their 1510 balance is why the 1510
* reconciliation shows a residual. Renderers show the boundary when
* register_coverage.has_pre_register_invoices is true.
*/
register_coverage: InvoiceRegisterCoverage
}
/**
@@ -105,9 +118,19 @@ export async function generateARLedger(
total_overdue: 0,
unpaid_count: 0,
unconverted_fx_count: 0,
register_coverage: NO_INVOICE_REGISTER_COVERAGE,
}
}
// Coverage disclosure. Non-fatal: the ledger's own numbers do not depend
// on it, so a failed lookup degrades to "no marker", never to an error.
let registerCoverage: InvoiceRegisterCoverage = NO_INVOICE_REGISTER_COVERAGE
try {
registerCoverage = await fetchInvoiceRegisterCoverage(supabase, companyId)
} catch {
// keep NO_INVOICE_REGISTER_COVERAGE
}
// Group by customer and calculate aging
const byCustomer = new Map<string, ARLedgerEntry>()
let unconvertedFxCount = 0
@@ -238,5 +261,6 @@ export async function generateARLedger(
total_overdue: Math.round(total_overdue * 100) / 100,
unpaid_count,
unconverted_fx_count: unconvertedFxCount,
register_coverage: registerCoverage,
}
}
+23
View File
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { hasPreRegisterArInPeriod } from '@/lib/invoices/invoice-register-coverage'
export interface ARReconciliationResult {
ar_ledger_total: number
@@ -21,6 +22,17 @@ export interface ARReconciliationResult {
* than a true reconciliation break.
*/
unconverted_fx_count: number
/**
* True when posted non-invoice-engine AR debit verifikat dated before the
* register's first invoice exist IN THIS PERIOD (migrated/backfilled
* invoice history). Only then may a renderer offer "migration" as an
* explanation for the difference: pre-boundary activity settled in an
* earlier period contributes nothing to this period's balance, and
* offering it anyway would cushion a genuine felbokning.
* Optional so report fixtures elsewhere stay valid; generateARReconciliation
* always sets it.
*/
pre_register_ar_in_period?: boolean
}
/**
@@ -115,6 +127,16 @@ export async function generateARReconciliation(
const difference = Math.round((arLedgerTotal - account1510Balance) * 100) / 100
// Coverage context for the difference. Non-fatal: a failed lookup degrades
// to "no explanation offered" (the helper returns false on failure), which
// leaves the red badge standing unqualified rather than excused.
let preRegisterArInPeriod = false
try {
preRegisterArInPeriod = await hasPreRegisterArInPeriod(supabase, companyId, periodId)
} catch {
// keep false
}
return {
ar_ledger_total: Math.round(arLedgerTotal * 100) / 100,
account_1510_balance: Math.round(account1510Balance * 100) / 100,
@@ -125,5 +147,6 @@ export async function generateARReconciliation(
// Avstämd: the user must fix the underlying data first.
is_reconciled: Math.abs(difference) < 0.01 && unconvertedFxCount === 0,
unconverted_fx_count: unconvertedFxCount,
pre_register_ar_in_period: preRegisterArInPeriod,
}
}