db8983ba9e
* feat(arcim-migration): Briox provider with SIE-over-API import - Briox auth via account ID + application token (no app-level credentials); both tokens rotate on refresh and are persisted - New sie-fetcher pulls the general ledger as SIE through the provider API for Fortnox, Briox and Bjorn Lunden - Wizard stops on a failed SIE import and surfaces the real errors instead of proceeding to the misleading migrate-guard message - PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED; new PROVIDER_TOKEN_INVALID for rejected provider credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices Defer revenue/costs per invoice line to 29xx/17xx interim accounts with automatic monthly dissolution (nightly cron + catch-up at registration), schedule cancellation on credit, year-end auto-detect exclusion for already-scheduled invoices, invoice-inbox service-period extraction for prefill, and an MCP tool to list schedules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing Generate the annual report as iXBRL from a generated taxonomy registry (K2 element lists, taxonomy:generate/check scripts + CI guard), expose it via the fiscal-period API, and add the bolagsverket extension for digital submission to eget utrymme with webhook-driven status tracking (submissions table + pg tests, lifecycle events, year-end wizard UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mcp): raise origin-guard test timeout to 20s The dynamic import pulls in the full server module; the parse alone flirts with the 5s default under full-suite parallel load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add new scripts and documentation for K2 AB taxonomy generation and validation - Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models. - Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle. - Included new documentation files: - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx` - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx` - `taxonomi-paket-2024-09-12_rev20250312.zip` * Add tests for bookkeeping accruals dissolution and supplier invoices - Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios. - Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions. - Introduce tests for the Arcim migration provider client, ensuring token handling and error classification. - Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings. - Add Zod schemas for Bolagsverket response payloads to ensure proper validation. - Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping. - Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly. - Introduce typed domain errors for accrual schedules to improve error handling in the service. - Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling. * fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments * fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated * feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id * feat(bokslut): enhance compliance and financial processing features with new submission details and security measures --------- Co-authored-by: Claude Fable 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
|
||
}
|