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:
co-authored by
Claude Opus 4.8
parent
358c25094d
commit
f6ee0c2a82
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user