Files
accounted/lib/bokslut/assets/depreciation-engine.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

491 lines
19 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.
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { listAssets } from './asset-service'
import type {
Asset,
FiscalPeriod,
JournalEntry,
CreateJournalEntryLineInput,
} from '@/types'
/** Rate constants for the non-linear Swedish depreciation methods. */
const DECLINING_RATE_30 = 0.3
const DECLINING_RATE_20 = 0.2
const RESTVARDE_RATE_25 = 0.25
export interface AssetDepreciation {
asset: Asset
/** Planenlig avskrivning för denna period, avrundad till hela kronor. */
amount: number
/** Net book value vid periodens slut (ackumulerad avskrivning inklusive
* denna period subtraherat från anskaffningsvärdet). Används av wizard
* UI:t för att visa restvärde efter avskrivning. */
netBookValueAfter: number
/** True om avskrivningen pro-rateras (tillgång anskaffad eller fullt
* avskriven mitt i perioden). */
proRated: boolean
/** Befintligt depreciation_schedules-id om en proposal redan finns för
* denna kombination. Wizard:t använder detta för att veta att proposalen
* redan har bokförts (om journal_entry_id är satt) eller är väntande
* (om journal_entry_id är null). */
existingScheduleId?: string
existingJournalEntryId?: string | null
}
export interface DepreciationProposal {
fiscalPeriod: { id: string; name: string; period_start: string; period_end: string }
items: AssetDepreciation[]
totalAmount: number
}
/**
* Compute avskrivning för en enskild tillgång under en given fiscal period.
*
* Method dispatch:
* * 'linear' (planenlig raklinje) — pro-rates by day-overlap of the
* period with the asset's active life and the disposal cutoff. Annual
* amount = (acquisition_cost − salvage_value) × 12 / useful_life_months.
* * 'declining_balance_30' (räkenskapsenlig huvudregel, IL 18 kap 13§) —
* 30% of the current book value. No annual pro-ration: K2 10.23 says
* "full annual amount regardless of partial year" when the asset is
* put into use, mirrored in Swedish tax practice for IL 18 kap. Disposal
* in-period still zeros out: we return 0 if disposed before the period
* and the full 30% if disposed during, because the disposal entry itself
* takes care of the asset's remaining book value.
* * 'declining_balance_20' (kompletteringsregel, IL 18 kap 17§) — 20% of
* book value. Same proration semantics as 30%.
* * 'restvardesavskrivning_25' (IL 18 kap 13§ st.3) — 25% of
* max(0, currentBookValue − restvarde_target). Floors at the target so
* the asset is never charged below restvärde. Same proration semantics
* as the other declining methods.
*
* For non-linear methods, currentBookValue =
* acquisition_cost − accumulated_depreciation_through_period_start.
*
* Callers that don't know prior accumulated depreciation pass 0 (e.g. tests
* for year-1 declining-balance). The orchestrator (`proposeAnnualPostings`)
* fetches it from posted depreciation_schedules.
*/
export function computeAnnualDepreciation(
asset: Asset,
fiscalPeriod: Pick<FiscalPeriod, 'period_start' | 'period_end'>,
priorAccumulated: number = 0,
): { amount: number; proRated: boolean } {
if (asset.disposed_at && asset.disposed_at < fiscalPeriod.period_start) {
return { amount: 0, proRated: false }
}
// K3 component approach (BFNAR 2012:1 ch.17.4) overrides the method-based
// dispatch entirely. When non-null and non-empty, each component is
// depreciated linearly on its own life (with the same pro-ration logic
// as the asset-level linear method) and the per-component amounts are
// summed. The asset's `depreciation_method` and `salvage_value` are
// ignored — components carry their own salvage_value and life.
if (Array.isArray(asset.k3_components) && asset.k3_components.length > 0) {
const result = computeComponentDepreciation(asset, fiscalPeriod)
return { amount: result.amount, proRated: result.proRated }
}
const acquisitionCost = Number(asset.acquisition_cost)
const method = asset.depreciation_method
if (method === 'linear') {
return computeLinearAnnual(asset, fiscalPeriod)
}
// Declining-balance methods (huvudregel 30%, kompletteringsregel 20%,
// restvärde 25%) do NOT pro-rate annually — full-year amount applies
// regardless of acquisition month. Disposal during the period is handled
// by disposeAsset(); we still charge the full annual amount because the
// disposal entry zeroes out the residual.
const currentBookValue = acquisitionCost - priorAccumulated
// Already fully depreciated (linear-style accumulated overshoot) or
// negative — defensive guard.
if (currentBookValue <= 0.005) {
return { amount: 0, proRated: false }
}
let annualAmount = 0
if (method === 'declining_balance_30') {
annualAmount = currentBookValue * DECLINING_RATE_30
} else if (method === 'declining_balance_20') {
annualAmount = currentBookValue * DECLINING_RATE_20
} else if (method === 'restvardesavskrivning_25') {
const target = Number(asset.restvarde_target ?? 0)
const depreciable = currentBookValue - target
if (depreciable <= 0.005) {
// Already at or below restvärde — never deplete past the floor.
return { amount: 0, proRated: false }
}
annualAmount = depreciable * RESTVARDE_RATE_25
}
// Monetary rounding per CLAUDE.md guard-rail #9. Schedules store NUMERIC
// values, but the journal entry rounds to whole kronor downstream — match
// the linear branch which rounds to integer kronor for the entry amount.
return {
amount: Math.round(annualAmount),
proRated: false,
}
}
function computeLinearAnnual(
asset: Asset,
fiscalPeriod: Pick<FiscalPeriod, 'period_start' | 'period_end'>,
): { amount: number; proRated: boolean } {
const acquisitionCost = Number(asset.acquisition_cost)
const salvageValue = Number(asset.salvage_value)
const depreciableBase = acquisitionCost - salvageValue
if (depreciableBase <= 0) return { amount: 0, proRated: false }
const usefulLifeMonths = asset.useful_life_months
const annualRate = 12 / usefulLifeMonths
// Determine the depreciation window for this period: the overlap between
// the asset's active life and the fiscal period.
const acquisition = isoToDate(asset.acquisition_date)
const lifeEndExclusive = addMonths(acquisition, usefulLifeMonths)
const periodStart = isoToDate(fiscalPeriod.period_start)
const periodEndInclusive = isoToDate(fiscalPeriod.period_end)
const disposalEnd = asset.disposed_at ? isoToDate(asset.disposed_at) : null
const windowStart = maxDate(acquisition, periodStart)
let windowEnd = minDate(periodEndInclusive, addDays(lifeEndExclusive, -1))
if (disposalEnd) windowEnd = minDate(windowEnd, disposalEnd)
if (windowEnd < windowStart) return { amount: 0, proRated: false }
const fullPeriodDays = daysBetween(periodStart, periodEndInclusive) + 1
const windowDays = daysBetween(windowStart, windowEnd) + 1
const fraction = windowDays / fullPeriodDays
const proRated = fraction < 0.999
// Full-year amount = annualRate × depreciableBase (linear).
const annualAmount = depreciableBase * annualRate
const proRatedAmount = annualAmount * fraction
return {
amount: Math.round(proRatedAmount),
proRated,
}
}
export interface ComponentDepreciationResult {
/** Sum of per-component depreciation, rounded to whole kronor. */
amount: number
/** True if any component was pro-rated (mid-year acquisition or disposal). */
proRated: boolean
/** Per-component breakdown — names mirror `asset.k3_components[*].name`.
* Each amount is rounded to whole kronor; the total `amount` is the sum
* of these rounded values (so the breakdown reconciles exactly with the
* total — no hidden öre). */
perComponent: { name: string; amount: number }[]
}
/**
* Compute component depreciation for a K3 asset (BFNAR 2012:1 ch.17.4).
*
* Mirrors `computeLinearAnnual` per component: the depreciable base is
* `cost − salvage_value` (salvage defaults to 0 when omitted), and the
* annual amount is `depreciableBase × 12 / useful_life_months`. The
* pro-ration window is the overlap between the period and the asset's
* active life — components share the same acquisition_date and disposal
* date as the parent asset, because BFNAR 2012:1 treats them as a single
* accounting unit for acquisition / disposal purposes; only the depreciation
* schedule is split.
*
* Per-component amounts are rounded to whole kronor individually, then
* summed, so the breakdown returned by this function reconciles exactly
* with `amount`. This matches the linear / declining methods which also
* round at the per-asset level.
*/
export function computeComponentDepreciation(
asset: Asset,
fiscalPeriod: Pick<FiscalPeriod, 'period_start' | 'period_end'>,
): ComponentDepreciationResult {
const components = asset.k3_components ?? []
if (components.length === 0) {
return { amount: 0, proRated: false, perComponent: [] }
}
// Pre-compute the period vs life window so each component shares the
// same date math (acquisition_date and disposal date are asset-level).
const acquisition = isoToDate(asset.acquisition_date)
const periodStart = isoToDate(fiscalPeriod.period_start)
const periodEndInclusive = isoToDate(fiscalPeriod.period_end)
const disposalEnd = asset.disposed_at ? isoToDate(asset.disposed_at) : null
const fullPeriodDays = daysBetween(periodStart, periodEndInclusive) + 1
const perComponent: { name: string; amount: number }[] = []
let total = 0
let anyProRated = false
for (const [index, component] of components.entries()) {
const label = component.name?.trim() || `Komponent ${index + 1}`
const cost = Number(component.cost)
const salvage = Number(component.salvage_value ?? 0)
const depreciableBase = cost - salvage
if (depreciableBase <= 0 || component.useful_life_months <= 0) {
perComponent.push({ name: label, amount: 0 })
continue
}
const annualRate = 12 / component.useful_life_months
const lifeEndExclusive = addMonths(acquisition, component.useful_life_months)
const windowStart = maxDate(acquisition, periodStart)
let windowEnd = minDate(periodEndInclusive, addDays(lifeEndExclusive, -1))
if (disposalEnd) windowEnd = minDate(windowEnd, disposalEnd)
if (windowEnd < windowStart) {
perComponent.push({ name: label, amount: 0 })
continue
}
const windowDays = daysBetween(windowStart, windowEnd) + 1
const fraction = windowDays / fullPeriodDays
if (fraction < 0.999) anyProRated = true
const annualAmount = depreciableBase * annualRate
const proRatedAmount = annualAmount * fraction
const rounded = Math.round(proRatedAmount)
perComponent.push({ name: label, amount: rounded })
total += rounded
}
return {
amount: total,
proRated: anyProRated,
perComponent,
}
}
/**
* Build a proposal listing planenlig avskrivning för every active asset.
* Reads existing depreciation_schedules so already-posted entries aren't
* proposed twice (the unique constraint on (asset_id, fiscal_period_id)
* would reject duplicates anyway, but the UI wants to display "redan
* bokförd" rather than fail).
*/
export async function proposeAnnualPostings(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<DepreciationProposal> {
const [periodResult, assets, currentSchedulesResult, priorSchedulesResult] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
listAssets(supabase, companyId),
// Schedules in the current period (proposal lookup / "already posted" badge)
supabase
.from('depreciation_schedules')
.select('id, asset_id, journal_entry_id')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId),
// Prior posted schedules (excluding the current period) so we can compute
// the true accumulated depreciation per asset for net book value.
supabase
.from('depreciation_schedules')
.select('asset_id, planned_depreciation, journal_entry_id, fiscal_period_id')
.eq('company_id', companyId)
.neq('fiscal_period_id', fiscalPeriodId)
.not('journal_entry_id', 'is', null),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const existing = new Map<string, { id: string; journal_entry_id: string | null }>(
(currentSchedulesResult.data ?? []).map(
(r: { id: string; asset_id: string; journal_entry_id: string | null }) => [
r.asset_id,
{ id: r.id, journal_entry_id: r.journal_entry_id },
],
),
)
// Sum of all prior posted depreciation per asset. This is the accumulated
// depreciation on the books before this period — does not yet count this
// period's proposal or any unposted current-period draft.
const priorAccumulated = new Map<string, number>()
for (const row of (priorSchedulesResult.data ?? []) as Array<{
asset_id: string
planned_depreciation: number | string
}>) {
const v = Number(row.planned_depreciation) || 0
priorAccumulated.set(row.asset_id, (priorAccumulated.get(row.asset_id) ?? 0) + v)
}
const items: AssetDepreciation[] = []
for (const asset of assets) {
// Skip assets disposed before period start
if (asset.disposed_at && asset.disposed_at < period.period_start) continue
const accumulatedBefore = priorAccumulated.get(asset.id) ?? 0
const { amount, proRated } = computeAnnualDepreciation(
asset,
period,
accumulatedBefore,
)
if (amount <= 0) continue
const existingSchedule = existing.get(asset.id)
const netBookValueAfter =
Math.round((Number(asset.acquisition_cost) - accumulatedBefore - amount) * 100) / 100
items.push({
asset,
amount,
netBookValueAfter,
proRated,
existingScheduleId: existingSchedule?.id,
existingJournalEntryId: existingSchedule?.journal_entry_id ?? null,
})
}
return {
fiscalPeriod: period,
items,
totalAmount: items.reduce((sum, item) => sum + item.amount, 0),
}
}
/**
* Commit the proposal as journal entries. Creates ONE journal entry per
* asset (rather than a single batch entry) so each can be reversed
* independently and so the depreciation_schedules row links one-to-one to
* its journal entry.
*
* Skips assets that already have a posted schedule för this period — the
* unique constraint would block them, and silently skipping is more useful
* than throwing. Returns the list of (asset_id, schedule, entry) tuples.
*/
export async function commitAnnualPostings(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
options: { assetIds?: string[] } = {},
): Promise<{
posted: { assetId: string; entry: JournalEntry; scheduleId: string }[]
skipped: { assetId: string; reason: string }[]
}> {
const proposal = await proposeAnnualPostings(supabase, companyId, fiscalPeriodId)
const periodEnd = proposal.fiscalPeriod.period_end
const periodName = proposal.fiscalPeriod.name
const allowed = options.assetIds ? new Set(options.assetIds) : null
const posted: { assetId: string; entry: JournalEntry; scheduleId: string }[] = []
const skipped: { assetId: string; reason: string }[] = []
for (const item of proposal.items) {
if (allowed && !allowed.has(item.asset.id)) continue
if (item.existingJournalEntryId) {
skipped.push({ assetId: item.asset.id, reason: 'already_posted' })
continue
}
const lines: CreateJournalEntryLineInput[] = [
{
account_number: item.asset.bas_expense_account,
debit_amount: item.amount,
credit_amount: 0,
line_description: `Avskrivning ${item.asset.name}`,
},
{
account_number: item.asset.bas_accumulated_account,
debit_amount: 0,
credit_amount: item.amount,
line_description: `Ack. avskrivning ${item.asset.name}`,
},
]
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: periodEnd,
description: `Planenlig avskrivning ${periodName}: ${item.asset.name}`,
source_type: 'year_end',
lines,
})
// Upsert the schedule row. If a draft (no journal_entry_id) already
// exists for (asset, period) we overwrite it with the posted entry.
if (item.existingScheduleId) {
const { error } = await supabase
.from('depreciation_schedules')
.update({ journal_entry_id: entry.id, posted_at: new Date().toISOString() })
.eq('id', item.existingScheduleId)
.eq('company_id', companyId)
if (error) throw new Error(`Failed to update schedule: ${error.message}`)
posted.push({ assetId: item.asset.id, entry, scheduleId: item.existingScheduleId })
} else {
const { data, error } = await supabase
.from('depreciation_schedules')
.insert({
user_id: userId,
company_id: companyId,
asset_id: item.asset.id,
fiscal_period_id: fiscalPeriodId,
planned_depreciation: item.amount,
journal_entry_id: entry.id,
posted_at: new Date().toISOString(),
})
.select('id')
.single()
if (error || !data) throw new Error(`Failed to insert schedule: ${error?.message}`)
posted.push({ assetId: item.asset.id, entry, scheduleId: data.id })
}
}
return { posted, skipped }
}
// ============================================================
// Date helpers — keep pure so the unit tests don't need to mock anything.
// ============================================================
function isoToDate(iso: string): Date {
// Force UTC midnight to avoid local-time DST drift confusing days math.
return new Date(iso + 'T00:00:00Z')
}
function addMonths(date: Date, months: number): Date {
// Clamp the day to the last valid day of the target month so end-of-month
// dates don't overflow forward (Jan 31 + 1 month → Feb 28, not Mar 3).
const targetMonth = date.getUTCMonth() + months
const lastDayOfTargetMonth = new Date(
Date.UTC(date.getUTCFullYear(), targetMonth + 1, 0),
).getUTCDate()
return new Date(
Date.UTC(
date.getUTCFullYear(),
targetMonth,
Math.min(date.getUTCDate(), lastDayOfTargetMonth),
),
)
}
function addDays(date: Date, days: number): Date {
const result = new Date(date)
result.setUTCDate(result.getUTCDate() + days)
return result
}
function maxDate(a: Date, b: Date): Date {
return a > b ? a : b
}
function minDate(a: Date, b: Date): Date {
return a < b ? a : b
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24))
}