Files
accounted/lib/bokslut/accruals/auto-detect.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

279 lines
11 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { parseInvoiceDateRange } from './date-range-parser'
export type PeriodiseringSource = 'invoice' | 'supplier_invoice'
export type PeriodiseringConfidence = 'high' | 'medium' | 'low'
export interface PeriodiseringSuggestion {
/** Underlying source invoice id (invoices.id or supplier_invoices.id). */
source_invoice_id: string
source_type: PeriodiseringSource
/** Net amount of the invoice (subtotal: excludes VAT, since VAT is
* reported in its own period and not periodiserad). */
original_amount: number
/** Portion of `original_amount` that falls AFTER period_end and should be
* reclassified to 17xx / 2970. Rounded to whole krona to match the
* manual prepaid/accrued helpers. */
periodisering_amount: number
/** Inclusive ISO start of the parsed service window. */
parsed_start: string
/** Inclusive ISO end of the parsed service window. */
parsed_end: string
confidence: PeriodiseringConfidence
/** One-sentence Swedish explanation for the wizard card. */
reason: string
/** Human-readable label of the source (supplier name / customer name +
* invoice number) for the wizard card. */
source_label: string
/** Suggested BAS accounts. For supplier invoices: prepaid (1710) ← expense
* (the source line's account_number, fallback 5800). For customer
* invoices: deferred revenue (2970) ← revenue (3001 default). */
suggested_prepaid_account: string | null
suggested_deferred_account: string | null
}
interface InvoiceRow {
id: string
invoice_number: string | null
invoice_date: string
subtotal: number
notes: string | null
customers: { name: string } | null
invoice_items: { description: string }[] | null
}
interface SupplierInvoiceRow {
id: string
supplier_invoice_number: string
invoice_date: string
subtotal: number
notes: string | null
suppliers: { name: string } | null
supplier_invoice_items: { description: string; account_number: string }[] | null
}
/** Compute the inclusive number of days between two ISO dates. */
function daysBetweenInclusive(startIso: string, endIso: string): number {
const start = new Date(startIso + 'T00:00:00Z').getTime()
const end = new Date(endIso + 'T00:00:00Z').getTime()
const days = Math.round((end - start) / 86_400_000) + 1
return days
}
/** First ISO date strictly after `iso`. */
function nextDayIso(iso: string): string {
const d = new Date(iso + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + 1)
return d.toISOString().slice(0, 10)
}
/**
* Build a suggestion if the parsed window extends beyond `periodEnd`. The
* portion AFTER period_end is the periodiseringsbelopp: pro-rated over
* total days in the parsed window.
*
* Returns null when:
* - no parseable range in the description / line items
* - parsed range ends on or before period_end (nothing to periodisera)
* - parsed range starts on or after the day after period_end (entire
* window is in the next year: that's a true prepaid for the next year,
* but it was booked in THIS year; pro-rate is 100%)
*/
function buildSuggestion(args: {
sourceId: string
sourceType: PeriodiseringSource
netAmount: number
description: string | null
itemDescriptions: string[]
/** Default expense account from the first supplier-invoice line. Reserved
* for a future enhancement where the wizard can pre-fill the manual-entry
* form with the actual account rather than the 5800 fallback. Not used
* yet but kept on the buildSuggestion args to keep the call sites stable. */
_itemDefaultAccount: string | null
sourceLabel: string
periodEnd: string
}): PeriodiseringSuggestion | null {
const { sourceId, sourceType, netAmount, description, itemDescriptions, sourceLabel, periodEnd } = args
if (!Number.isFinite(netAmount) || netAmount <= 0) return null
// Try the head text first, then each item: first hit wins.
let parsed = parseInvoiceDateRange(description)
let parsedFromItem = false
if (!parsed) {
for (const itemDesc of itemDescriptions) {
const p = parseInvoiceDateRange(itemDesc)
if (p) {
parsed = p
parsedFromItem = true
break
}
}
}
if (!parsed) return null
// If the parsed range ends within the period, nothing to periodisera.
if (parsed.endDate <= periodEnd) return null
const totalDays = daysBetweenInclusive(parsed.startDate, parsed.endDate)
if (totalDays <= 0) return null
const periodisationStart = parsed.startDate > periodEnd ? parsed.startDate : nextDayIso(periodEnd)
const daysAfterPeriodEnd = daysBetweenInclusive(periodisationStart, parsed.endDate)
if (daysAfterPeriodEnd <= 0) return null
const ratio = daysAfterPeriodEnd / totalDays
const periodisationAmount = roundOre(netAmount * ratio)
if (periodisationAmount <= 0) return null
// Confidence policy: parsed from the head description wins "high"; parsed
// from a line item lands at "medium" since the head text is the canonical
// location. "low" is reserved for future heuristics that catch e.g. a
// single date + interpretation rules.
const confidence: PeriodiseringConfidence = parsedFromItem ? 'medium' : 'high'
const isSupplier = sourceType === 'supplier_invoice'
const reason = isSupplier
? `Leverantörsfakturan löper ${parsed.startDate}: ${parsed.endDate}. ${daysAfterPeriodEnd} av ${totalDays} dagar avser nästa räkenskapsår.`
: `Kundfakturan löper ${parsed.startDate}: ${parsed.endDate}. ${daysAfterPeriodEnd} av ${totalDays} dagar avser nästa räkenskapsår.`
return {
source_invoice_id: sourceId,
source_type: sourceType,
original_amount: netAmount,
periodisering_amount: periodisationAmount,
parsed_start: parsed.startDate,
parsed_end: parsed.endDate,
confidence,
reason,
source_label: sourceLabel,
suggested_prepaid_account: isSupplier ? '1710' : null,
suggested_deferred_account: isSupplier ? null : '2970',
}
}
/**
* Auto-detect candidate periodiseringar for a fiscal period. Scans:
* - customer invoices (sent / partially_paid / paid) issued within the
* period whose notes / line items mention a service window
* - supplier invoices (approved or paid) registered within the period,
* same parsing
*
* The returned suggestions are NEVER posted automatically: the wizard
* surfaces them with a confidence badge and the user accepts/rejects each.
*/
export async function detectPeriodisering(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<PeriodiseringSuggestion[]> {
// Resolve the fiscal period window. We scope candidate invoices to those
// dated within the period: anything outside is either an opening-balance
// carryover (its own concern) or a future invoice (no period to detect).
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (periodError || !period) return []
const periodStart = period.period_start as string
const periodEnd = period.period_end as string
// Invoices already covered by a löpande accrual schedule (periodisering
// skapad på fakturaraden) are handled month by month and must never be
// suggested again at year-end: that would periodisera the same belopp
// twice. Cancelled schedules don't exclude: their invoice was credited and
// the status filters below drop it anyway.
const { data: scheduleRows } = await supabase
.from('accrual_schedules')
.select('supplier_invoice_id, invoice_id')
.eq('company_id', companyId)
.neq('status', 'cancelled')
const coveredSupplierInvoices = new Set(
((scheduleRows ?? []) as Array<{ supplier_invoice_id: string | null }>)
.map((row) => row.supplier_invoice_id)
.filter(Boolean),
)
const coveredInvoices = new Set(
((scheduleRows ?? []) as Array<{ invoice_id: string | null }>)
.map((row) => row.invoice_id)
.filter(Boolean),
)
// Customer invoices: only "real" ones (sent/paid). Drafts and overdue
// get skipped: drafts haven't moved through the engine, overdue is just a
// status label that overlaps with sent here.
const { data: invoiceRows } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, subtotal, notes, customers(name), invoice_items(description)')
.eq('company_id', companyId)
.gte('invoice_date', periodStart)
.lte('invoice_date', periodEnd)
.in('status', ['sent', 'partially_paid', 'paid', 'overdue'])
// Supplier invoices: approved or paid (registration journal entry exists).
const { data: supplierRows } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_invoice_number, invoice_date, subtotal, notes, suppliers(name), supplier_invoice_items(description, account_number)',
)
.eq('company_id', companyId)
.gte('invoice_date', periodStart)
.lte('invoice_date', periodEnd)
.in('status', ['approved', 'partially_paid', 'paid'])
const suggestions: PeriodiseringSuggestion[] = []
for (const row of (invoiceRows ?? []) as unknown as InvoiceRow[]) {
if (coveredInvoices.has(row.id)) continue
const itemDescs = (row.invoice_items ?? []).map((i) => i.description).filter(Boolean)
const customerName = row.customers?.name ?? 'Okänd kund'
const sourceLabel = row.invoice_number
? `${customerName} (faktura ${row.invoice_number})`
: customerName
const s = buildSuggestion({
sourceId: row.id,
sourceType: 'invoice',
netAmount: Number(row.subtotal ?? 0),
description: row.notes,
itemDescriptions: itemDescs,
_itemDefaultAccount: null,
sourceLabel,
periodEnd,
})
if (s) suggestions.push(s)
}
for (const row of (supplierRows ?? []) as unknown as SupplierInvoiceRow[]) {
if (coveredSupplierInvoices.has(row.id)) continue
const itemDescs = (row.supplier_invoice_items ?? []).map((i) => i.description).filter(Boolean)
const firstAccount = row.supplier_invoice_items?.[0]?.account_number ?? null
const supplierName = row.suppliers?.name ?? 'Okänd leverantör'
const sourceLabel = `${supplierName} (lev.faktura ${row.supplier_invoice_number})`
const s = buildSuggestion({
sourceId: row.id,
sourceType: 'supplier_invoice',
netAmount: Number(row.subtotal ?? 0),
description: row.notes,
itemDescriptions: itemDescs,
_itemDefaultAccount: firstAccount,
sourceLabel,
periodEnd,
})
if (s) suggestions.push(s)
}
// Sort by confidence (high first) then by amount desc so the wizard shows
// the biggest, most-confident proposals at the top.
suggestions.sort((a, b) => {
const order: Record<PeriodiseringConfidence, number> = { high: 0, medium: 1, low: 2 }
if (order[a.confidence] !== order[b.confidence]) return order[a.confidence] - order[b.confidence]
return b.periodisering_amount - a.periodisering_amount
})
return suggestions
}