ec27228a8e
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>
279 lines
11 KiB
TypeScript
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
|
|
}
|