* 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>
191 lines
7.3 KiB
TypeScript
191 lines
7.3 KiB
TypeScript
/**
|
||
* Jämkning helpers — input-VAT correction on disposal of investeringsvara
|
||
* within the korrigeringstid (ML 8a kap 4-7 §§).
|
||
*
|
||
* When an asset that had input VAT deducted at acquisition is disposed of
|
||
* within the correction period, part of the original deducted input VAT
|
||
* must be paid back. The amount is the portion that corresponds to the
|
||
* remaining months of the correction period.
|
||
*
|
||
* Correction periods per ML 8a kap 6 §:
|
||
* - 60 months (5 years) for lös egendom / movable property
|
||
* - 120 months (10 years) for fastighet / markanläggning (real property)
|
||
*
|
||
* The two functions in this file are PURE (no I/O, no Supabase, no clock
|
||
* read) so they can be tested with simple input/output cases.
|
||
*
|
||
* Caller responsibility:
|
||
* - Decide whether a disposal event triggers jämkning. The most common
|
||
* trigger is a sale within korrigeringstid, but ML 8a kap also lists
|
||
* "ändrad användning" and "utträde ur skattskyldighet". The caller
|
||
* passes the boolean so this helper stays domain-agnostic.
|
||
* - Source `originalInputVat`. For new assets this comes from the
|
||
* supplier invoice that booked the acquisition; for legacy assets the
|
||
* user has to enter it manually.
|
||
*/
|
||
|
||
import type { AssetCategory } from '@/types'
|
||
|
||
/**
|
||
* Inputs to compute the jämkning amount on disposal.
|
||
*/
|
||
export interface JamkningInput {
|
||
/** Original input VAT deducted at acquisition (BAS 2641 debit). */
|
||
originalInputVat: number
|
||
/**
|
||
* Total correction period in months. 60 for movable property,
|
||
* 120 for fastighet / markanläggning. Caller decides which.
|
||
*/
|
||
totalCorrectionMonths: number
|
||
/**
|
||
* Months remaining in the correction period as of the disposal date.
|
||
* Caller computes this so the helper avoids any clock / calendar
|
||
* dependency.
|
||
*/
|
||
remainingMonths: number
|
||
/**
|
||
* Whether the disposal event triggers jämkning at all. Most disposals
|
||
* within the korrigeringstid trigger it, but the caller may opt out
|
||
* (e.g. the buyer continues to use the asset in a fully taxable
|
||
* verksamhet and assumes the jämkning obligation via avtal — ML 8a kap
|
||
* 12 §).
|
||
*/
|
||
disposalEvent: 'triggers_jamkning' | 'no_jamkning'
|
||
}
|
||
|
||
/**
|
||
* Compute the jämkning amount per ML 8a kap 7 §. Returns a positive number
|
||
* representing the amount to be paid back to the state (i.e. reverse the
|
||
* input-VAT deduction). When disposal happens AFTER the correction period
|
||
* (remainingMonths <= 0) the formula returns 0 — caller can simply skip
|
||
* the line.
|
||
*
|
||
* Formula: (remaining / total) × originalInputVat
|
||
*
|
||
* Edge cases:
|
||
* - disposalEvent = 'no_jamkning' → 0
|
||
* - totalCorrectionMonths <= 0 → 0 (defensive — caller bug)
|
||
* - remainingMonths <= 0 → 0 (asset is past the correction period)
|
||
* - remainingMonths > totalCorrectionMonths → caps at originalInputVat
|
||
* (sold immediately, before any correction period has elapsed)
|
||
*/
|
||
export function computeJamkningAmount(input: JamkningInput): number {
|
||
if (input.disposalEvent === 'no_jamkning') return 0
|
||
if (input.totalCorrectionMonths <= 0) return 0
|
||
if (input.remainingMonths <= 0) return 0
|
||
|
||
const remaining = Math.min(input.remainingMonths, input.totalCorrectionMonths)
|
||
const raw = (remaining / input.totalCorrectionMonths) * input.originalInputVat
|
||
return Math.round(raw * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* Suggested eligibility check for an asset disposal. Returns the
|
||
* totalCorrectionMonths the caller should pass to computeJamkningAmount,
|
||
* along with the remainingMonths derived from acquisitionDate and
|
||
* disposalDate.
|
||
*
|
||
* The threshold lives here (not in the asset row) because it's a property
|
||
* of the asset CATEGORY / BAS account class, not user-editable per-asset:
|
||
*
|
||
* - Fastighet (BAS 1100-1199) → 120 months
|
||
* - Markanläggning (BAS 1150-1159) — also 120 months
|
||
* - All other movable property → 60 months
|
||
*
|
||
* Pure: takes only dates and the asset's BAS account, returns numbers.
|
||
* Caller decides whether to surface the suggestion in the UI.
|
||
*/
|
||
export interface JamkningEligibility {
|
||
/** Suggested total correction period (60 or 120 months). */
|
||
totalCorrectionMonths: number
|
||
/** Months elapsed between acquisitionDate and disposalDate (clamped at 0). */
|
||
elapsedMonths: number
|
||
/** Remaining months in the correction period (clamped at 0). */
|
||
remainingMonths: number
|
||
/**
|
||
* Whether the disposal falls WITHIN the correction period. Convenience
|
||
* boolean — equivalent to `remainingMonths > 0`. Caller uses this to
|
||
* show / hide the jämkning UI.
|
||
*/
|
||
withinCorrectionPeriod: boolean
|
||
}
|
||
|
||
export function assessJamkningEligibility(args: {
|
||
basExpenseAccount?: string
|
||
basAssetAccount?: string
|
||
category?: AssetCategory
|
||
acquisitionDate: string
|
||
disposalDate: string
|
||
}): JamkningEligibility {
|
||
const totalCorrectionMonths = isRealProperty(args) ? 120 : 60
|
||
const elapsed = monthsBetween(args.acquisitionDate, args.disposalDate)
|
||
const elapsedClamped = Math.max(0, elapsed)
|
||
const remaining = Math.max(0, totalCorrectionMonths - elapsedClamped)
|
||
return {
|
||
totalCorrectionMonths,
|
||
elapsedMonths: elapsedClamped,
|
||
remainingMonths: remaining,
|
||
withinCorrectionPeriod: remaining > 0,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Real property (fastighet / markanläggning) per BAS 1100-1199 lives on
|
||
* the 10-year (120 mån) correction period. Everything else uses 5 years.
|
||
*
|
||
* The plan's contract is that this resolves off the asset's BAS account
|
||
* range, with category as a secondary signal. Two reasons we prefer
|
||
* account-driven over category-driven:
|
||
* 1. The account is what BAS reports / SIE / INK2R actually read; the
|
||
* category is just a UI label.
|
||
* 2. Users who override the BAS account to something outside the
|
||
* category's default range get a consistent answer with what their
|
||
* reports show.
|
||
*/
|
||
function isRealProperty(args: {
|
||
basExpenseAccount?: string
|
||
basAssetAccount?: string
|
||
category?: AssetCategory
|
||
}): boolean {
|
||
// Prefer the asset (anskaffning) account when supplied — it's the most
|
||
// direct mapping to the BAS class.
|
||
const assetAccount = args.basAssetAccount
|
||
if (assetAccount && /^1[1][0-9]{2}$/.test(assetAccount)) return true
|
||
// Expense account check — 7820-7829 = byggnader/markanläggning.
|
||
const expense = args.basExpenseAccount
|
||
if (expense && /^782[0-9]$/.test(expense)) return true
|
||
// Category fallback for callers who only have the asset row's category
|
||
// (e.g. UI that hasn't loaded the full asset yet).
|
||
if (args.category === 'building' || args.category === 'land_improvement') {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Calendar months between two ISO date strings, rounded toward zero.
|
||
* Counts complete months only — partial months don't tick the clock.
|
||
*
|
||
* The Swedish tax authorities count months, not days, for jämkning
|
||
* (ML 8a kap 6 §). Example: acquired 2023-01-15, sold 2026-01-14 →
|
||
* 35 months elapsed (the 36th month hasn't completed yet).
|
||
*/
|
||
function monthsBetween(fromIso: string, toIso: string): number {
|
||
const from = parseIsoDate(fromIso)
|
||
const to = parseIsoDate(toIso)
|
||
if (!from || !to) return 0
|
||
let months = (to.year - from.year) * 12 + (to.month - from.month)
|
||
if (to.day < from.day) months -= 1
|
||
return months
|
||
}
|
||
|
||
function parseIsoDate(iso: string): { year: number; month: number; day: number } | null {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
|
||
if (!m) return null
|
||
return {
|
||
year: Number(m[1]),
|
||
month: Number(m[2]),
|
||
day: Number(m[3]),
|
||
}
|
||
}
|