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
@@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user