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,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockCreateClient(),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockBuildAccrualsProposal = vi.fn()
const mockDetectPeriodisering = vi.fn()
vi.mock('@/lib/bokslut/accruals/accrual-detector', async () => {
const actual =
(await vi.importActual('@/lib/bokslut/accruals/accrual-detector')) as Record<string, unknown>
return {
...actual,
buildAccrualsProposal: (...args: unknown[]) => mockBuildAccrualsProposal(...args),
}
})
vi.mock('@/lib/bokslut/accruals/auto-detect', () => ({
detectPeriodisering: (...args: unknown[]) => mockDetectPeriodisering(...args),
}))
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
})
})
describe('GET /api/bookkeeping/fiscal-periods/[id]/accruals', () => {
it('returns 401 when unauthenticated', async () => {
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
})
const { GET } = await import('../route')
const res = await GET(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
createMockRouteParams({ id: 'period-1' }),
)
expect(res.status).toBe(401)
})
it('returns the snapshot plus autoDetected suggestions', async () => {
mockBuildAccrualsProposal.mockResolvedValue({
fiscalPeriod: { id: 'period-1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
proposals: [],
})
mockDetectPeriodisering.mockResolvedValue([
{
source_invoice_id: 'sup-1',
source_type: 'supplier_invoice',
original_amount: 12000,
periodisering_amount: 6000,
parsed_start: '2025-07-01',
parsed_end: '2026-06-30',
confidence: 'high',
reason: 'Mock reason',
source_label: 'Test Supplier',
suggested_prepaid_account: '1710',
suggested_deferred_account: null,
},
])
const { GET } = await import('../route')
const res = await GET(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
createMockRouteParams({ id: 'period-1' }),
)
const { status, body } = await parseJsonResponse<{ data: { autoDetected: unknown[] } }>(res)
expect(status).toBe(200)
expect(body.data.autoDetected).toHaveLength(1)
})
it('still returns the snapshot when auto-detect throws', async () => {
mockBuildAccrualsProposal.mockResolvedValue({
fiscalPeriod: { id: 'period-1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
proposals: [],
})
mockDetectPeriodisering.mockRejectedValue(new Error('boom'))
const { GET } = await import('../route')
const res = await GET(
createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
createMockRouteParams({ id: 'period-1' }),
)
const { status, body } = await parseJsonResponse<{ data: { autoDetected: unknown[] } }>(res)
expect(status).toBe(200)
expect(body.data.autoDetected).toEqual([])
})
})
@@ -6,11 +6,15 @@ import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
buildAccrualsProposal,
proposeAccruedInterest,
proposeAccruedUtility,
proposeAuditFee,
proposeManualAccrued,
proposeManualPrepaid,
proposeRevenueDeferral,
proposeVacationLiabilityChange,
} from '@/lib/bokslut/accruals/accrual-detector'
import { detectPeriodisering } from '@/lib/bokslut/accruals/auto-detect'
import type { AccrualProposal } from '@/lib/bokslut/accruals/types'
import type { JournalEntry } from '@/types'
@@ -20,8 +24,18 @@ export const GET = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const proposal = await buildAccrualsProposal(supabase, companyId, id)
return NextResponse.json({ data: proposal })
// Run the two independent scans in parallel so the wizard's first
// paint isn't gated on the slower auto-detect query.
const [proposal, autoDetected] = await Promise.all([
buildAccrualsProposal(supabase, companyId, id),
detectPeriodisering(supabase, companyId, id).catch((err) => {
// Auto-detect is best-effort — a malformed invoice description
// shouldn't break the rest of the preflight. Log + return empty.
log.warn('auto-detect failed', { error: (err as Error)?.message })
return []
}),
])
return NextResponse.json({ data: { ...proposal, autoDetected } })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
@@ -32,6 +46,19 @@ export const GET = withRouteContext(
},
)
// Defense-in-depth on caller-supplied account numbers. The wizard sends
// accounts from a closed template list, but the API accepts them as plain
// strings so we constrain the BAS class per accrual kind:
// - cost accounts (5xxx-8xxx) for expense legs
// - revenue accounts (3xxx) for revenue legs
// - 17xx for förutbetalda kostnader (prepaid)
// - 29xx for upplupna poster (accrued / deferred)
// Anything outside these ranges is rejected with 400 before reaching the
// engine — keeps a compromised browser session from posting arbitrary
// balance-sheet hits.
const EXPENSE_ACCOUNT_RE = /^[5-8]\d{3}$/
const REVENUE_ACCOUNT_RE = /^3\d{3}$/
const PostItemSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('vacation_liability_change') }),
z.object({
@@ -42,14 +69,35 @@ const PostItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('manual_prepaid_expense'),
amount: z.number().positive(),
expense_account: z.string().regex(/^\d{4}$/),
expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
prepaid_account: z.string().regex(/^17\d{2}$/),
description: z.string().min(1),
}),
z.object({
kind: z.literal('manual_accrued_expense'),
amount: z.number().positive(),
expense_account: z.string().regex(/^\d{4}$/),
expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
accrued_account: z.string().regex(/^29\d{2}$/),
description: z.string().min(1),
}),
z.object({
kind: z.literal('deferred_revenue'),
amount: z.number().positive(),
revenue_account: z.string().regex(REVENUE_ACCOUNT_RE),
deferred_account: z.string().regex(/^29\d{2}$/),
description: z.string().min(1),
}),
z.object({
kind: z.literal('accrued_interest'),
amount: z.number().positive(),
expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
accrued_account: z.string().regex(/^29\d{2}$/),
description: z.string().min(1),
}),
z.object({
kind: z.literal('accrued_utility'),
amount: z.number().positive(),
expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
accrued_account: z.string().regex(/^29\d{2}$/),
description: z.string().min(1),
}),
@@ -132,6 +180,33 @@ export const POST = withRouteContext(
closingDate: period.period_end,
})
break
case 'deferred_revenue':
proposal = proposeRevenueDeferral({
amount: item.amount,
revenueAccount: item.revenue_account,
deferredAccount: item.deferred_account,
description: item.description,
closingDate: period.period_end,
})
break
case 'accrued_interest':
proposal = proposeAccruedInterest({
amount: item.amount,
expenseAccount: item.expense_account,
accruedAccount: item.accrued_account,
description: item.description,
closingDate: period.period_end,
})
break
case 'accrued_utility':
proposal = proposeAccruedUtility({
amount: item.amount,
expenseAccount: item.expense_account,
accruedAccount: item.accrued_account,
description: item.description,
closingDate: period.period_end,
})
break
}
if (!proposal) continue
@@ -199,6 +274,15 @@ async function findExistingAccrualEntry(
case 'manual_accrued_expense':
pattern = `Periodisering: Upplupen kostnad: ${escapeLike(item.description)}%`
break
case 'deferred_revenue':
pattern = `Periodisering: Förutbetald intäkt: ${escapeLike(item.description)}%`
break
case 'accrued_interest':
pattern = `Periodisering: Upplupen ränta: ${escapeLike(item.description)}%`
break
case 'accrued_utility':
pattern = `Periodisering: Upplupen förbrukning: ${escapeLike(item.description)}%`
break
}
const { data } = await supabase
@@ -3,6 +3,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-pdf'
import { ArsredovisningK3PDF } from '@/lib/bokslut/arsredovisning/arsredovisning-k3-pdf'
export const GET = withRouteContext(
'period.arsredovisning_pdf',
@@ -14,7 +15,15 @@ export const GET = withRouteContext(
// inside buildArsredovisningData. The URL stays clean — no narrative
// text in query params, access logs, or browser history.
const data = await buildArsredovisningData(supabase, companyId, id)
const pdfBuffer = await renderToBuffer(ArsredovisningPDF({ data }))
// Dispatch on the framework recorded in the data. K3 documents need
// the additional kassaflöde + equity-changes pages + richer noter
// that ArsredovisningK3PDF renders. K2 (the default) keeps the
// existing template byte-for-byte unchanged.
const PdfComponent =
data.accounting_framework === 'k3'
? ArsredovisningK3PDF
: ArsredovisningPDF
const pdfBuffer = await renderToBuffer(PdfComponent({ data }))
// "-utkast" suffix mirrors the existing PDF routes; the file becomes
// "fastställd" only after the signature flow records all signatures.
// Sanitize the dynamic segment so a stray quote / newline in the date
@@ -13,7 +13,10 @@ import {
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder'
import {
buildDispositionsProposal,
buildLatentTaxProposal,
} from '@/lib/bokslut/dispositions-proposal-builder'
import type { ProposedDisposition } from '@/lib/bokslut/types'
import type { JournalEntry } from '@/types'
@@ -45,6 +48,9 @@ const DISPOSITION_ORDER: Record<string, number> = {
periodiseringsfond_avsattning: 2,
sarskild_loneskatt: 3,
bolagsskatt: 4,
// K3 only — posts last because it depends on the closing 21xx balance,
// which only stabilises once avsättning / återföring have been applied.
uppskjuten_skatt: 5,
}
// ============================================================
@@ -112,6 +118,11 @@ const ItemSchema = z.discriminatedUnion('kind', [
.enum(['machinery_equipment', 'building', 'immaterial', 'group'])
.optional(),
}),
// K3 only — uppskjuten skatt provision. Server recomputes the amount from
// current 2240 + 21xx state so the client cannot override it.
z.object({
kind: z.literal('uppskjuten_skatt'),
}),
])
const PostBodySchema = z.object({
@@ -260,6 +271,15 @@ async function computeProposal(
additionalAmount: item.additionalAmount,
category: item.category,
})
case 'uppskjuten_skatt':
// Server-only: recompute from current TB (which already reflects any
// 21xx postings that committed earlier in this batch). The client
// sends no amount — the calculator owns the K3 split.
return buildLatentTaxProposal({
supabase,
companyId,
fiscalPeriodId,
})
}
}
+20 -2
View File
@@ -28,6 +28,10 @@ export async function GET(request: Request) {
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
// 'series' optional filter — single uppercase letter A–Z. Ignored if any
// other value is passed (defense against trivial injection / typos).
const seriesRaw = searchParams.get('series')
const seriesFilter = seriesRaw && /^[A-Z]$/.test(seriesRaw) ? seriesRaw : null
// 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc'
// sort_by overrides sort_date when present. sort_date is kept for backwards
// compatibility with older clients.
@@ -69,8 +73,18 @@ export async function GET(request: Request) {
}
const rows = data ?? []
const entries = rows.map((r: { entry: unknown }) => r.entry)
const count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
let entries = rows.map((r: { entry: unknown }) => r.entry) as Array<{ voucher_series?: string }>
let count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
// The list_fiscal_period_entries_with_related RPC doesn't accept a series
// filter, so post-filter here. Recompute count from the filtered set so
// the paginator stays consistent; consequence: when a series filter is
// applied, the cross-period follow-up surfacing is still on but the
// visible total drops to the matching subset.
if (seriesFilter) {
entries = entries.filter((e) => (e?.voucher_series ?? 'A') === seriesFilter)
count = entries.length
}
return NextResponse.json({ data: entries, count })
}
@@ -115,6 +129,10 @@ export async function GET(request: Request) {
query = query.lte('entry_date', dateTo)
}
if (seriesFilter) {
query = query.eq('voucher_series', seriesFilter)
}
const { data, error, count } = await query
if (error) {