* 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>
190 lines
7.1 KiB
TypeScript
190 lines
7.1 KiB
TypeScript
/**
|
||
* ROT/RUT-avdrag rules.
|
||
*
|
||
* Implements the calculation and validation logic for Sweden's tax deduction
|
||
* for household services (RUT) and home renovation (ROT). As of 2026:
|
||
* - ROT: 30% of labor cost, max 50 000 kr per person per year.
|
||
* - RUT: 50% of labor cost, max 75 000 kr per person per year.
|
||
*
|
||
* The deduction applies to labor only — material costs and travel time are
|
||
* NOT eligible. In this v1 we treat the entire invoice item amount as labor
|
||
* when the user flags it ROT/RUT; the user is expected to either invoice
|
||
* labor on its own row or split materials onto a non-flagged row. A future
|
||
* iteration can add per-line "labor portion" handling if needed.
|
||
*
|
||
* We CAN'T verify that the customer has remaining yearly headroom (they may
|
||
* have claimed elsewhere). We surface a warning when the per-invoice total
|
||
* already exceeds the statutory max — the customer must then handle the
|
||
* excess outside of fakturamodellen.
|
||
*
|
||
* All functions are pure and deterministic. No I/O, no DB calls — easy to
|
||
* unit-test and easy to embed in the API validator and the live total
|
||
* preview in the invoice editor.
|
||
*/
|
||
|
||
/** Percentage of eligible amount deducted for ROT (renovation). 2026 rule. */
|
||
export const ROT_PERCENT = 0.30
|
||
|
||
/** Percentage of eligible amount deducted for RUT (household services). 2026 rule. */
|
||
export const RUT_PERCENT = 0.50
|
||
|
||
/** Maximum yearly ROT deduction per person, in kr. 2026 rule. */
|
||
export const ROT_MAX = 50000
|
||
|
||
/** Maximum yearly RUT deduction per person, in kr. 2026 rule. */
|
||
export const RUT_MAX = 75000
|
||
|
||
export type DeductionType = 'rot' | 'rut'
|
||
|
||
/** Skatteverket work codes used by Husavdragstjänsten. Maps a free-text */
|
||
/** "what the worker did" label to the official code the Skatteverket file */
|
||
/** will need. v1 stores both — the label is shown on the PDF; the code is */
|
||
/** stored as `work_type` for the future submission file. */
|
||
export const ROT_WORK_TYPES = [
|
||
{ code: 'BYGG', label: 'Byggnadsarbete' },
|
||
{ code: 'EL', label: 'Elarbete' },
|
||
{ code: 'GLAS_PLAT', label: 'Glas- och plåtarbete' },
|
||
{ code: 'MARK_DRAN', label: 'Mark- och dräneringsarbete' },
|
||
{ code: 'MURNING', label: 'Murnings- och putsarbete' },
|
||
{ code: 'MALNING', label: 'Mål- och tapetseringsarbete' },
|
||
{ code: 'VVS', label: 'VVS-arbete' },
|
||
{ code: 'IT', label: 'IT-tjänster i hemmet' },
|
||
] as const
|
||
|
||
export const RUT_WORK_TYPES = [
|
||
{ code: 'STAD', label: 'Städning, tvätt och vård av kläder' },
|
||
{ code: 'KLAD', label: 'Klädvård i hemmet' },
|
||
{ code: 'TRADGARD', label: 'Trädgårdsarbete' },
|
||
{ code: 'BARNPASS', label: 'Barnpassning' },
|
||
{ code: 'PERSONLIG_OMS', label: 'Personlig omsorg' },
|
||
{ code: 'FLYTT', label: 'Flytthjälp' },
|
||
{ code: 'REPARATION', label: 'Reparation av vitvaror' },
|
||
{ code: 'IT', label: 'IT-tjänster i hemmet' },
|
||
{ code: 'MOBLERING', label: 'Möblering och tillsyn av bostad' },
|
||
{ code: 'TRANSPORT', label: 'Transport till och från återvinning' },
|
||
] as const
|
||
|
||
export interface ItemForDeduction {
|
||
/** Unit price (per `quantity`). Same field as invoice_items.unit_price. */
|
||
unit_price: number
|
||
/** Quantity. Same field as invoice_items.quantity. */
|
||
quantity: number
|
||
/** 'rot' | 'rut' | null. Drives whether the deduction kicks in at all. */
|
||
deduction_type?: DeductionType | null
|
||
/**
|
||
* Optional. Reserved for a future iteration where the eligible portion of
|
||
* the row is just the labor hours × hourly rate. v1 ignores this and
|
||
* deducts on the full line total; we still take the field so the API
|
||
* schema accepts it without rejecting future-shaped payloads.
|
||
*/
|
||
labor_hours?: number | null
|
||
}
|
||
|
||
/**
|
||
* Compute the deduction amount for a single invoice item. Returns 0 when
|
||
* the item has no deduction_type. The result is always >= 0 and <= line
|
||
* total (no over-deduction even if percentages are tweaked).
|
||
*/
|
||
export function computeDeduction(item: ItemForDeduction): number {
|
||
if (!item.deduction_type) return 0
|
||
const lineTotal = item.unit_price * item.quantity
|
||
if (lineTotal <= 0) return 0
|
||
const percent = item.deduction_type === 'rot' ? ROT_PERCENT : RUT_PERCENT
|
||
const raw = lineTotal * percent
|
||
// Cap at line total — defensive against future rule changes that would
|
||
// push percent past 1.0.
|
||
const capped = Math.min(raw, lineTotal)
|
||
return Math.round(capped * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* Sum the per-item deduction over an invoice. Returns the total to store
|
||
* on invoices.deduction_total and to use as the 1513 debit amount.
|
||
*/
|
||
export function computeInvoiceDeductionTotal(items: ItemForDeduction[]): number {
|
||
let total = 0
|
||
for (const item of items) {
|
||
total += computeDeduction(item)
|
||
}
|
||
return Math.round(total * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* Sum per deduction kind. Used to surface separate cap warnings.
|
||
*/
|
||
export function computeDeductionTotalsByKind(items: ItemForDeduction[]): {
|
||
rot: number
|
||
rut: number
|
||
} {
|
||
let rot = 0
|
||
let rut = 0
|
||
for (const item of items) {
|
||
const amount = computeDeduction(item)
|
||
if (item.deduction_type === 'rot') rot += amount
|
||
else if (item.deduction_type === 'rut') rut += amount
|
||
}
|
||
return {
|
||
rot: Math.round(rot * 100) / 100,
|
||
rut: Math.round(rut * 100) / 100,
|
||
}
|
||
}
|
||
|
||
export interface ValidateInvoiceItem extends ItemForDeduction {
|
||
housing_designation?: string | null
|
||
}
|
||
|
||
export interface ValidationResult {
|
||
errors: string[]
|
||
warnings: string[]
|
||
}
|
||
|
||
/**
|
||
* Validate ROT/RUT prerequisites against a draft invoice.
|
||
*
|
||
* Errors block invoice creation; warnings surface in the UI but don't
|
||
* block (we can't verify a customer's yearly headroom across providers,
|
||
* but we can surface a "this invoice alone exceeds the cap" warning).
|
||
*
|
||
* The function takes invoice-level metadata as separate arguments rather
|
||
* than reading them off the items array so callers can compose it from
|
||
* either a HTTP request body or the form state without restructuring.
|
||
*/
|
||
export function validateInvoice(
|
||
items: ValidateInvoiceItem[],
|
||
personnummerProvided: boolean,
|
||
housingDesignationProvided: boolean,
|
||
): ValidationResult {
|
||
const errors: string[] = []
|
||
const warnings: string[] = []
|
||
|
||
const hasAnyDeduction = items.some((item) => item.deduction_type)
|
||
const hasAnyRot = items.some((item) => item.deduction_type === 'rot')
|
||
|
||
if (hasAnyDeduction && !personnummerProvided) {
|
||
errors.push('Personnummer krävs för ROT/RUT-avdrag.')
|
||
}
|
||
|
||
// ROT requires fastighetsbeteckning per Skatteverket's Husavdragstjänst.
|
||
// RUT does not (in 2026 the Skatteverket file accepts RUT without it).
|
||
if (hasAnyRot && !housingDesignationProvided) {
|
||
errors.push('Fastighetsbeteckning krävs för ROT-avdrag.')
|
||
}
|
||
|
||
const { rot, rut } = computeDeductionTotalsByKind(items)
|
||
|
||
if (rot > ROT_MAX) {
|
||
warnings.push(
|
||
`ROT-avdraget på denna faktura (${rot.toFixed(2)} kr) överstiger årsmaximum ${ROT_MAX.toLocaleString('sv-SE')} kr. ` +
|
||
'Kunden behöver kontrollera sitt återstående utrymme själv.',
|
||
)
|
||
}
|
||
if (rut > RUT_MAX) {
|
||
warnings.push(
|
||
`RUT-avdraget på denna faktura (${rut.toFixed(2)} kr) överstiger årsmaximum ${RUT_MAX.toLocaleString('sv-SE')} kr. ` +
|
||
'Kunden behöver kontrollera sitt återstående utrymme själv.',
|
||
)
|
||
}
|
||
|
||
return { errors, warnings }
|
||
}
|