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>
156 lines
5.7 KiB
TypeScript
156 lines
5.7 KiB
TypeScript
/**
|
||
* 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
|
||
}
|