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
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { ProposedDisposition } from '../types'
|
|
|
|
/** Maximum periodiseringsfond avsättning for aktiebolag: 25 % of skattemässigt
|
|
* resultat före avsättning. Enskild firma uses 30 % but is handled in NE/INK1
|
|
* rather than booked. */
|
|
export const PFOND_AB_RATE = 0.25
|
|
export const PFOND_AB_RATE_PCT = '25 %'
|
|
|
|
/** Mandatory holding period: a fond avsatt år N must be återförd no later
|
|
* than räkenskapsår N+6 (IL 30 kap 7 §). */
|
|
export const PFOND_MAX_HOLD_YEARS = 6
|
|
|
|
/**
|
|
* BAS account convention: account = '212' + (fiscalYear % 10). 2020 → '2120',
|
|
* 2025 → '2125'. The collision year 2019/2029 maps to '2129' per the BAS
|
|
* 2020 seed; if a company has fonder in both years on the same account, the
|
|
* service surfaces a warning so the user can split the balance manually.
|
|
*/
|
|
export function getPeriodiseringsfondCohortAccount(fiscalYear: number): string {
|
|
if (fiscalYear === 2019) return '2129'
|
|
return '212' + (fiscalYear % 10).toString()
|
|
}
|
|
|
|
export interface ExistingFond {
|
|
/** BAS account number for the cohort (e.g. '2120'). */
|
|
account_number: string
|
|
/** Cohort year derived from account naming convention (e.g. 2020 for 2120). */
|
|
cohort_year: number
|
|
/** Current credit balance (positive = liability balance). */
|
|
balance: number
|
|
/** True if the fond must be returned this year (cohort_year + 6 ≤ closing_year). */
|
|
must_return_this_year: boolean
|
|
}
|
|
|
|
export interface PfondAvsattningInput {
|
|
/** Skattemässigt resultat före avsättning. 25 % cap is applied to this. */
|
|
skattemassigtResultatBeforeAvsattning: number
|
|
/** Amount the user wants to set aside. Defaults to the maximum (25 %). */
|
|
desiredAmount?: number
|
|
/** Closing year of the fiscal period (e.g. 2025 for FY ending 2025-12-31).
|
|
* Determines which cohort account to use. */
|
|
fiscalYear: number
|
|
}
|
|
|
|
export interface PfondAvsattningComputation {
|
|
rate: number
|
|
maxAmount: number
|
|
desiredAmount: number
|
|
actualAmount: number
|
|
cohortAccount: string
|
|
cohortYear: number
|
|
cappedToMax: boolean
|
|
}
|
|
|
|
/**
|
|
* Propose a periodiseringsfond avsättning. Caps the user's desired amount
|
|
* to 25 % of skattemässigt resultat före avsättning (rounded down to whole
|
|
* krona). Returns null when no positive avsättning would result (loss year
|
|
* or zero desired).
|
|
*/
|
|
export function proposeAvsattning(input: PfondAvsattningInput): ProposedDisposition | null {
|
|
const base = Math.max(0, Math.floor(input.skattemassigtResultatBeforeAvsattning))
|
|
const maxAmount = Math.floor(base * PFOND_AB_RATE)
|
|
const desiredAmount = Math.max(0, Math.floor(input.desiredAmount ?? maxAmount))
|
|
const actualAmount = Math.min(desiredAmount, maxAmount)
|
|
const cohortAccount = getPeriodiseringsfondCohortAccount(input.fiscalYear)
|
|
const cappedToMax = desiredAmount > maxAmount
|
|
|
|
if (actualAmount === 0) {
|
|
return null
|
|
}
|
|
|
|
const computation: PfondAvsattningComputation = {
|
|
rate: PFOND_AB_RATE,
|
|
maxAmount,
|
|
desiredAmount,
|
|
actualAmount,
|
|
cohortAccount,
|
|
cohortYear: input.fiscalYear,
|
|
cappedToMax,
|
|
}
|
|
|
|
const warnings: string[] = []
|
|
if (cappedToMax) {
|
|
warnings.push(
|
|
`Begärt belopp (${desiredAmount} kr) översteg ${PFOND_AB_RATE_PCT}-taket. Avsättningen begränsades till ${maxAmount} kr.`,
|
|
)
|
|
}
|
|
|
|
return {
|
|
kind: 'periodiseringsfond_avsattning',
|
|
label: `Avsättning till periodiseringsfond ${input.fiscalYear}`,
|
|
description: `Debet 8811, kredit ${cohortAccount}. Max ${PFOND_AB_RATE_PCT} av skattemässigt resultat.`,
|
|
amount: actualAmount,
|
|
lines: [
|
|
{
|
|
account_number: '8811',
|
|
debit_amount: actualAmount,
|
|
credit_amount: 0,
|
|
line_description: `Avsättning periodiseringsfond ${input.fiscalYear}`,
|
|
},
|
|
{
|
|
account_number: cohortAccount,
|
|
debit_amount: 0,
|
|
credit_amount: actualAmount,
|
|
line_description: `Periodiseringsfond ${input.fiscalYear}`,
|
|
},
|
|
],
|
|
warnings,
|
|
computation: computation as unknown as Record<string, unknown>,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List existing periodiseringsfonder by querying the account balance of every
|
|
* 2110-2199 account as of the closing date of the fiscal period. Marks any
|
|
* fond whose cohort_year + 6 ≤ closing_year as `must_return_this_year`.
|
|
*
|
|
* Uses the trial-balance pattern: sum debit/credit on each 21xx account from
|
|
* inception through the closing date. Result is positive when the credit
|
|
* balance exceeds debits (the normal state of a liability account).
|
|
*/
|
|
export async function listExistingPeriodiseringsfonder(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
closingDate: string,
|
|
): Promise<ExistingFond[]> {
|
|
const closingYear = parseInt(closingDate.slice(0, 4), 10)
|
|
if (Number.isNaN(closingYear)) {
|
|
throw new Error(`Invalid closing date: ${closingDate}`)
|
|
}
|
|
|
|
// Sum debit/credit per 21xx account up to and including the closing date.
|
|
// Use the journal_entry_lines table directly: RLS scopes to the company.
|
|
const { data, error } = await supabase
|
|
.from('journal_entry_lines')
|
|
.select(
|
|
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)',
|
|
)
|
|
.eq('journal_entries.company_id', companyId)
|
|
.eq('journal_entries.status', 'posted')
|
|
.lte('journal_entries.entry_date', closingDate)
|
|
.gte('account_number', '2110')
|
|
.lte('account_number', '2199')
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to fetch periodiseringsfond balances: ${error.message}`)
|
|
}
|
|
|
|
type Row = { account_number: string; debit_amount: number | string | null; credit_amount: number | string | null }
|
|
const byAccount = new Map<string, number>()
|
|
for (const row of (data ?? []) as Row[]) {
|
|
const balance =
|
|
(Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
|
|
byAccount.set(row.account_number, (byAccount.get(row.account_number) ?? 0) + balance)
|
|
}
|
|
|
|
const fonder: ExistingFond[] = []
|
|
for (const [accountNumber, balance] of byAccount) {
|
|
if (Math.abs(balance) < 0.005) continue
|
|
const cohortYear = cohortYearFromAccount(accountNumber)
|
|
if (cohortYear === null) continue
|
|
fonder.push({
|
|
account_number: accountNumber,
|
|
cohort_year: cohortYear,
|
|
balance: Math.round(balance * 100) / 100,
|
|
must_return_this_year: cohortYear + PFOND_MAX_HOLD_YEARS <= closingYear,
|
|
})
|
|
}
|
|
|
|
fonder.sort((a, b) => a.cohort_year - b.cohort_year)
|
|
return fonder
|
|
}
|
|
|
|
/**
|
|
* Derive the cohort year from a BAS account number. Returns null for accounts
|
|
* that don't follow the '212X' convention (e.g. 2110 = grouping account, no
|
|
* specific cohort).
|
|
*/
|
|
function cohortYearFromAccount(accountNumber: string): number | null {
|
|
if (!/^212\d$/.test(accountNumber)) return null
|
|
const lastDigit = parseInt(accountNumber.slice(-1), 10)
|
|
// BAS 2020: 2129 represents 2019 by convention; 2128 = 2028.
|
|
if (lastDigit === 9) return 2019
|
|
// For 0-8, the cohort year is in the 2020s. As fiscal years extend past
|
|
// 2029 the convention will recycle (2120 might mean 2030 then); cap at
|
|
// 2020-decade interpretation for now and surface ambiguity in a warning.
|
|
return 2020 + lastDigit
|
|
}
|
|
|
|
export interface PfondAteforingProposal {
|
|
/** One proposal per individual fond being returned. The wizard renders these
|
|
* as separate cards; mandatory ones (must_return_this_year) cannot be skipped. */
|
|
proposals: ProposedDisposition[]
|
|
/** Total schablonintäkt computed on the OPENING balance of all 21xx accounts.
|
|
* This is NOT booked: it goes into INK2 as a manual adjustment to taxable
|
|
* result. Caller (bolagsskatt-calculator) reads this to add to taxable result. */
|
|
schablonintaktAmount: number
|
|
}
|
|
|
|
/**
|
|
* Propose periodiseringsfond reversals. Forces reversal of any fond reaching
|
|
* its 6-year limit; offers optional reversal of newer fonder. Also computes
|
|
* the schablonintäkt on the opening balance of all 21xx accounts (per IL 30
|
|
* kap 6a §): caller adds this to taxable result when computing bolagsskatt.
|
|
*
|
|
* @param schablonintaktRate Statslåneräntan 30 nov året före, plus 1 pe, min
|
|
* 0.5 %. For income year 2025: ~3.0 %. Caller passes this in because the
|
|
* rate changes annually and is sourced from Riksbanken.
|
|
*/
|
|
export function proposeAteforing(
|
|
existingFonder: ExistingFond[],
|
|
options: {
|
|
/** Map from account_number to desired return amount. Omit entries the
|
|
* user does not want to return (mandatory ones are returned regardless). */
|
|
returns?: Record<string, number>
|
|
/** Schablonintäkt rate as a decimal (0.03 for 3 %). Applied to opening
|
|
* balance of every 21xx account. */
|
|
schablonintaktRate: number
|
|
},
|
|
): PfondAteforingProposal {
|
|
const proposals: ProposedDisposition[] = []
|
|
let schablonintaktAmount = 0
|
|
|
|
for (const fond of existingFonder) {
|
|
schablonintaktAmount += fond.balance * options.schablonintaktRate
|
|
|
|
const desiredReturn = options.returns?.[fond.account_number] ?? 0
|
|
const isMandatory = fond.must_return_this_year
|
|
const returnAmount = isMandatory
|
|
? fond.balance // forced full reversal
|
|
: Math.min(Math.max(0, Math.floor(desiredReturn)), fond.balance)
|
|
|
|
if (returnAmount === 0) continue
|
|
|
|
const warnings: string[] = []
|
|
if (isMandatory) {
|
|
warnings.push(
|
|
`Periodiseringsfond ${fond.cohort_year} har nått 6-årsgränsen och måste återföras.`,
|
|
)
|
|
}
|
|
|
|
proposals.push({
|
|
kind: 'periodiseringsfond_ateforing',
|
|
label: `Återföring periodiseringsfond ${fond.cohort_year}`,
|
|
description: `Debet ${fond.account_number}, kredit 8819.`,
|
|
amount: returnAmount,
|
|
lines: [
|
|
{
|
|
account_number: fond.account_number,
|
|
debit_amount: returnAmount,
|
|
credit_amount: 0,
|
|
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
|
|
},
|
|
{
|
|
account_number: '8819',
|
|
debit_amount: 0,
|
|
credit_amount: returnAmount,
|
|
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
|
|
},
|
|
],
|
|
warnings,
|
|
computation: {
|
|
cohort_year: fond.cohort_year,
|
|
opening_balance: fond.balance,
|
|
return_amount: returnAmount,
|
|
was_mandatory: isMandatory,
|
|
},
|
|
required: isMandatory,
|
|
})
|
|
}
|
|
|
|
return {
|
|
proposals,
|
|
schablonintaktAmount: Math.round(schablonintaktAmount),
|
|
}
|
|
}
|