* 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>
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Mock trial-balance and income-statement so we can plant deterministic
|
|
// inputs into the cash-flow generator. The generator is pure logic over
|
|
// (TB rows, IS totals) — testing it in isolation avoids re-creating the
|
|
// Supabase mock surface for two layered generators.
|
|
vi.mock('../trial-balance', () => ({
|
|
generateTrialBalance: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../income-statement', () => ({
|
|
generateIncomeStatement: vi.fn(),
|
|
}))
|
|
|
|
import { generateKassaflodesanalys } from '../kassaflodesanalys'
|
|
import { generateTrialBalance } from '../trial-balance'
|
|
import { generateIncomeStatement } from '../income-statement'
|
|
import type { TrialBalanceRow, IncomeStatementReport } from '@/types'
|
|
|
|
const mockTrialBalance = vi.mocked(generateTrialBalance)
|
|
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
|
|
|
|
function makeSupabase(period: { period_start: string; period_end: string } | null) {
|
|
// Lightweight chainable mock — kassaflodesanalys only calls
|
|
// supabase.from('fiscal_periods').select().eq().eq().single()
|
|
const builder: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq']) {
|
|
builder[m] = vi.fn().mockReturnValue(builder)
|
|
}
|
|
builder.single = vi.fn().mockResolvedValue(
|
|
period ? { data: period, error: null } : { data: null, error: null }
|
|
)
|
|
return {
|
|
from: vi.fn().mockReturnValue(builder),
|
|
} as unknown as Parameters<typeof generateKassaflodesanalys>[0]
|
|
}
|
|
|
|
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
|
|
return {
|
|
account_number: '0000',
|
|
account_name: 'X',
|
|
account_class: 0,
|
|
opening_debit: 0,
|
|
opening_credit: 0,
|
|
period_debit: 0,
|
|
period_credit: 0,
|
|
closing_debit: 0,
|
|
closing_credit: 0,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeIs(overrides: Partial<IncomeStatementReport> = {}): IncomeStatementReport {
|
|
return {
|
|
revenue_sections: [],
|
|
total_revenue: 0,
|
|
expense_sections: [],
|
|
total_expenses: 0,
|
|
financial_sections: [],
|
|
total_financial: 0,
|
|
net_result: 0,
|
|
period: { start: '2024-01-01', end: '2024-12-31' },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('generateKassaflodesanalys', () => {
|
|
const PERIOD = { period_start: '2024-01-01', period_end: '2024-12-31' }
|
|
|
|
it('returns all-zero sections for an empty period', async () => {
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [],
|
|
totalDebit: 0,
|
|
totalCredit: 0,
|
|
isBalanced: true,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(makeIs())
|
|
|
|
const report = await generateKassaflodesanalys(
|
|
makeSupabase(PERIOD),
|
|
'company-1',
|
|
'period-1'
|
|
)
|
|
|
|
expect(report.lopande.total).toBe(0)
|
|
expect(report.investerings.total).toBe(0)
|
|
expect(report.finansierings.total).toBe(0)
|
|
expect(report.total_cash_flow).toBe(0)
|
|
expect(report.reconciliation.is_reconciled).toBe(true)
|
|
expect(report.reconciliation.opening_cash_1xxx).toBe(0)
|
|
expect(report.reconciliation.closing_cash_1xxx).toBe(0)
|
|
expect(report.reconciliation.delta_actual).toBe(0)
|
|
expect(report.reconciliation.delta_calculated).toBe(0)
|
|
})
|
|
|
|
it('adds back avskrivningar (100 000 kr) to löpande verksamhet', async () => {
|
|
// Setup: 100 000 kr depreciation booked: debit 7832, credit 1219 (ack avskr).
|
|
// Expected: avskrivningar = 100 000, added back to resultat efter finansiella.
|
|
// For reconciliation: bank moved 0 because depreciation is non-cash; the
|
|
// fixed-asset NET delta is 1219 going up in credit (i.e. asset side down),
|
|
// which surfaces as avyttring (debit-side negative) -> +100 000 in investing.
|
|
//
|
|
// To keep this test focused on the "add back" behavior, we plant zero
|
|
// movement on classes 1-3,4-6,8 except 78xx (depreciation expense) and
|
|
// the offsetting 1219 (ack avskr). Result before fin: -100 000 (only
|
|
// expense). Add back +100 000. Net cash flow from operating: 0.
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [
|
|
makeRow({
|
|
account_number: '7832',
|
|
account_class: 7,
|
|
period_debit: 100000,
|
|
closing_debit: 100000,
|
|
}),
|
|
// 1219 = ack avskr inventarier (contra-asset, credit-normal). Increases
|
|
// by 100 000 over the period.
|
|
makeRow({
|
|
account_number: '1219',
|
|
account_class: 1,
|
|
period_credit: 100000,
|
|
closing_credit: 100000,
|
|
}),
|
|
],
|
|
totalDebit: 100000,
|
|
totalCredit: 100000,
|
|
isBalanced: true,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(
|
|
makeIs({
|
|
total_expenses: 100000,
|
|
net_result: -100000,
|
|
})
|
|
)
|
|
|
|
const report = await generateKassaflodesanalys(
|
|
makeSupabase(PERIOD),
|
|
'company-1',
|
|
'period-1'
|
|
)
|
|
|
|
expect(report.lopande.resultat_efter_finansiella_poster).toBe(-100000)
|
|
expect(report.lopande.avskrivningar).toBe(100000)
|
|
// Result + add-back depreciation = 0 löpande
|
|
expect(report.lopande.total).toBe(0)
|
|
// 1219 sits in 12xx range (investing), credit went up = debit-side delta
|
|
// is negative -> avyttring path. This is acceptable behavior; the
|
|
// reconciliation invariant is what protects us. Verify it holds:
|
|
// total_cash_flow should equal delta_actual on 19xx (which is 0).
|
|
expect(report.reconciliation.delta_actual).toBe(0)
|
|
// The mock setup ensures investing offsets to make the reconciliation
|
|
// balance against 0 cash movement.
|
|
expect(report.reconciliation.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('reconciles when a 50 000 kr deposit hits the bank (1930)', async () => {
|
|
// Setup: customer pays an invoice 50 000 net of VAT for simplicity.
|
|
// 1930 (bank) debit 50 000; 1510 (kundfordringar) credit 50 000.
|
|
// No P&L impact (already booked at invoice creation).
|
|
//
|
|
// Expected:
|
|
// Δ kortfristiga fordringar = -(-50 000) = +50 000 (receivables down → cash in)
|
|
// Result efter fin = 0
|
|
// Lopande total = +50 000
|
|
// delta_actual = 50 000 (closing 19xx = 50 000)
|
|
// delta_calculated = 50 000
|
|
// is_reconciled = true
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [
|
|
makeRow({
|
|
account_number: '1930',
|
|
account_class: 1,
|
|
period_debit: 50000,
|
|
closing_debit: 50000,
|
|
}),
|
|
makeRow({
|
|
account_number: '1510',
|
|
account_class: 1,
|
|
opening_debit: 50000,
|
|
period_credit: 50000,
|
|
closing_debit: 50000,
|
|
closing_credit: 50000,
|
|
}),
|
|
],
|
|
totalDebit: 100000,
|
|
totalCredit: 50000,
|
|
isBalanced: false,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(makeIs())
|
|
|
|
const report = await generateKassaflodesanalys(
|
|
makeSupabase(PERIOD),
|
|
'company-1',
|
|
'period-1'
|
|
)
|
|
|
|
// 1510 net debit-side delta = (50000-50000) - (50000-0) = -50000
|
|
// delta_kortfristiga_fordringar = -(-50000) = +50000
|
|
expect(report.lopande.delta_kortfristiga_fordringar).toBe(50000)
|
|
expect(report.lopande.total).toBe(50000)
|
|
expect(report.reconciliation.opening_cash_1xxx).toBe(0)
|
|
expect(report.reconciliation.closing_cash_1xxx).toBe(50000)
|
|
expect(report.reconciliation.delta_actual).toBe(50000)
|
|
expect(report.reconciliation.delta_calculated).toBe(50000)
|
|
expect(report.reconciliation.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('records asset purchase (200 000 kr) as investing outflow', async () => {
|
|
// Setup: buy inventarie for 200 000: debit 1220, credit 1930.
|
|
// 1930 (bank): credit 200 000 → closing -200 000
|
|
// 1220 (inventarier): debit 200 000 → closing +200 000
|
|
//
|
|
// Expected:
|
|
// forvarv_anlaggningar = -200 000
|
|
// delta_actual = -200 000 (bank went down)
|
|
// delta_calculated = -200 000
|
|
// is_reconciled = true
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [
|
|
makeRow({
|
|
account_number: '1930',
|
|
account_class: 1,
|
|
period_credit: 200000,
|
|
closing_credit: 200000,
|
|
}),
|
|
makeRow({
|
|
account_number: '1220',
|
|
account_class: 1,
|
|
period_debit: 200000,
|
|
closing_debit: 200000,
|
|
}),
|
|
],
|
|
totalDebit: 200000,
|
|
totalCredit: 200000,
|
|
isBalanced: true,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(makeIs())
|
|
|
|
const report = await generateKassaflodesanalys(
|
|
makeSupabase(PERIOD),
|
|
'company-1',
|
|
'period-1'
|
|
)
|
|
|
|
expect(report.investerings.forvarv_anlaggningar).toBe(-200000)
|
|
expect(report.investerings.avyttring_anlaggningar).toBe(0)
|
|
expect(report.investerings.total).toBe(-200000)
|
|
expect(report.reconciliation.delta_actual).toBe(-200000)
|
|
expect(report.reconciliation.delta_calculated).toBe(-200000)
|
|
expect(report.reconciliation.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('records loan increase (500 000 kr) as financing inflow', async () => {
|
|
// Setup: take out a 500 000 long-term loan: debit 1930, credit 2350.
|
|
// 1930 (bank): debit 500 000 → closing +500 000
|
|
// 2350 (långfristiga lån): credit 500 000 → closing -500 000 on debit side
|
|
//
|
|
// Expected:
|
|
// delta_lan = 500 000 (loans went up, cash in)
|
|
// delta_actual = 500 000
|
|
// is_reconciled = true
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [
|
|
makeRow({
|
|
account_number: '1930',
|
|
account_class: 1,
|
|
period_debit: 500000,
|
|
closing_debit: 500000,
|
|
}),
|
|
makeRow({
|
|
account_number: '2350',
|
|
account_class: 2,
|
|
period_credit: 500000,
|
|
closing_credit: 500000,
|
|
}),
|
|
],
|
|
totalDebit: 500000,
|
|
totalCredit: 500000,
|
|
isBalanced: true,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(makeIs())
|
|
|
|
const report = await generateKassaflodesanalys(
|
|
makeSupabase(PERIOD),
|
|
'company-1',
|
|
'period-1'
|
|
)
|
|
|
|
expect(report.finansierings.delta_lan).toBe(500000)
|
|
expect(report.finansierings.total).toBe(500000)
|
|
expect(report.reconciliation.delta_actual).toBe(500000)
|
|
expect(report.reconciliation.delta_calculated).toBe(500000)
|
|
expect(report.reconciliation.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('detects mismatch when a cash movement has no balancing classification', async () => {
|
|
// Plant an invariant violation: 1930 went up by 10 000 but no offsetting
|
|
// entry on any tracked account class. This is the kind of bug a real
|
|
// bookkeeping error would surface as.
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [
|
|
makeRow({
|
|
account_number: '1930',
|
|
account_class: 1,
|
|
period_debit: 10000,
|
|
closing_debit: 10000,
|
|
}),
|
|
// The "offset" is in account 9999 (out-of-range). The cash flow
|
|
// generator doesn't see it. is_reconciled must flag false.
|
|
makeRow({
|
|
account_number: '9999',
|
|
account_class: 9,
|
|
period_credit: 10000,
|
|
closing_credit: 10000,
|
|
}),
|
|
],
|
|
totalDebit: 10000,
|
|
totalCredit: 10000,
|
|
isBalanced: true,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(makeIs())
|
|
|
|
const report = await generateKassaflodesanalys(
|
|
makeSupabase(PERIOD),
|
|
'company-1',
|
|
'period-1'
|
|
)
|
|
|
|
expect(report.reconciliation.delta_actual).toBe(10000)
|
|
expect(report.reconciliation.delta_calculated).toBe(0)
|
|
expect(report.reconciliation.mismatch_amount).toBe(10000)
|
|
expect(report.reconciliation.is_reconciled).toBe(false)
|
|
})
|
|
|
|
it('throws when fiscal period is not found', async () => {
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [],
|
|
totalDebit: 0,
|
|
totalCredit: 0,
|
|
isBalanced: true,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(makeIs())
|
|
|
|
await expect(
|
|
generateKassaflodesanalys(makeSupabase(null), 'company-1', 'period-1')
|
|
).rejects.toThrow('Fiscal period not found')
|
|
})
|
|
|
|
it('uses Math.round for monetary precision (no toFixed)', async () => {
|
|
// Plant fractional cents in the inputs; result must be rounded to 2dp,
|
|
// never via toFixed which would return a string.
|
|
mockTrialBalance.mockResolvedValue({
|
|
rows: [
|
|
makeRow({
|
|
account_number: '1930',
|
|
account_class: 1,
|
|
period_debit: 33.337,
|
|
closing_debit: 33.337,
|
|
}),
|
|
makeRow({
|
|
account_number: '7832',
|
|
account_class: 7,
|
|
period_debit: 33.337,
|
|
closing_debit: 33.337,
|
|
}),
|
|
],
|
|
totalDebit: 66.674,
|
|
totalCredit: 0,
|
|
isBalanced: false,
|
|
})
|
|
mockIncomeStatement.mockResolvedValue(
|
|
makeIs({ total_expenses: 33.337, net_result: -33.337 })
|
|
)
|
|
|
|
const report = await generateKassaflodesanalys(
|
|
makeSupabase(PERIOD),
|
|
'company-1',
|
|
'period-1'
|
|
)
|
|
|
|
// resultat = revenue - expenses + non-tax-financial = 0 - 33.337 + 0 = -33.34
|
|
expect(report.lopande.resultat_efter_finansiella_poster).toBe(-33.34)
|
|
expect(report.lopande.avskrivningar).toBe(33.34)
|
|
// No bare toFixed return values — these must be numbers, not strings.
|
|
expect(typeof report.lopande.total).toBe('number')
|
|
})
|
|
})
|