Files
accounted/lib/bokslut/accruals/accrual-detector.ts
T
MattssonandClaude Opus 4.7 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +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,
}
}