Files
accounted/lib/invoices/late-payment-interest.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

156 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Statutory late-payment interest (dröjsmålsränta) per Räntelagen §6.
*
* The default rate is Riksbankens referensränta + 8 percentage points,
* applied as a simple annual interest on the overdue amount over the
* number of days the invoice has been overdue.
*
* Formula:
* interest = overdueAmount × annualRate × overdueDays / 365
*
* We use 365 days (not 360): this matches Swedish practice and is what
* Skatteverket / Kronofogden use in their late-payment calculators.
*
* Companies may override the statutory rate via
* `company_settings.reminder_interest_rate_override`. When an override is
* supplied we apply it directly (i.e. NOT referensränta + override) since
* that's the simpler mental model for users entering "11.5% per year".
*
* Referensränta is set by Riksbanken twice a year on January 1 and July 1.
* We hardcode the lookup table here to avoid a network call on every
* reminder; the table is small (one row per six months) and the values
* are public, durable records. Update this table when Riksbanken publishes
* a new rate.
*/
/**
* Riksbankens referensränta history. Each entry is the rate effective from
* the given date onward (until the next entry). Most recent first is more
* efficient to search but for clarity we keep them in ascending order and
* walk from newest to oldest at lookup time.
*
* Source: https://www.riksbank.se/sv/statistik/rantor-och-valutakurser/referensranta/
*
* The "annual default rate" applied to invoices is referensränta + 0.08
* (eight percentage points, Räntelagen §6).
*/
const REFERENSRANTA_HISTORY: ReadonlyArray<{ from: string; rate: number }> = [
{ from: '2022-01-01', rate: 0.0 },
{ from: '2022-07-01', rate: 0.005 },
{ from: '2023-01-01', rate: 0.025 },
{ from: '2023-07-01', rate: 0.035 },
{ from: '2024-01-01', rate: 0.04 },
{ from: '2024-07-01', rate: 0.0425 },
{ from: '2025-01-01', rate: 0.0375 },
{ from: '2025-07-01', rate: 0.0325 },
{ from: '2026-01-01', rate: 0.025 },
] as const
const LATE_PAYMENT_PREMIUM = 0.08 // 8 procentenheter per Räntelagen §6
/**
* Look up the Riksbanken referensränta that was effective on a given date.
* Returns the rate as a decimal fraction (e.g. 0.025 = 2.5%).
*
* If the requested date is before the earliest entry in the table we fall
* back to the earliest entry (this is a defensive measure, should never
* happen in practice since Accounted was launched after 2022).
*/
export function getReferensrantaAt(date: string): number {
// Walk newest-first so the first match wins.
for (let i = REFERENSRANTA_HISTORY.length - 1; i >= 0; i--) {
const entry = REFERENSRANTA_HISTORY[i]
if (date >= entry.from) {
return entry.rate
}
}
return REFERENSRANTA_HISTORY[0].rate
}
/**
* Compute the statutory annual late-payment interest rate for a given
* "from date" (typically the invoice due date). If an override is
* supplied, it is returned as-is.
*/
export function getAnnualInterestRate(fromDate: string, overrideRate?: number | null): number {
if (overrideRate !== undefined && overrideRate !== null) {
return overrideRate
}
return getReferensrantaAt(fromDate) + LATE_PAYMENT_PREMIUM
}
export interface LatePaymentInterestInput {
/** Outstanding overdue amount (the invoice total or remaining balance). */
overdueAmount: number
/** Invoice due date (YYYY-MM-DD). Interest starts the day AFTER due date. */
dueDate: string
/** Reference date for the calculation (YYYY-MM-DD). Defaults to today. */
asOfDate: string
/**
* Optional annual rate override (e.g. 0.115 for 11.5%). When supplied
* we use this verbatim instead of looking up Räntelagen §6.
*/
overrideRate?: number | null
}
export interface LatePaymentInterestResult {
/** Annual rate actually applied (decimal fraction, e.g. 0.115 = 11.5%). */
rate: number
/** Computed interest amount in SEK, rounded to 2 decimals. */
amount: number
/** Start date used for the interest calc (= dueDate). */
fromDate: string
/** Number of overdue days (positive integer, 0 if not overdue). */
days: number
}
/**
* Compute statutory late-payment interest (dröjsmålsränta).
*
* Returns the rate that was applied, the rounded amount, the from-date
* used (= dueDate), and the number of overdue days. If the invoice is
* not yet overdue the amount and days are both 0.
*
* Throws if `overdueAmount` is negative (callers should clamp to 0 if
* they want to silently no-op, but this is almost always a bug).
*/
export function calculateLatePaymentInterest(
input: LatePaymentInterestInput,
): LatePaymentInterestResult {
const { overdueAmount, dueDate, asOfDate, overrideRate } = input
if (overdueAmount < 0) {
throw new Error('overdueAmount must be non-negative')
}
const days = daysBetween(dueDate, asOfDate)
const rate = getAnnualInterestRate(dueDate, overrideRate)
if (days <= 0 || overdueAmount === 0) {
return { rate, amount: 0, fromDate: dueDate, days: 0 }
}
const raw = overdueAmount * rate * (days / 365)
const amount = Math.round(raw * 100) / 100
return { rate, amount, fromDate: dueDate, days }
}
/**
* Compute whole-day difference between two YYYY-MM-DD dates. Positive if
* `to` is after `from`. Returns 0 if `to <= from`.
*
* We use UTC midnight to avoid DST artifacts (Sweden observes DST). The
* inputs are date-only strings so timezone doesn't affect the result as
* long as we anchor both at UTC.
*/
function daysBetween(from: string, to: string): number {
const fromMs = Date.parse(`${from}T00:00:00Z`)
const toMs = Date.parse(`${to}T00:00:00Z`)
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
return 0
}
const diff = Math.floor((toMs - fromMs) / 86_400_000)
return diff > 0 ? diff : 0
}