Files
accounted/lib/reports/__tests__/continuity-check.test.ts
T
MattssonandClaude Opus 4.7 32d9978f1b Fix/chrome pdf preview csp (#572)
* 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>
2026-05-26 22:29:41 +02:00

295 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock Supabase — table-keyed result queues
// ============================================================
type MockResult = { data?: unknown; error?: unknown; count?: number }
let mockResults: Record<string, MockResult[]>
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'range', 'update']) {
b[m] = vi.fn().mockReturnValue(b)
}
const consume = (): MockResult => {
const queue = mockResults[tableName]
if (!queue || queue.length === 0) return { data: null, error: null }
return queue.shift()!
}
b.single = vi.fn().mockImplementation(async () => consume())
b.then = (resolve: (v: unknown) => void) => resolve(consume())
return b
}
function makeClient() {
const rpc = vi.fn().mockImplementation(async (fn: string) => {
const queue = mockResults[`rpc:${fn}`]
if (!queue || queue.length === 0) return { data: [], error: null }
return queue.shift()!
})
return {
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
rpc,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
import { validateBalanceContinuity } from '../continuity-check'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
mockResults = {}
supabase = makeClient()
})
describe('validateBalanceContinuity', () => {
it('returns valid for first period (no previous_period_id)', async () => {
mockResults = {
fiscal_periods: [
{ data: { id: 'p1', name: 'FY2024', period_start: '2024-01-01', previous_period_id: null, opening_balance_entry_id: null } },
],
}
const result = await validateBalanceContinuity(supabase, 'company-1', 'p1')
expect(result.valid).toBe(true)
expect(result.discrepancies).toEqual([])
expect(result.checked_accounts).toBe(0)
expect(result.previous_period_name).toBeNull()
})
it('returns valid when IB matches UB', async () => {
mockResults = {
fiscal_periods: [
// Target period
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
// Previous period (for name)
{ data: { id: 'p1', name: 'FY2024' } },
// Previous period (for generateTrialBalance)
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
],
journal_entry_lines: [
// Previous period lines (trial balance — prior OB comes from RPC, defaults empty)
{
data: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 30000 },
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
],
},
// Current period OB entry lines (getOpeningBalances)
{
data: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 30000 },
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
],
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar', account_class: 1 },
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
],
},
],
}
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
expect(result.valid).toBe(true)
expect(result.discrepancies).toEqual([])
expect(result.checked_accounts).toBe(3)
expect(result.period_name).toBe('FY2025')
expect(result.previous_period_name).toBe('FY2024')
})
it('detects discrepancy in one account', async () => {
mockResults = {
fiscal_periods: [
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
{ data: { id: 'p1', name: 'FY2024' } },
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
],
journal_entry_lines: [
// Previous UB: 1930 = 50000 debit
{
data: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
],
},
// Current IB: 1930 = 49000 debit (mismatch!)
{
data: [
{ account_number: '1930', debit_amount: 49000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
],
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
],
},
],
}
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
expect(result.valid).toBe(false)
expect(result.discrepancies).toHaveLength(1)
expect(result.discrepancies[0].account_number).toBe('1930')
expect(result.discrepancies[0].previous_ub_net).toBe(50000)
expect(result.discrepancies[0].current_ib_net).toBe(49000)
expect(result.discrepancies[0].difference).toBe(1000)
})
it('detects account present in UB but not IB', async () => {
mockResults = {
fiscal_periods: [
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
{ data: { id: 'p1', name: 'FY2024' } },
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
],
journal_entry_lines: [
// Previous UB has 1510 and 2440
{
data: [
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
{ account_number: '2440', debit_amount: 0, credit_amount: 10000 },
],
},
// Current IB only has 1510 (2440 missing)
{
data: [
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
],
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar', account_class: 1 },
{ account_number: '2440', account_name: 'Leverantörsskulder', account_class: 2 },
],
},
],
}
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
expect(result.valid).toBe(false)
expect(result.discrepancies).toHaveLength(1)
expect(result.discrepancies[0].account_number).toBe('2440')
expect(result.discrepancies[0].previous_ub_net).toBe(-10000)
expect(result.discrepancies[0].current_ib_net).toBe(0)
})
it('detects account present in IB but not UB', async () => {
mockResults = {
fiscal_periods: [
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
{ data: { id: 'p1', name: 'FY2024' } },
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
],
journal_entry_lines: [
// Previous UB: only 1930
{
data: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
],
},
// Current IB: 1930 + 1510 (1510 shouldn't be here)
{
data: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 5000, credit_amount: 0 },
],
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
],
},
],
}
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
expect(result.valid).toBe(false)
expect(result.discrepancies).toHaveLength(1)
expect(result.discrepancies[0].account_number).toBe('1510')
expect(result.discrepancies[0].previous_ub_net).toBe(0)
expect(result.discrepancies[0].current_ib_net).toBe(5000)
})
it('treats sub-öre float drift as valid (ORE_TOLERANCE = 0.005 SEK)', async () => {
mockResults = {
fiscal_periods: [
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
{ data: { id: 'p1', name: 'FY2024' } },
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
],
journal_entry_lines: [
{
data: [
{ account_number: '1930', debit_amount: 50000.001, credit_amount: 0 },
],
},
{
data: [
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
],
},
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
],
},
],
}
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
expect(result.valid).toBe(true)
expect(result.discrepancies).toEqual([])
})
it('throws when period not found', async () => {
mockResults = {
fiscal_periods: [
{ data: null, error: { message: 'not found' } },
],
}
await expect(
validateBalanceContinuity(supabase, 'company-1', 'nonexistent')
).rejects.toThrow('Fiscal period not found')
})
it('throws when previous period not found', async () => {
mockResults = {
fiscal_periods: [
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: null } },
{ data: null, error: { message: 'not found' } },
],
}
await expect(
validateBalanceContinuity(supabase, 'company-1', 'p2')
).rejects.toThrow('Previous fiscal period not found')
})
})