Files
accounted/lib/bokslut/accruals/accrual-detector.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

432 lines
15 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import type { AccrualProposal, AccrualsProposal } from './types'
/** Sociala avgifter on accrued salary/vacation (K3 BFNAR 2012:1 ch.19,
* K2 BFNAR 2016:10 ch.16). Same rate as the regular AGI calculation. */
export const AVGIFTER_RATE_ON_ACCRUED = 0.3142
/**
* Compute the next-day ISO date for auto-reversal. The accrual is reversed
* on the first day of the period following the closing date.
*/
function nextDayIso(closingDate: string): string {
const d = new Date(closingDate + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + 1)
return d.toISOString().slice(0, 10)
}
/**
* Propose an adjustment of semesterlöneskuld (vacation pay liability).
*
* Two correctness rules drive this entry (per BFNAR 2016:10 ch.16 and the
* vacation-liability report):
*
* 1. The delta is anchored against the **current closing balance** of
* 2920 (after any mid-year partial accruals / reversals), not the
* opening balance. Anchoring on opening would over- or understate the
* adjustment by the sum of in-period movements.
*
* 2. Semesterlöneskuld is a balance-sheet carry-forward (2920 / 2940
* persist until the vacation is actually paid). The bokslut delta is
* a normal posting that does NOT reverse on Jan 1: reversing would
* zero the liability on day 1 of the new year, which is a known
* Swedish bookkeeping error. Hence `reverses_on` is empty for this
* proposal; the wizard / UI suppresses the reversal badge accordingly.
*
* Returns null when delta is zero (no entry to propose).
*/
export async function proposeVacationLiabilityChange(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: { closingDate: string },
): Promise<AccrualProposal | null> {
const closingYear = parseInt(options.closingDate.slice(0, 4), 10)
if (Number.isNaN(closingYear)) {
throw new Error(`Invalid closing date: ${options.closingDate}`)
}
const [report, tb] = await Promise.all([
generateVacationLiability(supabase, companyId, closingYear),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
])
// Current closing balance (what 2920 should be at year-end)
const targetLiability = Math.round(report.totals.accruedAmount)
// Anchor on the CURRENT closing balance, not opening: captures any
// mid-year accruals / reversals that have already touched 2920.
const row2920 = tb.rows.find((r) => r.account_number === '2920')
const currentLiability = row2920
? Math.round((row2920.closing_credit - row2920.closing_debit) * 100) / 100
: 0
const deltaLiability = targetLiability - currentLiability
if (Math.abs(deltaLiability) < 1) {
return null
}
// Round each side to whole krona so the entry stays balanced after
// rounding. Compute avgifter on the same rounded delta.
const deltaInt = Math.round(deltaLiability)
const avgifterDelta = Math.round(deltaInt * AVGIFTER_RATE_ON_ACCRUED)
// Positive delta = more vacation liability accrued → debit 7090 expense.
// Negative delta = vacation taken / liability released → credit 7090.
const isIncrease = deltaInt > 0
const lines = [
{
account_number: '7090',
debit_amount: isIncrease ? Math.abs(deltaInt) : 0,
credit_amount: isIncrease ? 0 : Math.abs(deltaInt),
line_description: 'Förändring av semesterlöneskuld',
},
{
account_number: '2920',
debit_amount: isIncrease ? 0 : Math.abs(deltaInt),
credit_amount: isIncrease ? Math.abs(deltaInt) : 0,
line_description: 'Upplupna semesterlöner',
},
{
account_number: '7519',
debit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
credit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
line_description: 'Sociala avgifter på upplupen semester (31,42 %)',
},
{
account_number: '2940',
debit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
credit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
line_description: 'Upplupna sociala avgifter på semester',
},
]
const totalAmount = Math.abs(deltaInt) + Math.abs(avgifterDelta)
return {
kind: 'vacation_liability_change',
label: isIncrease
? `Ökning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`
: `Minskning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`,
description:
'Justering av 2920 mot 7090 plus 31,42 % sociala avgifter på 2940 mot 7519. Saldot på 2920 rullas vidare till nästa år (ingen vändning).',
amount: totalAmount,
lines,
// null (not '') = no reversal. The future accrual-reversal cron will
// filter `reverses_on IS NOT NULL` and an empty string would silently
// match that.
reverses_on: null,
warnings: [],
computation: {
current_2920: currentLiability,
closing_target: targetLiability,
delta: deltaInt,
avgifter_rate: AVGIFTER_RATE_ON_ACCRUED,
avgifter_delta: avgifterDelta,
employee_rows: report.rows.length,
},
}
}
export interface AuditFeeInput {
/** Estimated audit fee for the fiscal year being closed. */
amount: number
/** Closing date: used to derive the reversal date. */
closingDate: string
/** Account to credit on the liability side. Defaults to 2992 (revision),
* use 2991 for bokslut-fee accrual. */
liabilityAccount?: '2991' | '2992'
}
/**
* Propose accrual of audit / bookkeeping fee that will be invoiced after
* year-end. Standard BFL practice: accrue the cost in the period it relates
* to, reverse on Jan 1 when the actual invoice arrives.
*/
export function proposeAuditFee(input: AuditFeeInput): AccrualProposal | null {
const amount = Math.round(input.amount)
if (amount <= 0) return null
const liabilityAccount = input.liabilityAccount ?? '2992'
const isBokslut = liabilityAccount === '2991'
// BAS 2026: 6420 = "Revisionsarvode" (lagstadgad revision specifically).
// Bokslutskostnader for a non-revisionspliktigt bolag belong on 6590
// (övriga externa tjänster): Skatteverket may query a 6420 debit
// without a corresponding revisor i bolaget.
const expenseAccount = isBokslut ? '6590' : '6420'
return {
kind: 'audit_fee',
label: isBokslut ? 'Beräknat arvode för bokslut' : 'Beräknat arvode för revision',
description: `Debet ${expenseAccount}, kredit ${liabilityAccount}. Vänds vid faktura nästa år.`,
amount,
lines: [
{
account_number: expenseAccount,
debit_amount: amount,
credit_amount: 0,
line_description: isBokslut ? 'Beräknat bokslutarvode' : 'Beräknat revisionsarvode',
},
{
account_number: liabilityAccount,
debit_amount: 0,
credit_amount: amount,
line_description: isBokslut ? 'Beräknat arvode bokslut' : 'Beräknat arvode revision',
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface ManualPrepaidInput {
/** Amount of the cost that relates to NEXT year and should be reclassified
* to a 17xx prepaid account. */
amount: number
/** Cost account being relieved (e.g. 6310 företagsförsäkringar). */
expenseAccount: string
/** Target prepaid account (e.g. 1730 förutbetalda försäkringspremier).
* Must be in the 17xx interimsfordringar range. */
prepaidAccount: string
/** Period this prepaid covers: used in the line description. */
description: string
closingDate: string
}
/**
* Manual prepaid expense reclassification. Debit 17xx, credit the expense
* account by the portion that hasn't been consumed yet.
*
* The caller chooses which expense and prepaid accounts to use because
* heuristic detection from supplier invoices isn't reliable (no service-
* period field on the invoice model: see types/index.ts SupplierInvoice).
* A future heuristic detector can replace this when the data model grows
* service_period_start/_end fields.
*/
export function proposeManualPrepaid(input: ManualPrepaidInput): AccrualProposal | null {
if (!/^17\d{2}$/.test(input.prepaidAccount)) {
throw new Error(`prepaidAccount must be in 17xx range, got ${input.prepaidAccount}`)
}
const amount = Math.round(input.amount)
if (amount <= 0) return null
return {
kind: 'manual_prepaid_expense',
label: `Förutbetald kostnad: ${input.description}`,
description: `Debet ${input.prepaidAccount}, kredit ${input.expenseAccount}. Vänds vid årsskiftet.`,
amount,
lines: [
{
account_number: input.prepaidAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Förutbetald: ${input.description}`,
},
{
account_number: input.expenseAccount,
debit_amount: 0,
credit_amount: amount,
line_description: `Periodisering ut: ${input.description}`,
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface ManualAccruedInput {
amount: number
/** Cost account being charged (e.g. 5010 hyra lokal). */
expenseAccount: string
/** Target accrued-cost account (e.g. 2990 övriga upplupna kostnader).
* Must be in the 29xx interimsskulder range. */
accruedAccount: string
description: string
closingDate: string
}
/**
* Manual accrued cost. Debit the expense account, credit 29xx for the
* portion incurred but not yet invoiced. Mirrors `proposeManualPrepaid`
* but in the opposite direction.
*/
export function proposeManualAccrued(input: ManualAccruedInput): AccrualProposal | null {
if (!/^29\d{2}$/.test(input.accruedAccount)) {
throw new Error(`accruedAccount must be in 29xx range, got ${input.accruedAccount}`)
}
const amount = Math.round(input.amount)
if (amount <= 0) return null
return {
kind: 'manual_accrued_expense',
label: `Upplupen kostnad: ${input.description}`,
description: `Debet ${input.expenseAccount}, kredit ${input.accruedAccount}. Vänds vid årsskiftet.`,
amount,
lines: [
{
account_number: input.expenseAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Periodisering in: ${input.description}`,
},
{
account_number: input.accruedAccount,
debit_amount: 0,
credit_amount: amount,
line_description: `Upplupen: ${input.description}`,
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface RevenueDeferralInput {
amount: number
/** Revenue account to debit (e.g. 3000 / 3001). The full periodisering
* flow debits revenue and credits 2970: opposite direction to a prepaid
* expense. */
revenueAccount: string
/** Target deferred-revenue account. Must be in the 29xx range; the wizard
* defaults this to 2970 specifically (förutbetalda intäkter). */
deferredAccount: string
description: string
closingDate: string
}
/**
* Propose a deferred-revenue entry. Customer has paid (or has been invoiced)
* for a service that spans across year-end: the portion attributable to
* NEXT year is reclassified out of revenue and into 2970. Reverses on Jan 1.
*
* Thin wrapper around `proposeManualAccrued` reversed direction-wise: debit
* the revenue account, credit 2970. Built on the same engine to keep the
* idempotency / period-lock guarantees identical.
*/
export function proposeRevenueDeferral(input: RevenueDeferralInput): AccrualProposal | null {
if (!/^29\d{2}$/.test(input.deferredAccount)) {
throw new Error(`deferredAccount must be in 29xx range, got ${input.deferredAccount}`)
}
const amount = Math.round(input.amount)
if (amount <= 0) return null
return {
kind: 'deferred_revenue',
label: `Förutbetald intäkt: ${input.description}`,
description: `Debet ${input.revenueAccount}, kredit ${input.deferredAccount}. Vänds vid årsskiftet.`,
amount,
lines: [
{
account_number: input.revenueAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Periodisering intäkt ut: ${input.description}`,
},
{
account_number: input.deferredAccount,
debit_amount: 0,
credit_amount: amount,
line_description: `Förutbetald intäkt: ${input.description}`,
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface AccruedInterestInput {
amount: number
/** Interest-expense account, typically 8410 räntekostnader. */
expenseAccount: string
/** Accrued-interest liability, typically 2940 upplupna sociala avgifter
* is wrong: actual choice is the more general 2940 / 2960 family. The
* wizard defaults to 2960 / 2950; this helper validates 29xx. */
accruedAccount: string
description: string
closingDate: string
}
/**
* Propose accrued interest expense. Same shape as a generic accrued cost,
* but emits a clearer label so the user can tell apart from rent/utilities
* in the wizard's review step.
*/
export function proposeAccruedInterest(input: AccruedInterestInput): AccrualProposal | null {
const base = proposeManualAccrued({
amount: input.amount,
expenseAccount: input.expenseAccount,
accruedAccount: input.accruedAccount,
description: input.description,
closingDate: input.closingDate,
})
if (!base) return null
return {
...base,
kind: 'accrued_interest',
label: `Upplupen ränta: ${input.description}`,
}
}
export interface AccruedUtilityInput {
amount: number
/** Utility-expense account (e.g. 5020 el för kontorslokal). */
expenseAccount: string
/** Accrued liability, typically 2990 övriga upplupna kostnader. */
accruedAccount: string
description: string
closingDate: string
}
/**
* Propose accrued utility cost. Same shape as proposeAccruedInterest with a
* different label: helps the wizard group similar accruals visually.
*/
export function proposeAccruedUtility(input: AccruedUtilityInput): AccrualProposal | null {
const base = proposeManualAccrued({
amount: input.amount,
expenseAccount: input.expenseAccount,
accruedAccount: input.accruedAccount,
description: input.description,
closingDate: input.closingDate,
})
if (!base) return null
return {
...base,
kind: 'accrued_utility',
label: `Upplupen förbrukning: ${input.description}`,
}
}
/**
* Build a snapshot of automatically-detectable accrual proposals for the
* wizard's preflight. Today this is just the vacation-liability delta;
* future versions can add salary-accrued-but-unpaid, supplier-invoice-period
* detection, etc. Manual prepaid/accrued cards are added via the UI form
* (the API endpoint accepts them but they're not in the auto-proposal).
*/
export async function buildAccrualsProposal(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<AccrualsProposal> {
const { data: period, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (error || !period) throw new Error('Fiscal period not found')
const proposals: AccrualProposal[] = []
const vacation = await proposeVacationLiabilityChange(supabase, companyId, fiscalPeriodId, {
closingDate: period.period_end,
})
if (vacation) proposals.push(vacation)
return {
fiscalPeriod: period,
proposals,
}
}