* 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>
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
ROT_PERCENT,
|
||
RUT_PERCENT,
|
||
ROT_MAX,
|
||
RUT_MAX,
|
||
computeDeduction,
|
||
computeInvoiceDeductionTotal,
|
||
computeDeductionTotalsByKind,
|
||
validateInvoice,
|
||
type ItemForDeduction,
|
||
type ValidateInvoiceItem,
|
||
} from '../rot-rut-rules'
|
||
|
||
describe('rot-rut-rules — constants', () => {
|
||
it('uses the 2026 statutory rates', () => {
|
||
expect(ROT_PERCENT).toBe(0.30)
|
||
expect(RUT_PERCENT).toBe(0.50)
|
||
expect(ROT_MAX).toBe(50000)
|
||
expect(RUT_MAX).toBe(75000)
|
||
})
|
||
})
|
||
|
||
describe('computeDeduction', () => {
|
||
it('standard ROT: 10 000 kr labor → 3 000 kr deduction', () => {
|
||
const item: ItemForDeduction = {
|
||
unit_price: 10000,
|
||
quantity: 1,
|
||
deduction_type: 'rot',
|
||
}
|
||
expect(computeDeduction(item)).toBe(3000)
|
||
})
|
||
|
||
it('standard RUT: 5 000 kr labor → 2 500 kr deduction', () => {
|
||
const item: ItemForDeduction = {
|
||
unit_price: 5000,
|
||
quantity: 1,
|
||
deduction_type: 'rut',
|
||
}
|
||
expect(computeDeduction(item)).toBe(2500)
|
||
})
|
||
|
||
it('no deduction_type → 0', () => {
|
||
const item: ItemForDeduction = {
|
||
unit_price: 10000,
|
||
quantity: 1,
|
||
}
|
||
expect(computeDeduction(item)).toBe(0)
|
||
})
|
||
|
||
it('null deduction_type → 0', () => {
|
||
const item: ItemForDeduction = {
|
||
unit_price: 10000,
|
||
quantity: 1,
|
||
deduction_type: null,
|
||
}
|
||
expect(computeDeduction(item)).toBe(0)
|
||
})
|
||
|
||
it('negative or zero amount → 0', () => {
|
||
expect(computeDeduction({ unit_price: 0, quantity: 1, deduction_type: 'rot' })).toBe(0)
|
||
expect(computeDeduction({ unit_price: -100, quantity: 1, deduction_type: 'rut' })).toBe(0)
|
||
})
|
||
|
||
it('quantity > 1 with ROT', () => {
|
||
const item: ItemForDeduction = {
|
||
unit_price: 500,
|
||
quantity: 20, // 10 000 total
|
||
deduction_type: 'rot',
|
||
}
|
||
expect(computeDeduction(item)).toBe(3000)
|
||
})
|
||
|
||
it('rounds to two decimals', () => {
|
||
const item: ItemForDeduction = {
|
||
unit_price: 333.33,
|
||
quantity: 1,
|
||
deduction_type: 'rut', // 333.33 * 0.5 = 166.665 → 166.67 (banker's rounding off)
|
||
}
|
||
expect(computeDeduction(item)).toBe(166.67)
|
||
})
|
||
|
||
it('caps at line total even if percent goes off (defensive)', () => {
|
||
// The percent is < 1.0 so this is hypothetical, but the cap is part
|
||
// of the contract — assert it via a synthetic case where unit_price ×
|
||
// quantity happens to be tiny but the rounding step could overshoot.
|
||
const item: ItemForDeduction = {
|
||
unit_price: 0.01,
|
||
quantity: 1,
|
||
deduction_type: 'rut',
|
||
}
|
||
// 0.01 * 0.5 = 0.005 → rounds to 0.01 = line_total. OK, capped.
|
||
expect(computeDeduction(item)).toBe(0.01)
|
||
})
|
||
})
|
||
|
||
describe('computeInvoiceDeductionTotal', () => {
|
||
it('mixed: ROT line + non-eligible line — only ROT generates deduction', () => {
|
||
const items: ItemForDeduction[] = [
|
||
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' },
|
||
{ unit_price: 2000, quantity: 1 }, // not flagged
|
||
]
|
||
expect(computeInvoiceDeductionTotal(items)).toBe(3000)
|
||
})
|
||
|
||
it('mixed ROT + RUT lines sum independently', () => {
|
||
const items: ItemForDeduction[] = [
|
||
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
|
||
{ unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
|
||
]
|
||
expect(computeInvoiceDeductionTotal(items)).toBe(5000)
|
||
})
|
||
|
||
it('all non-eligible → 0', () => {
|
||
const items: ItemForDeduction[] = [
|
||
{ unit_price: 1000, quantity: 1 },
|
||
{ unit_price: 2000, quantity: 1 },
|
||
]
|
||
expect(computeInvoiceDeductionTotal(items)).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe('computeDeductionTotalsByKind', () => {
|
||
it('separates ROT and RUT', () => {
|
||
const items: ItemForDeduction[] = [
|
||
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
|
||
{ unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
|
||
{ unit_price: 2000, quantity: 1, deduction_type: 'rot' }, // 600
|
||
]
|
||
expect(computeDeductionTotalsByKind(items)).toEqual({ rot: 3600, rut: 2000 })
|
||
})
|
||
})
|
||
|
||
describe('validateInvoice', () => {
|
||
it('errors when ROT/RUT but personnummer missing', () => {
|
||
const items: ValidateInvoiceItem[] = [
|
||
{ unit_price: 5000, quantity: 1, deduction_type: 'rut' },
|
||
]
|
||
const result = validateInvoice(items, false, true)
|
||
expect(result.errors).toContain('Personnummer krävs för ROT/RUT-avdrag.')
|
||
})
|
||
|
||
it('errors when ROT but housing_designation missing', () => {
|
||
const items: ValidateInvoiceItem[] = [
|
||
{ unit_price: 5000, quantity: 1, deduction_type: 'rot' },
|
||
]
|
||
const result = validateInvoice(items, true, false)
|
||
expect(result.errors).toContain('Fastighetsbeteckning krävs för ROT-avdrag.')
|
||
})
|
||
|
||
it('RUT without housing_designation → no error (RUT does not require it)', () => {
|
||
const items: ValidateInvoiceItem[] = [
|
||
{ unit_price: 5000, quantity: 1, deduction_type: 'rut' },
|
||
]
|
||
const result = validateInvoice(items, true, false)
|
||
expect(result.errors).toHaveLength(0)
|
||
})
|
||
|
||
it('no deduction lines → no errors regardless of metadata', () => {
|
||
const items: ValidateInvoiceItem[] = [
|
||
{ unit_price: 5000, quantity: 1 },
|
||
]
|
||
expect(validateInvoice(items, false, false).errors).toHaveLength(0)
|
||
})
|
||
|
||
it('warns about ROT cap when invoice alone exceeds 50 000', () => {
|
||
const items: ValidateInvoiceItem[] = [
|
||
{ unit_price: 200000, quantity: 1, deduction_type: 'rot' }, // 60 000 deduction
|
||
]
|
||
const result = validateInvoice(items, true, true)
|
||
expect(result.errors).toHaveLength(0)
|
||
expect(result.warnings.length).toBeGreaterThan(0)
|
||
expect(result.warnings[0]).toMatch(/ROT/)
|
||
expect(result.warnings[0]).toMatch(/50/)
|
||
})
|
||
|
||
it('warns about RUT cap when invoice alone exceeds 75 000', () => {
|
||
const items: ValidateInvoiceItem[] = [
|
||
{ unit_price: 200000, quantity: 1, deduction_type: 'rut' }, // 100 000 deduction
|
||
]
|
||
const result = validateInvoice(items, true, true)
|
||
expect(result.warnings.length).toBeGreaterThan(0)
|
||
expect(result.warnings[0]).toMatch(/RUT/)
|
||
expect(result.warnings[0]).toMatch(/75/)
|
||
})
|
||
|
||
it('no warning when total under cap', () => {
|
||
const items: ValidateInvoiceItem[] = [
|
||
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000 — well under cap
|
||
]
|
||
const result = validateInvoice(items, true, true)
|
||
expect(result.warnings).toHaveLength(0)
|
||
})
|
||
})
|