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

191 lines
7.3 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.
/**
* 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]),
}
}