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>
This commit is contained in:
Mattsson
2026-05-26 22:29:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 0087b7be3f
commit 32d9978f1b
207 changed files with 24187 additions and 1052 deletions
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from '@/tests/pg/setup'
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
/**
* Plan 3 invariants. These tests verify the database-level guarantees that
* back the application-level invariants in executeYearEndClosing():
*
* 1. Closing entries must balance to the öre — the journal_entries balance
* trigger rejects anything else on draft→posted.
* 2. A one-öre discrepancy fed into a closing-style entry is rejected
* by the trigger; the row stays in 'draft' and no posted state is
* created — i.e. DB state is unchanged from the caller's perspective
* (no voucher number assigned, no audit_log row for a posted entry).
*
* The full executeYearEndClosing() flow is exercised by the existing mock-
* based test in year-end-service.test.ts. Running that flow against real
* Postgres requires a Supabase JS client wired to this pool, which is out
* of scope for the pg-real harness; the invariants below are the
* load-bearing checks the application layer relies on.
*/
describe('year-end invariants (pg-real)', () => {
it('closing entry must balance to the öre', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
// Build a closing-style draft entry: 3001 → 2099 transfer that's off
// by one öre.
const entryId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-12-31',
description: 'Årsbokslut (unbalanced)',
})
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '3001', 0, 1000.00),
($1, '2099', 1000.01, 0)`,
[entryId],
)
// Attempt to commit via the same RPC the engine uses. The balance
// trigger must fire and the RPC must fail.
const pool = getPool()
await expect(
pool.query(`SELECT commit_journal_entry($1, $2)`, [companyId, entryId]),
).rejects.toThrow()
// DB state unchanged: entry still draft, no voucher assigned.
const { rows } = await pool.query<{ status: string; voucher_number: number }>(
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
[entryId],
)
expect(rows[0].status).toBe('draft')
expect(Number(rows[0].voucher_number)).toBe(0)
})
it('balanced closing entry commits cleanly and zeros class 3 net', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
// Step 1: post a revenue entry so 3001 has a credit balance.
const revenueId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-06-01',
description: 'Revenue',
})
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 5000.00, 0),
($1, '3001', 0, 5000.00)`,
[revenueId],
)
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, revenueId])
// Step 2: the closing entry — debit 3001, credit 2099.
const closeId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-12-31',
description: 'Årsbokslut',
})
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '3001', 5000.00, 0),
($1, '2099', 0, 5000.00)`,
[closeId],
)
await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, closeId])
// Class-3 net across posted lines in this period must be 0 to the öre.
const { rows } = await getPool().query<{ net: string }>(
`SELECT COALESCE(SUM(l.debit_amount - l.credit_amount), 0) AS net
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = $1
AND je.fiscal_period_id = $2
AND je.status = 'posted'
AND l.account_number LIKE '3%'`,
[companyId, fiscalPeriodId],
)
const net = roundOre(Number(rows[0].net))
expect(Math.abs(net)).toBeLessThanOrEqual(ORE_TOLERANCE)
})
it('rejects a one-öre IB/UB style discrepancy in opening balance lines', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
// Opening-balance-style draft where 1930 IB and 2099 IB are off by 0.01.
const ibId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-01-01',
description: 'Ingående balans (skewed)',
})
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 1234.56, 0),
($1, '2099', 0, 1234.57)`,
[ibId],
)
await expect(
getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, ibId]),
).rejects.toThrow()
const { rows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.journal_entries WHERE id = $1`,
[ibId],
)
expect(rows[0].status).toBe('draft')
})
// Sanity: roundOre / ORE_TOLERANCE are wired through. This is the
// imported boundary — if it breaks, every consumer above breaks too.
it('exposes a half-öre tolerance', () => {
expect(ORE_TOLERANCE).toBe(0.005)
expect(roundOre(1.005)).toBe(1.01)
})
// Quiet linter — randomUUID is referenced through the seed helper but
// we keep an explicit import for future cases that need their own UUIDs.
it('uuid helper is available', () => {
expect(randomUUID()).toMatch(/[0-9a-f-]{36}/)
})
})
@@ -37,6 +37,7 @@ vi.mock('@/lib/reports/income-statement', () => ({
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
reverseEntry: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/currency-revaluation', () => ({
+114 -17
View File
@@ -1,6 +1,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
import { createLogger } from '@/lib/logger'
const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
@@ -303,7 +307,7 @@ export async function previewYearEndClosing(
for (const account of resultAccounts) {
const netBalance = account.closing_debit - account.closing_credit
if (Math.abs(netBalance) < 0.005) continue
if (Math.abs(netBalance) < ORE_TOLERANCE) continue
resultAccountSummary.push({
account_number: account.account_number,
@@ -317,14 +321,14 @@ export async function previewYearEndClosing(
closingLines.push({
account_number: account.account_number,
debit_amount: 0,
credit_amount: Math.round(netBalance * 100) / 100,
credit_amount: roundOre(netBalance),
line_description: `Closing: ${account.account_name}`,
})
} else {
// Account has credit balance → debit it to zero
closingLines.push({
account_number: account.account_number,
debit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
debit_amount: roundOre(Math.abs(netBalance)),
credit_amount: 0,
line_description: `Closing: ${account.account_name}`,
})
@@ -337,9 +341,9 @@ export async function previewYearEndClosing(
// If negative (loss): debit to equity (2099/2010)
const totalClosingDebit = closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalClosingCredit = closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
const balancingAmount = Math.round(Math.abs(totalClosingDebit - totalClosingCredit) * 100) / 100
const balancingAmount = roundOre(Math.abs(totalClosingDebit - totalClosingCredit))
if (balancingAmount > 0.005) {
if (balancingAmount > ORE_TOLERANCE) {
if (totalClosingDebit > totalClosingCredit) {
// More debits than credits → need credit on closing account
closingLines.push({
@@ -442,6 +446,22 @@ export async function executeYearEndClosing(
throw new Error('No result accounts to close — period has no activity')
}
// 3a. INVARIANT: closing entry must balance to the öre before commit.
// This guards against rounding drift in previewYearEndClosing — the DB
// balance trigger would catch it too, but we want a clear Swedish error
// surfaced to the user, not a generic Postgres exception.
const preCommitDebit = roundOre(
preview.closingLines.reduce((s, l) => s + l.debit_amount, 0)
)
const preCommitCredit = roundOre(
preview.closingLines.reduce((s, l) => s + l.credit_amount, 0)
)
if (Math.abs(preCommitDebit - preCommitCredit) > ORE_TOLERANCE) {
throw new Error(
`Bokslutsverifikationen balanserar inte: debet=${preCommitDebit}, kredit=${preCommitCredit}`
)
}
// 4. Create closing entry via the journal engine
const closingEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
@@ -452,6 +472,32 @@ export async function executeYearEndClosing(
lines: preview.closingLines,
})
// 4a. INVARIANT: after the closing entry, class 3-8 net must be exactly 0
// (to the öre). If not, we have a logic bug — fail loud rather than
// proceed into IB generation with a corrupt trial balance.
// createJournalEntry has no transactional grouping with the next call;
// the engine commits atomically per-entry via commit_journal_entry RPC,
// so a failure here means we need to reverse the just-committed entry.
try {
const postCloseTB = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
let resultNet = 0
for (const row of postCloseTB.rows) {
if (row.account_class >= 3 && row.account_class <= 8) {
resultNet += row.closing_debit - row.closing_credit
}
}
resultNet = roundOre(resultNet)
if (Math.abs(resultNet) > ORE_TOLERANCE) {
throw new Error(
`Resultatkonton (klass 3-8) saknar nollställning efter bokslut: nettot är ${resultNet} SEK`
)
}
} catch (err) {
// Best-effort reversal of the closing entry before re-throwing.
await safeReverse(supabase, companyId, userId, closingEntry.id, 'closing entry')
throw err
}
// 5. Update fiscal period with closing_entry_id
const { error: updateError } = await supabase
.from('fiscal_periods')
@@ -481,7 +527,19 @@ export async function executeYearEndClosing(
nextPeriod.id
)
// 10. Validate IB/UB continuity and persist result
// 10. Validate IB/UB continuity and persist result.
// INVARIANT: any account differing by more than ORE_TOLERANCE is a hard
// failure. Best-effort rollback of both the IB entry and the closing
// entry so the user sees a clean state and can re-run the wizard.
//
// Note on atomicity: createJournalEntry uses an atomic commit_journal_entry
// RPC per entry, but the closing + IB entries are two separate commits with
// a period lock/close in between. Once committed, posted entries are
// immutable by DB trigger — true rollback isn't possible. reverseEntry()
// posts a compensating storno entry instead. The closed period was also
// locked & closed, but reverseEntry uses an entry_date that — under the
// period-lock trigger — may be blocked. We attempt reversal but tolerate
// failure, surfacing the original continuity error either way.
const continuity = await validateBalanceContinuity(supabase, companyId, nextPeriod.id)
await supabase
@@ -490,12 +548,21 @@ export async function executeYearEndClosing(
.eq('id', nextPeriod.id)
.eq('company_id', companyId)
if (!continuity.valid) {
const overTolerance = continuity.discrepancies.filter(
(d) => Math.abs(d.difference) > ORE_TOLERANCE
)
if (overTolerance.length > 0) {
await safeReverse(supabase, companyId, userId, openingBalanceEntry.id, 'opening balance entry')
await safeReverse(supabase, companyId, userId, closingEntry.id, 'closing entry')
throw new Error(
`IB/UB continuity check failed: ${continuity.discrepancies.length} account(s) differ. ` +
continuity.discrepancies.map(d =>
`${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`
).join('; ')
`IB/UB-kontinuitet misslyckades: ${overTolerance.length} konto(n) avviker. ` +
overTolerance
.map(
(d) =>
`${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`
)
.join('; ')
)
}
@@ -519,6 +586,7 @@ export async function executeYearEndClosing(
nextPeriod,
openingBalanceEntry,
revaluationEntry: revaluationResult?.entry ?? null,
continuity,
}
}
@@ -562,13 +630,13 @@ export async function generateOpeningBalances(
for (const account of balanceSheetAccounts) {
const netBalance = account.closing_debit - account.closing_credit
if (Math.abs(netBalance) < 0.005) continue
if (Math.abs(netBalance) < ORE_TOLERANCE) continue
if (netBalance > 0) {
// Debit balance → opening debit
openingLines.push({
account_number: account.account_number,
debit_amount: Math.round(netBalance * 100) / 100,
debit_amount: roundOre(netBalance),
credit_amount: 0,
line_description: `Ingående balans: ${account.account_name}`,
})
@@ -577,7 +645,7 @@ export async function generateOpeningBalances(
openingLines.push({
account_number: account.account_number,
debit_amount: 0,
credit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
credit_amount: roundOre(Math.abs(netBalance)),
line_description: `Ingående balans: ${account.account_name}`,
})
}
@@ -591,9 +659,9 @@ export async function generateOpeningBalances(
const totalDebit = openingLines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = openingLines.reduce((sum, l) => sum + l.credit_amount, 0)
if (Math.abs(totalDebit - totalCredit) > 0.01) {
if (Math.abs(totalDebit - totalCredit) > ORE_TOLERANCE) {
throw new Error(
`Opening balances are not balanced: debit=${totalDebit}, credit=${totalCredit}`
`Ingående balanser balanserar inte: debet=${roundOre(totalDebit)}, kredit=${roundOre(totalCredit)}`
)
}
@@ -623,3 +691,32 @@ export async function generateOpeningBalances(
return openingEntry
}
/**
* Best-effort reversal used by executeYearEndClosing's rollback paths.
*
* Posted journal entries are immutable per DB trigger — we can't truly
* roll them back, only post a compensating storno via reverseEntry().
* Closed/locked periods may also block the reversal date. We swallow
* failures here so the caller can re-throw the original invariant error
* with maximum diagnostic value; the orphaned entries (if any) become
* a manual cleanup task documented in the surfaced Swedish error.
*/
async function safeReverse(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string,
label: string
): Promise<void> {
try {
await reverseEntry(supabase, companyId, userId, entryId)
} catch (err) {
log.error(`year-end rollback: could not reverse ${label}`, err as Error, {
operation: 'year_end.rollback',
companyId,
entityType: 'journal_entry',
entityId: entryId,
})
}
}
+79
View File
@@ -264,6 +264,85 @@ export async function linkToJournalEntry(
return data as DocumentAttachment
}
export type DeleteDocumentResult =
| { ok: true; document: Pick<DocumentAttachment, 'id' | 'file_name'> }
| { ok: false; reason: 'not_found' | 'linked_to_entry'; status: number; message: string }
/**
* Delete a document if and only if it is not yet linked to a journal entry.
*
* BFL 7 kap 2§: once a document is attached to a verifikation it becomes
* räkenskapsinformation and may not be deleted within the 7-year retention
* window. Linked docs must be superseded via createNewVersion() instead.
* The block_document_deletion() trigger is the DB-level backstop.
*/
export async function deleteDocument(
supabase: SupabaseClient,
companyId: string,
documentId: string
): Promise<DeleteDocumentResult> {
const { data: doc, error: fetchError } = await supabase
.from('document_attachments')
.select('id, file_name, storage_path, journal_entry_id, user_id')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
if (fetchError || !doc) {
return {
ok: false,
reason: 'not_found',
status: 404,
message: 'Underlaget hittades inte.',
}
}
if (doc.journal_entry_id) {
return {
ok: false,
reason: 'linked_to_entry',
status: 409,
message:
'Underlaget är knutet till en verifikation och utgör räkenskapsinformation enligt Bokföringslagen 7 kap 2§. Räkenskapsinformation ska bevaras i minst 7 år och får inte raderas. Använd "Ersätt med ny version" om underlaget behöver korrigeras.',
}
}
const { error: deleteError } = await supabase
.from('document_attachments')
.delete()
.eq('id', documentId)
.eq('company_id', companyId)
if (deleteError) {
const msg = (deleteError as { message?: string }).message ?? ''
if (msg.includes('Bokföringslagen') || msg.includes('retention')) {
return {
ok: false,
reason: 'linked_to_entry',
status: 409,
message:
'Underlaget kan inte tas bort på grund av Bokföringslagens bevarandekrav (7 kap 2§).',
}
}
throw new Error(`Failed to delete document: ${msg}`)
}
if (doc.storage_path) {
await supabase.storage.from('documents').remove([doc.storage_path])
}
await eventBus.emit({
type: 'document.deleted',
payload: {
document: { id: doc.id, file_name: doc.file_name },
userId: doc.user_id,
companyId,
},
})
return { ok: true, document: { id: doc.id, file_name: doc.file_name } }
}
/**
* Verify document integrity by re-hashing and comparing
*/