* 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>
206 lines
6.5 KiB
TypeScript
206 lines
6.5 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
computeJamkningAmount,
|
||
assessJamkningEligibility,
|
||
} from '../jamkning'
|
||
|
||
describe('computeJamkningAmount', () => {
|
||
it('5-year asset sold after 3 years (24 months remaining, 20 000 kr input VAT) → 8 000 kr', () => {
|
||
// ML 8a kap 7 §: (24 / 60) × 20 000 = 8 000
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 20_000,
|
||
totalCorrectionMonths: 60,
|
||
remainingMonths: 24,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(8_000)
|
||
})
|
||
|
||
it('10-year fastighet sold after 7 years (36 months remaining, 200 000 kr input VAT) → 60 000 kr', () => {
|
||
// ML 8a kap 7 §: (36 / 120) × 200 000 = 60 000
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 200_000,
|
||
totalCorrectionMonths: 120,
|
||
remainingMonths: 36,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(60_000)
|
||
})
|
||
|
||
it('sold after the correction period (0 remaining) → 0', () => {
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 20_000,
|
||
totalCorrectionMonths: 60,
|
||
remainingMonths: 0,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(0)
|
||
})
|
||
|
||
it('sold immediately (60 months remaining on 60-month period) → full originalInputVat', () => {
|
||
// (60 / 60) × 20 000 = 20 000 — the full deduction must be reversed
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 20_000,
|
||
totalCorrectionMonths: 60,
|
||
remainingMonths: 60,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(20_000)
|
||
})
|
||
|
||
it('returns 0 when disposalEvent is no_jamkning', () => {
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 20_000,
|
||
totalCorrectionMonths: 60,
|
||
remainingMonths: 24,
|
||
disposalEvent: 'no_jamkning',
|
||
})
|
||
expect(amount).toBe(0)
|
||
})
|
||
|
||
it('caps remaining months at totalCorrectionMonths (defensive)', () => {
|
||
// A caller bug could pass remainingMonths > totalCorrectionMonths.
|
||
// Cap at the total so the answer never exceeds originalInputVat.
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 10_000,
|
||
totalCorrectionMonths: 60,
|
||
remainingMonths: 120,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(10_000)
|
||
})
|
||
|
||
it('handles negligible cost (zero originalInputVat) → 0 without NaN', () => {
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 0,
|
||
totalCorrectionMonths: 60,
|
||
remainingMonths: 24,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(0)
|
||
expect(Number.isNaN(amount)).toBe(false)
|
||
})
|
||
|
||
it('returns 0 when totalCorrectionMonths is 0 (avoid divide-by-zero)', () => {
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 20_000,
|
||
totalCorrectionMonths: 0,
|
||
remainingMonths: 0,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(0)
|
||
expect(Number.isFinite(amount)).toBe(true)
|
||
})
|
||
|
||
it('returns 0 when totalCorrectionMonths is negative (defensive)', () => {
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 20_000,
|
||
totalCorrectionMonths: -60,
|
||
remainingMonths: -24,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(0)
|
||
expect(Number.isFinite(amount)).toBe(true)
|
||
})
|
||
|
||
it('rounds to two decimals (no öre stray cents)', () => {
|
||
// (17 / 60) × 10 000 = 2833.333... → 2833.33
|
||
const amount = computeJamkningAmount({
|
||
originalInputVat: 10_000,
|
||
totalCorrectionMonths: 60,
|
||
remainingMonths: 17,
|
||
disposalEvent: 'triggers_jamkning',
|
||
})
|
||
expect(amount).toBe(2_833.33)
|
||
})
|
||
})
|
||
|
||
describe('assessJamkningEligibility', () => {
|
||
it('returns 120 months for fastighet BAS 1110', () => {
|
||
const e = assessJamkningEligibility({
|
||
basAssetAccount: '1110',
|
||
basExpenseAccount: '7821',
|
||
category: 'building',
|
||
acquisitionDate: '2020-01-01',
|
||
disposalDate: '2026-01-01',
|
||
})
|
||
expect(e.totalCorrectionMonths).toBe(120)
|
||
// 6 years = 72 months elapsed → 48 months remaining
|
||
expect(e.elapsedMonths).toBe(72)
|
||
expect(e.remainingMonths).toBe(48)
|
||
expect(e.withinCorrectionPeriod).toBe(true)
|
||
})
|
||
|
||
it('returns 60 months for equipment BAS 1220', () => {
|
||
const e = assessJamkningEligibility({
|
||
basAssetAccount: '1220',
|
||
basExpenseAccount: '7832',
|
||
category: 'equipment',
|
||
acquisitionDate: '2024-01-01',
|
||
disposalDate: '2026-01-01',
|
||
})
|
||
expect(e.totalCorrectionMonths).toBe(60)
|
||
// 2 years = 24 months → 36 months remaining
|
||
expect(e.elapsedMonths).toBe(24)
|
||
expect(e.remainingMonths).toBe(36)
|
||
expect(e.withinCorrectionPeriod).toBe(true)
|
||
})
|
||
|
||
it('detects markanläggning (BAS 1150) as real property → 120 months', () => {
|
||
const e = assessJamkningEligibility({
|
||
basAssetAccount: '1150',
|
||
basExpenseAccount: '7824',
|
||
category: 'land_improvement',
|
||
acquisitionDate: '2023-06-01',
|
||
disposalDate: '2024-06-01',
|
||
})
|
||
expect(e.totalCorrectionMonths).toBe(120)
|
||
})
|
||
|
||
it('falls back to category when account is unrecognized', () => {
|
||
// No BAS account provided — has to rely on the category signal.
|
||
const e = assessJamkningEligibility({
|
||
category: 'building',
|
||
acquisitionDate: '2024-01-01',
|
||
disposalDate: '2026-01-01',
|
||
})
|
||
expect(e.totalCorrectionMonths).toBe(120)
|
||
})
|
||
|
||
it('reports withinCorrectionPeriod = false after the full period elapses', () => {
|
||
const e = assessJamkningEligibility({
|
||
basAssetAccount: '1220',
|
||
category: 'equipment',
|
||
acquisitionDate: '2020-01-01',
|
||
disposalDate: '2026-01-01',
|
||
})
|
||
// 6 years = 72 months elapsed > 60 → 0 remaining
|
||
expect(e.remainingMonths).toBe(0)
|
||
expect(e.withinCorrectionPeriod).toBe(false)
|
||
})
|
||
|
||
it('counts complete months only (day-precision)', () => {
|
||
// 2023-01-15 to 2026-01-14 → 35 complete months (the 36th hasn't finished)
|
||
const e = assessJamkningEligibility({
|
||
basAssetAccount: '1220',
|
||
category: 'equipment',
|
||
acquisitionDate: '2023-01-15',
|
||
disposalDate: '2026-01-14',
|
||
})
|
||
expect(e.elapsedMonths).toBe(35)
|
||
expect(e.remainingMonths).toBe(25)
|
||
})
|
||
|
||
it('clamps elapsedMonths to 0 if disposalDate precedes acquisitionDate', () => {
|
||
// Defensive — should never happen in practice but must not blow up.
|
||
const e = assessJamkningEligibility({
|
||
basAssetAccount: '1220',
|
||
category: 'equipment',
|
||
acquisitionDate: '2026-01-01',
|
||
disposalDate: '2024-01-01',
|
||
})
|
||
expect(e.elapsedMonths).toBe(0)
|
||
expect(e.remainingMonths).toBe(60)
|
||
})
|
||
})
|