Bug/customer invoice bug (#628)

* fix(supplier-invoices): self-assess reverse-charge VAT + link payments to vouchers

Reverse-charge supplier invoices now carry a per-item reverse_charge_rate (0.06/0.12/0.25). Under omvänd skattskyldighet the supplier charges 0% VAT, so the line vat_rate stays 0 and the buyer self-assesses fiktiv moms at the statutory rate. Centralizes rate resolution (resolveReverseChargeRate) and the ruta 20-24 basis-account guard (isReverseChargeBasisAccount) in vat-entries so the booking engine and review-dialog preview can no longer drift.

Adds the link_supplier_invoice_voucher pending operation: mark a leverantorsfaktura paid by linking an existing posted verifikat that debits 2440, with no new journal entry. Exposes find-candidates/link MCP tools and the bulk-reconcile helper, scoped under suppliers:read/write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(vat): report yearly VAT over the rakenskapsar, not the calendar year

Annual VAT (helarsmoms) is filed per beskattningsar/rakenskapsar (SFL 26 kap), which can be extended or shortened up to 18 months. The previous Jan-Dec calendar span silently dropped part of an extended first year. calculateVatDeclaration now accepts a fiscalPeriodId and resolves the period's actual bounds for yearly; monthly/quarterly stay calendar. The reports UI passes the selected fiscal period, defaults the periodicity from the company's moms_period setting, and carries the period into the ruta drill-down. full-archive export threads the period id through too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migration): resolve supplier invoice status from payment amounts

The provider's lifecycle status and its payment status are computed independently upstream and can contradict each other (e.g. a Fortnox invoice marked booked but fully paid). Both the arcim entity-mapper and the Fortnox mapper now let payment state win: fully paid -> paid, partial -> partially_paid, otherwise the mapped lifecycle status, with credit notes forced terminal. Balance is compared numerically (never strict === 0) so float drift or a residual ore resolves cleanly, and an absent Balance is treated as unpaid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(enable-banking): only ingest booked transactions to stop re-import drift

Pending entries are skipped during sync: a pending row is unstable across syncs (a later 'synka nu' returns it still pending or finally booked, often with a different effective date). Because both the dedup external_id and the content-dedup key are date-derived, that drift minted a new id and re-imported a transaction that already existed - observed in production as the same amount+description landing twice with different dates. Gating the import set on a stable booking_date removes the drift at the source and leaves booked rows' ids byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(gitignore): ignore local SIE test fixtures

tests/fixtures/sie/ may contain real or scrubbed company data and must never be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoice): handle errors during registration journal entry creation and ensure invoice rollback
feat(tests): add test for reverse charge rate handling on supplier invoice line items
feat(fortnox): ensure paid status reflects zero balance for fully paid invoices
chore(migrations): add reverse_charge_rate to supplier_invoice_items and backfill link_supplier_invoice_voucher

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-02 13:25:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 358c25094d
commit f6ee0c2a82
43 changed files with 2226 additions and 182 deletions
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the two dependencies so we test the ORCHESTRATION logic (confidence
// gating, near-tie margin, cross-invoice voucher exclusivity, consumed-voucher
// filtering) in isolation. The matcher + RPC link are exercised by their own
// suites (supplier-voucher-matching.test.ts / .pg.test.ts).
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
vi.mock('../supplier-voucher-matching', () => ({
findMatchingVouchersForSupplierInvoice: vi.fn(),
linkSupplierInvoiceToVoucher: vi.fn(),
}))
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
findMatchingVouchersForSupplierInvoice,
linkSupplierInvoiceToVoucher,
} from '../supplier-voucher-matching'
import { reconcileSupplierInvoiceVouchers } from '../bulk-reconcile-supplier-vouchers'
const mFetchAll = vi.mocked(fetchAllRows)
const mFind = vi.mocked(findMatchingVouchersForSupplierInvoice)
const mLink = vi.mocked(linkSupplierInvoiceToVoucher)
interface InvOver {
id: string
number?: string
status?: string
total?: number
remaining?: number
due?: string
isCredit?: boolean
}
function inv(over: InvOver) {
const total = over.total ?? 1000
return {
id: over.id,
supplier_invoice_number: over.number ?? `F-${over.id}`,
arrival_number: 1,
status: over.status ?? 'overdue',
currency: 'SEK',
total,
paid_amount: 0,
remaining_amount: over.remaining ?? total,
due_date: over.due ?? '2026-02-01',
paid_at: null,
exchange_rate: null,
supplier_id: 's1',
is_credit_note: over.isCredit ?? false,
supplier: { id: 's1', name: 'Leverantör AB' },
}
}
function cand(over: { je: string; confidence?: number; amount?: number; n?: number }) {
return {
journal_entry_id: over.je,
voucher_series: 'A',
voucher_number: over.n ?? 1,
entry_date: '2026-02-01',
description: 'Leverantörsbetalning',
ap_debit_amount: over.amount ?? 1000,
currency: 'SEK',
ap_line_currency: 'SEK',
period_locked: false,
confidence: over.confidence ?? 0.95,
match_reason: 'test',
}
}
/** Queue the two fetchAllRows reads: invoices, then existing payments. */
function queue(invoices: unknown[], payments: { journal_entry_id: string | null }[] = []) {
mFetchAll.mockReset()
mFetchAll.mockResolvedValueOnce(invoices as never).mockResolvedValueOnce(payments as never)
}
const okLink = (over: { paymentAmount?: number; status?: 'paid' | 'partially_paid'; je: string }) => ({
ok: true as const,
result: {
paymentId: 'p1',
invoiceStatus: over.status ?? ('paid' as const),
paidAmount: 1000,
remainingAmount: 0,
paymentAmount: over.paymentAmount ?? 1000,
journalEntryId: over.je,
},
})
const run = () =>
reconcileSupplierInvoiceVouchers({ supabase: {} as never, companyId: 'c1', userId: 'u1' })
describe('reconcileSupplierInvoiceVouchers', () => {
beforeEach(() => {
vi.clearAllMocks()
mFind.mockReset()
mLink.mockReset()
})
it('auto-links a single unambiguous exact match and marks it paid', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
mLink.mockResolvedValueOnce(okLink({ je: 'v1' }) as never)
const res = await run()
expect(res.scanned).toBe(1)
expect(res.autoLinked).toBe(1)
expect(res.ambiguous).toBe(0)
expect(res.unmatched).toBe(0)
expect(res.links).toHaveLength(1)
expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' })
expect(mLink).toHaveBeenCalledTimes(1)
expect(mLink).toHaveBeenCalledWith({} , 'u1', 'c1', expect.objectContaining({
supplierInvoiceId: 'i1',
journalEntryId: 'v1',
}))
})
it('does not auto-link a below-threshold (amount-only) candidate', async () => {
queue([inv({ id: 'i1' })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.8, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0]).toMatchObject({ supplier_invoice_id: 'i1', reason: 'low_confidence' })
expect(mLink).not.toHaveBeenCalled()
})
it('does not auto-link when the top two candidates are within the margin', async () => {
queue([inv({ id: 'i1' })])
mFind.mockResolvedValueOnce([
cand({ je: 'v1', confidence: 0.95, amount: 1000 }),
cand({ je: 'v2', confidence: 0.95, amount: 1000, n: 2 }),
] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0].reason).toBe('multiple_candidates')
expect(mLink).not.toHaveBeenCalled()
})
it('demotes BOTH invoices when one voucher is the top pick for two of them', async () => {
queue([inv({ id: 'i1', remaining: 1000 }), inv({ id: 'i2', remaining: 1000 })])
// Each invoice has exactly one strong candidate — but it is the SAME voucher.
mFind
.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(2)
expect(res.review.every((r) => r.reason === 'voucher_contested')).toBe(true)
expect(mLink).not.toHaveBeenCalled()
})
it('excludes a voucher already consumed as a payment on another invoice', async () => {
queue([inv({ id: 'i1' })], [{ journal_entry_id: 'v1' }])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never)
const res = await run()
expect(res.unmatched).toBe(1)
expect(res.autoLinked).toBe(0)
expect(mLink).not.toHaveBeenCalled()
})
it('routes a candidate whose AP debit exceeds the remaining to review', async () => {
queue([inv({ id: 'i1', total: 1000, remaining: 500 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.99, amount: 1000 })] as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.review[0].reason).toBe('amount_exceeds_remaining')
expect(mLink).not.toHaveBeenCalled()
})
it('dryRun produces the plan without writing', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
const res = await reconcileSupplierInvoiceVouchers({
supabase: {} as never,
companyId: 'c1',
userId: 'u1',
dryRun: true,
})
expect(res.autoLinked).toBe(1)
expect(res.links[0]).toMatchObject({ journal_entry_id: 'v1', invoice_status: 'paid' })
expect(mLink).not.toHaveBeenCalled()
})
it('skips credit notes and zero-remaining invoices entirely', async () => {
queue([inv({ id: 'i1', isCredit: true }), inv({ id: 'i2', remaining: 0 })])
const res = await run()
expect(res.scanned).toBe(0)
expect(res.autoLinked).toBe(0)
expect(mFind).not.toHaveBeenCalled()
})
it('surfaces an RPC rejection as review rather than a successful link', async () => {
queue([inv({ id: 'i1', remaining: 1000 })])
mFind.mockResolvedValueOnce([cand({ je: 'v1', confidence: 0.95, amount: 1000 })] as never)
mLink.mockResolvedValueOnce({ ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' } as never)
const res = await run()
expect(res.autoLinked).toBe(0)
expect(res.ambiguous).toBe(1)
expect(res.review[0].reason).toBe('voucher_contested')
})
})
@@ -0,0 +1,370 @@
/**
* Bulk reconcile supplier invoices to already-posted GL payment vouchers.
*
* Context: when a company is migrated from another system (e.g. Fortnox via the
* arcim-migration extension), the general ledger — including the bank-payment
* vouchers that settle accounts payable (Dr 2440 / Cr 1930) — is imported
* separately via SIE. Supplier invoices are imported as standalone
* `supplier_invoices` rows with NO link to those vouchers (the entity mapper
* never sets `payment_journal_entry_id`). Fortnox is queried with
* `?filter=unpaid`, so an invoice whose payment was booked in the source GL but
* never registered against the leverantörsfaktura object arrives here as an
* open payable. Once its due date passes the nightly cron flips it to
* `overdue` — even though the settling voucher already exists in the GL.
*
* This pass links each open payable to its matching posted voucher (reusing the
* exact same matcher + RPC behind the manual "Markera som betald → Befintlig
* verifikation" UI flow), so genuinely-settled invoices show as paid instead of
* falsely overdue. It NEVER creates, edits, or deletes a journal entry — it only
* inserts a `supplier_invoice_payments` row pointing at the existing voucher and
* advances the invoice's paid/remaining/status (all via the atomic
* `link_supplier_invoice_to_voucher` RPC).
*
* Safety: auto-linking is intentionally conservative. A voucher is linked
* automatically only when the match is unambiguous (see AUTO_LINK_* constants
* and the uniqueness rules below). Everything else is surfaced for manual review
* rather than guessed at. The function is idempotent and order-independent — it
* can be re-run any time after both halves of a migration exist.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
findMatchingVouchersForSupplierInvoice,
linkSupplierInvoiceToVoucher,
type SupplierVoucherCandidate,
} from './supplier-voucher-matching'
import type { SupplierInvoice, Supplier } from '@/types'
const log = createLogger('bulk-reconcile-supplier-vouchers')
/**
* Minimum confidence for an UNATTENDED auto-link. 0.95 = OCR/invoice-number hit
* (0.99) or exact-remaining-amount AND supplier-name corroboration (0.95).
* Amount-only matches (0.80, even with the +0.05 date bump → 0.85) are
* deliberately excluded — too many invoices share round amounts.
*/
const AUTO_LINK_MIN_CONFIDENCE = 0.95
/**
* Required confidence gap between the top candidate and the runner-up. A near
* tie means two vouchers look equally plausible → not safe to auto-pick. A
* margin (not exact equality) absorbs the ±0.05 date-proximity perturbation.
*/
const AUTO_LINK_MIN_MARGIN = 0.1
/** 0.5 öre — mirrors the tolerance used across the matching/RPC paths. */
const AMOUNT_TOLERANCE = 0.005
/** Safety cap on invoices processed in a single run (Vercel 300s budget). */
const DEFAULT_MAX_INVOICES = 2000
/** Supplier-invoice statuses that represent an open payable. */
const PAYABLE_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid']
type ReconcileInvoiceRow = SupplierInvoice & {
is_credit_note?: boolean | null
supplier?: { id: string; name: string } | null
}
export type ReconcileReviewReason =
| 'multiple_candidates' // ≥2 candidates within the auto-link margin
| 'low_confidence' // best candidate below AUTO_LINK_MIN_CONFIDENCE
| 'amount_exceeds_remaining' // best candidate would overpay the invoice
| 'voucher_contested' // one voucher is the top pick for >1 invoice, or RPC rejected
export interface ReconcileLink {
supplier_invoice_id: string
supplier_invoice_number: string | null
journal_entry_id: string
payment_amount: number
invoice_status: 'paid' | 'partially_paid'
confidence: number
match_reason: string
}
export interface ReconcileReviewItem {
supplier_invoice_id: string
supplier_invoice_number: string | null
reason: ReconcileReviewReason
candidates: SupplierVoucherCandidate[]
}
export interface ReconcileResult {
/** Open payables considered (after credit-note / zero-remaining filtering). */
scanned: number
/** Invoices auto-linked to a voucher (or that would be, when dryRun). */
autoLinked: number
/** Invoices with candidate(s) but not safe to auto-link — need manual review. */
ambiguous: number
/** Invoices with no eligible voucher candidate at all. */
unmatched: number
/** True when more payables existed than `maxInvoices` and the rest were skipped. */
capped: boolean
links: ReconcileLink[]
review: ReconcileReviewItem[]
}
export interface ReconcileOptions {
supabase: SupabaseClient
companyId: string
/** Real user id — written onto the supplier_invoice_payments row + emitted event. */
userId: string
/** Compute the plan without writing. Default false. */
dryRun?: boolean
/** Max invoices to process in one run. Default 2000. */
maxInvoices?: number
onProgress?: (done: number, total: number) => void
}
const SELECT_COLUMNS =
'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, is_credit_note, supplier:suppliers(id, name)'
function remainingOf(inv: ReconcileInvoiceRow): number {
if (typeof inv.remaining_amount === 'number') return Math.max(0, inv.remaining_amount)
return Math.max(0, Math.round((inv.total - (inv.paid_amount ?? 0)) * 100) / 100)
}
/**
* Link open supplier-invoice payables to their matching already-posted GL
* vouchers. See file header for the full rationale and guarantees.
*/
export async function reconcileSupplierInvoiceVouchers(
opts: ReconcileOptions,
): Promise<ReconcileResult> {
const { supabase, companyId, userId, dryRun = false } = opts
const maxInvoices = opts.maxInvoices ?? DEFAULT_MAX_INVOICES
const result: ReconcileResult = {
scanned: 0,
autoLinked: 0,
ambiguous: 0,
unmatched: 0,
capped: false,
links: [],
review: [],
}
// 1. Open payables with an outstanding balance, excluding credit notes.
// Deterministic order so re-runs and the cross-invoice uniqueness pass are
// stable. Fully-paid invoices ('paid') are excluded by the status filter,
// making re-runs naturally idempotent.
const invoices = await fetchAllRows<ReconcileInvoiceRow>(
({ from, to }) =>
supabase
.from('supplier_invoices')
.select(SELECT_COLUMNS)
.eq('company_id', companyId)
.in('status', PAYABLE_STATUSES)
.order('due_date', { ascending: true })
.order('id', { ascending: true })
.range(from, to) as unknown as PromiseLike<{
// The `supplier:suppliers(id, name)` join makes PostgREST infer `supplier`
// as an array; ReconcileInvoiceRow models the runtime single-object shape.
data: ReconcileInvoiceRow[] | null
error: { message: string } | null
}>,
)
const payables = invoices.filter(
(inv) => !inv.is_credit_note && remainingOf(inv) > AMOUNT_TOLERANCE,
)
const toProcess = payables.slice(0, maxInvoices)
if (payables.length > maxInvoices) {
result.capped = true
log.warn('reconcile capped to maxInvoices — remaining payables left for a later run', {
companyId,
totalPayables: payables.length,
cap: maxInvoices,
})
}
// 2. Pre-load every voucher already consumed as a supplier payment (for ANY
// invoice in the company). Neither the matcher nor the RPC stop the SAME
// voucher being linked to a SECOND invoice, so we enforce exclusivity here.
const existingPayments = await fetchAllRows<{ journal_entry_id: string | null }>(({ from, to }) =>
supabase
.from('supplier_invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.range(from, to),
)
const consumedVouchers = new Set(
existingPayments
.map((p) => p.journal_entry_id)
.filter((id): id is string => !!id),
)
// 3. Per-invoice candidate gathering (read-only). Decide auto-eligibility.
interface Plan {
invoice: ReconcileInvoiceRow
candidates: SupplierVoucherCandidate[]
top?: SupplierVoucherCandidate
}
const autoCandidatePlans: Plan[] = []
for (const invoice of toProcess) {
result.scanned++
const candidates = await findMatchingVouchersForSupplierInvoice(
supabase,
companyId,
invoice as unknown as SupplierInvoice & { supplier?: Supplier },
{ limit: 5 },
)
// Drop vouchers already used elsewhere in the company.
const fresh = candidates.filter((c) => !consumedVouchers.has(c.journal_entry_id))
if (fresh.length === 0) {
result.unmatched++
continue
}
const top = fresh[0]
const runnerUp = fresh[1]
const remaining = remainingOf(invoice)
const confidentEnough = top.confidence >= AUTO_LINK_MIN_CONFIDENCE
const clearMargin = !runnerUp || top.confidence - runnerUp.confidence >= AUTO_LINK_MIN_MARGIN
// The RPC rejects a voucher whose AP debit exceeds the remaining amount; an
// OCR match (which ignores amount) could trip this, so screen it out here.
const amountFits = top.ap_debit_amount <= remaining + AMOUNT_TOLERANCE
if (confidentEnough && clearMargin && amountFits) {
autoCandidatePlans.push({ invoice, candidates: fresh, top })
} else {
result.ambiguous++
result.review.push({
supplier_invoice_id: invoice.id,
supplier_invoice_number: invoice.supplier_invoice_number ?? null,
reason: !confidentEnough
? 'low_confidence'
: !amountFits
? 'amount_exceeds_remaining'
: 'multiple_candidates',
candidates: fresh,
})
}
}
// 4. Cross-invoice uniqueness: if one voucher is the top auto-pick for more
// than one invoice (e.g. two identical 5 000 kr invoices both grabbing the
// same 5 000 kr voucher), auto-link NONE of them — demote all to review.
const claimsByVoucher = new Map<string, Plan[]>()
for (const plan of autoCandidatePlans) {
const key = plan.top!.journal_entry_id
const arr = claimsByVoucher.get(key) ?? []
arr.push(plan)
claimsByVoucher.set(key, arr)
}
const safePlans: Plan[] = []
for (const claimants of claimsByVoucher.values()) {
if (claimants.length === 1) {
safePlans.push(claimants[0])
} else {
for (const c of claimants) {
result.ambiguous++
result.review.push({
supplier_invoice_id: c.invoice.id,
supplier_invoice_number: c.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: c.candidates,
})
}
}
}
// 5. Link the unambiguous plans. Deterministic order; respect exclusivity
// across the batch via consumedVouchers.
safePlans.sort(
(a, b) =>
(a.invoice.due_date ?? '').localeCompare(b.invoice.due_date ?? '') ||
a.invoice.id.localeCompare(b.invoice.id),
)
let done = 0
for (const plan of safePlans) {
const top = plan.top!
const remaining = remainingOf(plan.invoice)
// Defensive: a voucher consumed earlier in THIS batch is off-limits.
if (consumedVouchers.has(top.journal_entry_id)) {
result.ambiguous++
result.review.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: plan.candidates,
})
continue
}
if (dryRun) {
const willBeFullyPaid = top.ap_debit_amount >= remaining - AMOUNT_TOLERANCE
result.autoLinked++
result.links.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
journal_entry_id: top.journal_entry_id,
payment_amount: Math.min(top.ap_debit_amount, remaining),
invoice_status: willBeFullyPaid ? 'paid' : 'partially_paid',
confidence: top.confidence,
match_reason: top.match_reason,
})
consumedVouchers.add(top.journal_entry_id)
done++
opts.onProgress?.(done, safePlans.length)
continue
}
const outcome = await linkSupplierInvoiceToVoucher(supabase, userId, companyId, {
supplierInvoiceId: plan.invoice.id,
journalEntryId: top.journal_entry_id,
notes: `Auto-länkad vid avstämning (${Math.round(top.confidence * 100)}% säkerhet): ${top.match_reason}`,
})
if (outcome.ok) {
result.autoLinked++
consumedVouchers.add(top.journal_entry_id)
result.links.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
journal_entry_id: top.journal_entry_id,
payment_amount: outcome.result.paymentAmount,
invoice_status: outcome.result.invoiceStatus,
confidence: top.confidence,
match_reason: top.match_reason,
})
} else {
// The RPC re-validates atomically; a rejection here (race, already-linked,
// amount drift) means it isn't a clean auto-link — surface it.
result.ambiguous++
result.review.push({
supplier_invoice_id: plan.invoice.id,
supplier_invoice_number: plan.invoice.supplier_invoice_number ?? null,
reason: 'voucher_contested',
candidates: plan.candidates,
})
log.warn('auto-link rejected by RPC', {
companyId,
supplierInvoiceId: plan.invoice.id,
journalEntryId: top.journal_entry_id,
code: outcome.code,
})
}
done++
opts.onProgress?.(done, safePlans.length)
}
log.info('reconcile complete', {
companyId,
dryRun,
scanned: result.scanned,
autoLinked: result.autoLinked,
ambiguous: result.ambiguous,
unmatched: result.unmatched,
capped: result.capped,
})
return result
}