Files
accounted/lib/bookkeeping/handlers/supplier-invoice-handler.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

230 lines
8.7 KiB
TypeScript

import { eventBus } from '@/lib/events/bus'
import type { EventPayload } from '@/lib/events/types'
import { createClient } from '@/lib/supabase/server'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { createLogger } from '@/lib/logger'
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
const log = createLogger('supplier-invoice-handler')
/**
* Core event handler: creates a registration journal entry when a supplier
* invoice is confirmed (accrual method only).
*
* This decouples journal entry creation from the invoice-inbox extension,
* making it a core concern triggered by the `supplier_invoice.confirmed` event.
*/
async function handleSupplierInvoiceConfirmed(
payload: EventPayload<'supplier_invoice.confirmed'>
): Promise<void> {
const { supplierInvoice, userId, companyId } = payload
// Guard: the inbox convert flow (and app/api/supplier-invoices) creates the
// registration entry inline before emitting. Without this short-circuit we
// double-post to 2440/2641/expense and overwrite registration_journal_entry_id.
if (supplierInvoice.registration_journal_entry_id) return
const supabase = await createClient()
// Re-fetch to catch callers whose in-memory payload is stale (invoice-inbox
// updates the row after insert but emits the pre-update object).
const { data: current } = await supabase
.from('supplier_invoices')
.select('registration_journal_entry_id')
.eq('id', supplierInvoice.id)
.single()
if (current?.registration_journal_entry_id) return
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
if (accountingMethod !== 'accrual') return
// Fetch invoice items
const { data: items, error: itemsError } = await supabase
.from('supplier_invoice_items')
.select('*')
.eq('supplier_invoice_id', supplierInvoice.id)
.order('sort_order')
if (itemsError || !items || items.length === 0) {
log.error('Failed to fetch invoice items:', itemsError)
return
}
// Fetch supplier type
const { data: supplier } = await supabase
.from('suppliers')
.select('supplier_type')
.eq('id', supplierInvoice.supplier_id)
.single()
const supplierType = supplier?.supplier_type || 'swedish_business'
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
supabase,
companyId,
userId,
supplierInvoice,
items as SupplierInvoiceItem[],
supplierType
)
if (journalEntry) {
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', supplierInvoice.id)
// Lines with a periodisering period get their schedule + catch-up
// dissolutions. Idempotent per line, so a replayed event is safe.
const scheduleResult = await createSchedulesForSupplierInvoice(
supabase,
companyId,
userId,
supplierInvoice,
items as SupplierInvoiceItem[],
journalEntry.id,
)
if (scheduleResult.failed > 0) {
log.error('accrual schedule creation failed for confirmed supplier invoice', {
supplierInvoiceId: supplierInvoice.id,
failed: scheduleResult.failed,
})
}
}
} catch (err) {
log.error('Failed to create registration journal entry:', err)
}
}
/**
* Retroactive match: when a supplier invoice is registered or approved, scan
* recent unmatched expense transactions for the bank payment that settles it.
*
* The forward direction (a freshly imported tx scanning existing invoices) lives
* in lib/transactions/ingest.ts. This is the mirror: needed because a Bankgiro
* payment is often imported BEFORE the invoice is registered, and nothing
* re-matched it afterwards (the reported RosholmDell case). Reuses the same
* `findSupplierInvoiceMatch` scorer (one invoice, many txs) so the two
* directions can never score differently.
*
* Writes a SUGGESTION (potential_supplier_invoice_id), never an auto-link
* (supplier_invoice_id): the match card / confirm dialog only surfaces for the
* suggestion column (transactions page + lib/worklist), and the product choice
* is "pre-fill, confirm to book": the user reviews and posts the verifikat.
* Setting supplier_invoice_id directly would skip that confirmation and strand
* the payment unbooked. This handler therefore never creates a journal entry.
*/
async function handleSupplierInvoiceRetroMatch(
// .registered and .approved share this payload shape.
payload: EventPayload<'supplier_invoice.registered'>
): Promise<void> {
const { supplierInvoice, userId, companyId } = payload
try {
const supabase = await createClient()
// Re-fetch with the supplier relation (the scorer reads bankgiro/plusgiro/
// name): the emitted payload can be stale or lack the join.
const { data: invoice } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*)')
.eq('id', supplierInvoice.id)
.eq('company_id', companyId)
.single()
if (!invoice) return
if (!['registered', 'approved'].includes(invoice.status)) return
if ((invoice.remaining_amount ?? invoice.total) <= 0) return
if (invoice.transaction_id) return // already settled by a bank tx
// Idempotency: if a tx already points at this invoice (a prior retro run, or
// ingest's forward match), don't add a competing suggestion.
const { count: linkedCount } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('supplier_invoice_id', invoice.id)
if (linkedCount && linkedCount > 0) return
// Bound the scan: ~90 days before the invoice/due date covers normal terms
// and the early-payment case, without trawling the whole ledger.
const anchor = invoice.invoice_date || invoice.due_date
if (!anchor) return
const anchorMs = new Date(anchor).getTime()
const lowDate = new Date(anchorMs - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
const { data: candidates } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.is('supplier_invoice_id', null)
.is('potential_supplier_invoice_id', null)
.is('journal_entry_id', null)
.lt('amount', 0)
.gte('date', lowDate)
.order('date', { ascending: false })
.limit(200)
if (!candidates || candidates.length === 0) return
// Pick the best candidate: highest confidence, tie-break on the payment
// closest to the invoice date.
let best: { tx: Transaction; confidence: number; matchMethod: string } | null = null
for (const tx of candidates) {
const match = findSupplierInvoiceMatch(tx as Transaction, [invoice as SupplierInvoice])
if (!match) continue
const closer =
best !== null &&
match.confidence === best.confidence &&
Math.abs(new Date(tx.date).getTime() - anchorMs) <
Math.abs(new Date(best.tx.date).getTime() - anchorMs)
if (!best || match.confidence > best.confidence || closer) {
best = { tx: tx as Transaction, confidence: match.confidence, matchMethod: match.matchMethod }
}
}
if (!best) return
// Suggestion only. The `.is('supplier_invoice_id', null)` guard avoids a
// race where the tx was linked between the scan and this write.
await supabase
.from('transactions')
.update({ potential_supplier_invoice_id: invoice.id })
.eq('id', best.tx.id)
.is('supplier_invoice_id', null)
logMatchEvent(supabase, userId, best.tx.id, 'auto_suggested', {
supplierInvoiceId: invoice.id,
matchConfidence: best.confidence,
matchMethod: best.matchMethod,
})
} catch (err) {
// Never break invoice registration: this is a best-effort convenience.
log.error('Retroactive supplier-invoice match failed:', err)
}
}
/**
* Register the core supplier invoice handlers on the event bus.
* Returns a combined unsubscribe function.
*/
export function registerSupplierInvoiceHandler(): () => void {
const unsubscribers = [
eventBus.on('supplier_invoice.confirmed', handleSupplierInvoiceConfirmed),
eventBus.on('supplier_invoice.registered', handleSupplierInvoiceRetroMatch),
eventBus.on('supplier_invoice.approved', handleSupplierInvoiceRetroMatch),
]
return () => unsubscribers.forEach((unsub) => unsub())
}