* 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>
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
|
|
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
|
import type { AccrualProposal, AccrualsProposal } from './types'
|
|
|
|
/** Sociala avgifter on accrued salary/vacation (K3 BFNAR 2012:1 ch.19,
|
|
* K2 BFNAR 2016:10 ch.16). Same rate as the regular AGI calculation. */
|
|
export const AVGIFTER_RATE_ON_ACCRUED = 0.3142
|
|
|
|
/**
|
|
* Compute the next-day ISO date for auto-reversal. The accrual is reversed
|
|
* on the first day of the period following the closing date.
|
|
*/
|
|
function nextDayIso(closingDate: string): string {
|
|
const d = new Date(closingDate + 'T00:00:00Z')
|
|
d.setUTCDate(d.getUTCDate() + 1)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
/**
|
|
* Propose an adjustment of semesterlöneskuld (vacation pay liability).
|
|
*
|
|
* Two correctness rules drive this entry (per BFNAR 2016:10 ch.16 and the
|
|
* vacation-liability report):
|
|
*
|
|
* 1. The delta is anchored against the **current closing balance** of
|
|
* 2920 (after any mid-year partial accruals / reversals), not the
|
|
* opening balance. Anchoring on opening would over- or understate the
|
|
* adjustment by the sum of in-period movements.
|
|
*
|
|
* 2. Semesterlöneskuld is a balance-sheet carry-forward (2920 / 2940
|
|
* persist until the vacation is actually paid). The bokslut delta is
|
|
* a normal posting that does NOT reverse on Jan 1 — reversing would
|
|
* zero the liability on day 1 of the new year, which is a known
|
|
* Swedish bookkeeping error. Hence `reverses_on` is empty for this
|
|
* proposal; the wizard / UI suppresses the reversal badge accordingly.
|
|
*
|
|
* Returns null when delta is zero (no entry to propose).
|
|
*/
|
|
export async function proposeVacationLiabilityChange(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
fiscalPeriodId: string,
|
|
options: { closingDate: string },
|
|
): Promise<AccrualProposal | null> {
|
|
const closingYear = parseInt(options.closingDate.slice(0, 4), 10)
|
|
if (Number.isNaN(closingYear)) {
|
|
throw new Error(`Invalid closing date: ${options.closingDate}`)
|
|
}
|
|
|
|
const [report, tb] = await Promise.all([
|
|
generateVacationLiability(supabase, companyId, closingYear),
|
|
generateTrialBalance(supabase, companyId, fiscalPeriodId),
|
|
])
|
|
|
|
// Current closing balance (what 2920 should be at year-end)
|
|
const targetLiability = Math.round(report.totals.accruedAmount)
|
|
// Anchor on the CURRENT closing balance, not opening — captures any
|
|
// mid-year accruals / reversals that have already touched 2920.
|
|
const row2920 = tb.rows.find((r) => r.account_number === '2920')
|
|
const currentLiability = row2920
|
|
? Math.round((row2920.closing_credit - row2920.closing_debit) * 100) / 100
|
|
: 0
|
|
|
|
const deltaLiability = targetLiability - currentLiability
|
|
if (Math.abs(deltaLiability) < 1) {
|
|
return null
|
|
}
|
|
|
|
// Round each side to whole krona so the entry stays balanced after
|
|
// rounding. Compute avgifter on the same rounded delta.
|
|
const deltaInt = Math.round(deltaLiability)
|
|
const avgifterDelta = Math.round(deltaInt * AVGIFTER_RATE_ON_ACCRUED)
|
|
|
|
// Positive delta = more vacation liability accrued → debit 7090 expense.
|
|
// Negative delta = vacation taken / liability released → credit 7090.
|
|
const isIncrease = deltaInt > 0
|
|
const lines = [
|
|
{
|
|
account_number: '7090',
|
|
debit_amount: isIncrease ? Math.abs(deltaInt) : 0,
|
|
credit_amount: isIncrease ? 0 : Math.abs(deltaInt),
|
|
line_description: 'Förändring av semesterlöneskuld',
|
|
},
|
|
{
|
|
account_number: '2920',
|
|
debit_amount: isIncrease ? 0 : Math.abs(deltaInt),
|
|
credit_amount: isIncrease ? Math.abs(deltaInt) : 0,
|
|
line_description: 'Upplupna semesterlöner',
|
|
},
|
|
{
|
|
account_number: '7519',
|
|
debit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
|
|
credit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
|
|
line_description: 'Sociala avgifter på upplupen semester (31,42 %)',
|
|
},
|
|
{
|
|
account_number: '2940',
|
|
debit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
|
|
credit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
|
|
line_description: 'Upplupna sociala avgifter på semester',
|
|
},
|
|
]
|
|
|
|
const totalAmount = Math.abs(deltaInt) + Math.abs(avgifterDelta)
|
|
|
|
return {
|
|
kind: 'vacation_liability_change',
|
|
label: isIncrease
|
|
? `Ökning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`
|
|
: `Minskning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`,
|
|
description:
|
|
'Justering av 2920 mot 7090 plus 31,42 % sociala avgifter på 2940 mot 7519. Saldot på 2920 rullas vidare till nästa år (ingen vändning).',
|
|
amount: totalAmount,
|
|
lines,
|
|
// null (not '') = no reversal. The future accrual-reversal cron will
|
|
// filter `reverses_on IS NOT NULL` and an empty string would silently
|
|
// match that.
|
|
reverses_on: null,
|
|
warnings: [],
|
|
computation: {
|
|
current_2920: currentLiability,
|
|
closing_target: targetLiability,
|
|
delta: deltaInt,
|
|
avgifter_rate: AVGIFTER_RATE_ON_ACCRUED,
|
|
avgifter_delta: avgifterDelta,
|
|
employee_rows: report.rows.length,
|
|
},
|
|
}
|
|
}
|
|
|
|
export interface AuditFeeInput {
|
|
/** Estimated audit fee for the fiscal year being closed. */
|
|
amount: number
|
|
/** Closing date — used to derive the reversal date. */
|
|
closingDate: string
|
|
/** Account to credit on the liability side. Defaults to 2992 (revision),
|
|
* use 2991 for bokslut-fee accrual. */
|
|
liabilityAccount?: '2991' | '2992'
|
|
}
|
|
|
|
/**
|
|
* Propose accrual of audit / bookkeeping fee that will be invoiced after
|
|
* year-end. Standard BFL practice: accrue the cost in the period it relates
|
|
* to, reverse on Jan 1 when the actual invoice arrives.
|
|
*/
|
|
export function proposeAuditFee(input: AuditFeeInput): AccrualProposal | null {
|
|
const amount = Math.round(input.amount)
|
|
if (amount <= 0) return null
|
|
const liabilityAccount = input.liabilityAccount ?? '2992'
|
|
const isBokslut = liabilityAccount === '2991'
|
|
// BAS 2026: 6420 = "Revisionsarvode" (lagstadgad revision specifically).
|
|
// Bokslutskostnader for a non-revisionspliktigt bolag belong on 6590
|
|
// (övriga externa tjänster) — Skatteverket may query a 6420 debit
|
|
// without a corresponding revisor i bolaget.
|
|
const expenseAccount = isBokslut ? '6590' : '6420'
|
|
|
|
return {
|
|
kind: 'audit_fee',
|
|
label: isBokslut ? 'Beräknat arvode för bokslut' : 'Beräknat arvode för revision',
|
|
description: `Debet ${expenseAccount}, kredit ${liabilityAccount}. Vänds vid faktura nästa år.`,
|
|
amount,
|
|
lines: [
|
|
{
|
|
account_number: expenseAccount,
|
|
debit_amount: amount,
|
|
credit_amount: 0,
|
|
line_description: isBokslut ? 'Beräknat bokslutarvode' : 'Beräknat revisionsarvode',
|
|
},
|
|
{
|
|
account_number: liabilityAccount,
|
|
debit_amount: 0,
|
|
credit_amount: amount,
|
|
line_description: isBokslut ? 'Beräknat arvode bokslut' : 'Beräknat arvode revision',
|
|
},
|
|
],
|
|
reverses_on: nextDayIso(input.closingDate),
|
|
warnings: [],
|
|
}
|
|
}
|
|
|
|
export interface ManualPrepaidInput {
|
|
/** Amount of the cost that relates to NEXT year and should be reclassified
|
|
* to a 17xx prepaid account. */
|
|
amount: number
|
|
/** Cost account being relieved (e.g. 6310 företagsförsäkringar). */
|
|
expenseAccount: string
|
|
/** Target prepaid account (e.g. 1730 förutbetalda försäkringspremier).
|
|
* Must be in the 17xx interimsfordringar range. */
|
|
prepaidAccount: string
|
|
/** Period this prepaid covers — used in the line description. */
|
|
description: string
|
|
closingDate: string
|
|
}
|
|
|
|
/**
|
|
* Manual prepaid expense reclassification. Debit 17xx, credit the expense
|
|
* account by the portion that hasn't been consumed yet.
|
|
*
|
|
* The caller chooses which expense and prepaid accounts to use because
|
|
* heuristic detection from supplier invoices isn't reliable (no service-
|
|
* period field on the invoice model — see types/index.ts SupplierInvoice).
|
|
* A future heuristic detector can replace this when the data model grows
|
|
* service_period_start/_end fields.
|
|
*/
|
|
export function proposeManualPrepaid(input: ManualPrepaidInput): AccrualProposal | null {
|
|
if (!/^17\d{2}$/.test(input.prepaidAccount)) {
|
|
throw new Error(`prepaidAccount must be in 17xx range, got ${input.prepaidAccount}`)
|
|
}
|
|
const amount = Math.round(input.amount)
|
|
if (amount <= 0) return null
|
|
|
|
return {
|
|
kind: 'manual_prepaid_expense',
|
|
label: `Förutbetald kostnad: ${input.description}`,
|
|
description: `Debet ${input.prepaidAccount}, kredit ${input.expenseAccount}. Vänds vid årsskiftet.`,
|
|
amount,
|
|
lines: [
|
|
{
|
|
account_number: input.prepaidAccount,
|
|
debit_amount: amount,
|
|
credit_amount: 0,
|
|
line_description: `Förutbetald: ${input.description}`,
|
|
},
|
|
{
|
|
account_number: input.expenseAccount,
|
|
debit_amount: 0,
|
|
credit_amount: amount,
|
|
line_description: `Periodisering ut: ${input.description}`,
|
|
},
|
|
],
|
|
reverses_on: nextDayIso(input.closingDate),
|
|
warnings: [],
|
|
}
|
|
}
|
|
|
|
export interface ManualAccruedInput {
|
|
amount: number
|
|
/** Cost account being charged (e.g. 5010 hyra lokal). */
|
|
expenseAccount: string
|
|
/** Target accrued-cost account (e.g. 2990 övriga upplupna kostnader).
|
|
* Must be in the 29xx interimsskulder range. */
|
|
accruedAccount: string
|
|
description: string
|
|
closingDate: string
|
|
}
|
|
|
|
/**
|
|
* Manual accrued cost. Debit the expense account, credit 29xx for the
|
|
* portion incurred but not yet invoiced. Mirrors `proposeManualPrepaid`
|
|
* but in the opposite direction.
|
|
*/
|
|
export function proposeManualAccrued(input: ManualAccruedInput): AccrualProposal | null {
|
|
if (!/^29\d{2}$/.test(input.accruedAccount)) {
|
|
throw new Error(`accruedAccount must be in 29xx range, got ${input.accruedAccount}`)
|
|
}
|
|
const amount = Math.round(input.amount)
|
|
if (amount <= 0) return null
|
|
|
|
return {
|
|
kind: 'manual_accrued_expense',
|
|
label: `Upplupen kostnad: ${input.description}`,
|
|
description: `Debet ${input.expenseAccount}, kredit ${input.accruedAccount}. Vänds vid årsskiftet.`,
|
|
amount,
|
|
lines: [
|
|
{
|
|
account_number: input.expenseAccount,
|
|
debit_amount: amount,
|
|
credit_amount: 0,
|
|
line_description: `Periodisering in: ${input.description}`,
|
|
},
|
|
{
|
|
account_number: input.accruedAccount,
|
|
debit_amount: 0,
|
|
credit_amount: amount,
|
|
line_description: `Upplupen: ${input.description}`,
|
|
},
|
|
],
|
|
reverses_on: nextDayIso(input.closingDate),
|
|
warnings: [],
|
|
}
|
|
}
|
|
|
|
export interface RevenueDeferralInput {
|
|
amount: number
|
|
/** Revenue account to debit (e.g. 3000 / 3001). The full periodisering
|
|
* flow debits revenue and credits 2970 — opposite direction to a prepaid
|
|
* expense. */
|
|
revenueAccount: string
|
|
/** Target deferred-revenue account. Must be in the 29xx range; the wizard
|
|
* defaults this to 2970 specifically (förutbetalda intäkter). */
|
|
deferredAccount: string
|
|
description: string
|
|
closingDate: string
|
|
}
|
|
|
|
/**
|
|
* Propose a deferred-revenue entry. Customer has paid (or has been invoiced)
|
|
* for a service that spans across year-end — the portion attributable to
|
|
* NEXT year is reclassified out of revenue and into 2970. Reverses on Jan 1.
|
|
*
|
|
* Thin wrapper around `proposeManualAccrued` reversed direction-wise: debit
|
|
* the revenue account, credit 2970. Built on the same engine to keep the
|
|
* idempotency / period-lock guarantees identical.
|
|
*/
|
|
export function proposeRevenueDeferral(input: RevenueDeferralInput): AccrualProposal | null {
|
|
if (!/^29\d{2}$/.test(input.deferredAccount)) {
|
|
throw new Error(`deferredAccount must be in 29xx range, got ${input.deferredAccount}`)
|
|
}
|
|
const amount = Math.round(input.amount)
|
|
if (amount <= 0) return null
|
|
|
|
return {
|
|
kind: 'deferred_revenue',
|
|
label: `Förutbetald intäkt: ${input.description}`,
|
|
description: `Debet ${input.revenueAccount}, kredit ${input.deferredAccount}. Vänds vid årsskiftet.`,
|
|
amount,
|
|
lines: [
|
|
{
|
|
account_number: input.revenueAccount,
|
|
debit_amount: amount,
|
|
credit_amount: 0,
|
|
line_description: `Periodisering intäkt ut: ${input.description}`,
|
|
},
|
|
{
|
|
account_number: input.deferredAccount,
|
|
debit_amount: 0,
|
|
credit_amount: amount,
|
|
line_description: `Förutbetald intäkt: ${input.description}`,
|
|
},
|
|
],
|
|
reverses_on: nextDayIso(input.closingDate),
|
|
warnings: [],
|
|
}
|
|
}
|
|
|
|
export interface AccruedInterestInput {
|
|
amount: number
|
|
/** Interest-expense account, typically 8410 räntekostnader. */
|
|
expenseAccount: string
|
|
/** Accrued-interest liability, typically 2940 upplupna sociala avgifter
|
|
* is wrong — actual choice is the more general 2940 / 2960 family. The
|
|
* wizard defaults to 2960 / 2950; this helper validates 29xx. */
|
|
accruedAccount: string
|
|
description: string
|
|
closingDate: string
|
|
}
|
|
|
|
/**
|
|
* Propose accrued interest expense. Same shape as a generic accrued cost,
|
|
* but emits a clearer label so the user can tell apart from rent/utilities
|
|
* in the wizard's review step.
|
|
*/
|
|
export function proposeAccruedInterest(input: AccruedInterestInput): AccrualProposal | null {
|
|
const base = proposeManualAccrued({
|
|
amount: input.amount,
|
|
expenseAccount: input.expenseAccount,
|
|
accruedAccount: input.accruedAccount,
|
|
description: input.description,
|
|
closingDate: input.closingDate,
|
|
})
|
|
if (!base) return null
|
|
return {
|
|
...base,
|
|
kind: 'accrued_interest',
|
|
label: `Upplupen ränta: ${input.description}`,
|
|
}
|
|
}
|
|
|
|
export interface AccruedUtilityInput {
|
|
amount: number
|
|
/** Utility-expense account (e.g. 5020 el för kontorslokal). */
|
|
expenseAccount: string
|
|
/** Accrued liability, typically 2990 övriga upplupna kostnader. */
|
|
accruedAccount: string
|
|
description: string
|
|
closingDate: string
|
|
}
|
|
|
|
/**
|
|
* Propose accrued utility cost. Same shape as proposeAccruedInterest with a
|
|
* different label — helps the wizard group similar accruals visually.
|
|
*/
|
|
export function proposeAccruedUtility(input: AccruedUtilityInput): AccrualProposal | null {
|
|
const base = proposeManualAccrued({
|
|
amount: input.amount,
|
|
expenseAccount: input.expenseAccount,
|
|
accruedAccount: input.accruedAccount,
|
|
description: input.description,
|
|
closingDate: input.closingDate,
|
|
})
|
|
if (!base) return null
|
|
return {
|
|
...base,
|
|
kind: 'accrued_utility',
|
|
label: `Upplupen förbrukning: ${input.description}`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a snapshot of automatically-detectable accrual proposals for the
|
|
* wizard's preflight. Today this is just the vacation-liability delta;
|
|
* future versions can add salary-accrued-but-unpaid, supplier-invoice-period
|
|
* detection, etc. Manual prepaid/accrued cards are added via the UI form
|
|
* (the API endpoint accepts them but they're not in the auto-proposal).
|
|
*/
|
|
export async function buildAccrualsProposal(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
fiscalPeriodId: string,
|
|
): Promise<AccrualsProposal> {
|
|
const { data: period, error } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, name, period_start, period_end')
|
|
.eq('id', fiscalPeriodId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
if (error || !period) throw new Error('Fiscal period not found')
|
|
|
|
const proposals: AccrualProposal[] = []
|
|
|
|
const vacation = await proposeVacationLiabilityChange(supabase, companyId, fiscalPeriodId, {
|
|
closingDate: period.period_end,
|
|
})
|
|
if (vacation) proposals.push(vacation)
|
|
|
|
return {
|
|
fiscalPeriod: period,
|
|
proposals,
|
|
}
|
|
}
|