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:
co-authored by
Claude Opus 4.7
parent
0087b7be3f
commit
32d9978f1b
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user