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
@@ -5,15 +5,87 @@ import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { disposeAsset } from '@/lib/bokslut/assets/asset-service'
|
||||
|
||||
const DisposeAssetSchema = z.object({
|
||||
disposed_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
disposed_proceeds: z.number().nonnegative(),
|
||||
proceeds_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
fiscal_period_id: z.string().uuid(),
|
||||
// accumulated_depreciation is intentionally NOT accepted from the client —
|
||||
// disposeAsset sums depreciation_schedules server-side so callers cannot
|
||||
// inflate the book-value calculation.
|
||||
})
|
||||
const VAT_TREATMENTS = [
|
||||
'standard_25',
|
||||
'reduced_12',
|
||||
'reduced_6',
|
||||
'reverse_charge',
|
||||
'export',
|
||||
'exempt',
|
||||
] as const
|
||||
|
||||
const DisposeAssetSchema = z
|
||||
.object({
|
||||
disposed_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
/** Gross proceeds (INCL VAT when applicable). */
|
||||
disposed_proceeds: z.number().nonnegative(),
|
||||
proceeds_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
fiscal_period_id: z.string().uuid(),
|
||||
/** Output VAT on the proceeds. Defaults to 0 (sale was momsfri). */
|
||||
proceeds_vat: z.number().nonnegative().optional(),
|
||||
/** Required when proceeds_vat > 0 so the engine can resolve a 26xx account. */
|
||||
vat_treatment: z.enum(VAT_TREATMENTS).optional(),
|
||||
/** Precomputed jämkning amount (ML 8a kap 7 §). Caller supplies; engine
|
||||
* books a 2641 credit + loss-account debit. */
|
||||
jamkning_amount: z.number().nonnegative().optional(),
|
||||
/** Audit metadata. */
|
||||
jamkning_remaining_months: z.number().int().nonnegative().optional(),
|
||||
jamkning_total_months: z.number().int().positive().optional(),
|
||||
jamkning_original_input_vat: z.number().nonnegative().optional(),
|
||||
// accumulated_depreciation is intentionally NOT accepted from the client —
|
||||
// disposeAsset sums depreciation_schedules server-side so callers cannot
|
||||
// inflate the book-value calculation.
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
// VAT consistency: if a treatment that produces a VAT line is selected,
|
||||
// the VAT amount must equal 25%/12%/6% of the net proceeds. Tolerance is
|
||||
// ±0.50 kr to handle rounding on item prices.
|
||||
if (value.proceeds_vat && value.proceeds_vat > 0) {
|
||||
if (!value.vat_treatment) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['vat_treatment'],
|
||||
message: 'vat_treatment krävs när proceeds_vat > 0.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const rate = vatRateFromTreatment(value.vat_treatment)
|
||||
if (rate === null) {
|
||||
// Treatments without a VAT line must carry 0 VAT.
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['proceeds_vat'],
|
||||
message: `proceeds_vat måste vara 0 för momsbehandling "${value.vat_treatment}".`,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Expected: proceeds_gross = net × (1 + rate), so net = gross / (1 + rate)
|
||||
// and vat = gross - net = gross × rate / (1 + rate).
|
||||
const expectedVat = (value.disposed_proceeds * rate) / (1 + rate)
|
||||
if (Math.abs(expectedVat - value.proceeds_vat) > 0.5) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['proceeds_vat'],
|
||||
message: `proceeds_vat ska vara ~${Math.round(expectedVat * 100) / 100} kr för momsbehandling "${value.vat_treatment}" på ${value.disposed_proceeds} kr brutto.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function vatRateFromTreatment(t: (typeof VAT_TREATMENTS)[number]): number | null {
|
||||
switch (t) {
|
||||
case 'standard_25':
|
||||
return 0.25
|
||||
case 'reduced_12':
|
||||
return 0.12
|
||||
case 'reduced_6':
|
||||
return 0.06
|
||||
case 'reverse_charge':
|
||||
case 'export':
|
||||
case 'exempt':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'assets.dispose',
|
||||
|
||||
@@ -3,40 +3,66 @@ import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { K3ComponentSchema } from '@/lib/api/schemas'
|
||||
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
|
||||
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
|
||||
import type { DepreciationMethod } from '@/types'
|
||||
|
||||
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
|
||||
'linear',
|
||||
'declining_balance_30',
|
||||
'declining_balance_20',
|
||||
'restvardesavskrivning_25',
|
||||
] as const
|
||||
|
||||
// Engine only implements linear today — reject declining_balance methods on
|
||||
// both create and update until the engine grows them. The DB enum keeps the
|
||||
// other methods reserved for a future phase.
|
||||
const SUPPORTED_DEPRECIATION_METHODS: readonly DepreciationMethod[] = ['linear'] as const
|
||||
const UpdateAssetSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
salvage_value: z.number().nonnegative().optional(),
|
||||
useful_life_months: z.number().int().positive().optional(),
|
||||
depreciation_method: z
|
||||
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
|
||||
.optional(),
|
||||
restvarde_target: z.number().nonnegative().nullable().optional(),
|
||||
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
// K3 component depreciation. Accepting `null` lets the caller clear an
|
||||
// existing breakdown (the engine then falls back to depreciation_method).
|
||||
// Per-component validation runs whenever the field is set to a non-null
|
||||
// value; the cross-sum check needs acquisition_cost so it's deferred to
|
||||
// updateAsset() which can read the existing row.
|
||||
k3_components: z.array(K3ComponentSchema).nullable().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
// Enforce the method/target biconditional when EITHER field is supplied.
|
||||
// We can't see the existing row from a zod refinement, so the
|
||||
// application-level updateAsset() carries the cross-row check; here we
|
||||
// only catch the obviously inconsistent combinations within a single
|
||||
// PATCH body.
|
||||
const hasMethod = value.depreciation_method !== undefined
|
||||
const hasTarget = value.restvarde_target !== undefined
|
||||
if (!hasMethod && !hasTarget) return
|
||||
|
||||
const UpdateAssetSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
salvage_value: z.number().nonnegative().optional(),
|
||||
useful_life_months: z.number().int().positive().optional(),
|
||||
depreciation_method: z
|
||||
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
|
||||
.optional()
|
||||
.refine(
|
||||
(m) => m === undefined || (SUPPORTED_DEPRECIATION_METHODS as readonly string[]).includes(m),
|
||||
{
|
||||
message:
|
||||
'Only "linear" depreciation is supported by the engine today. ' +
|
||||
'Declining-balance methods are reserved for a future phase.',
|
||||
},
|
||||
),
|
||||
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
})
|
||||
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
|
||||
const targetIsSet = value.restvarde_target !== null && value.restvarde_target !== undefined
|
||||
|
||||
if (hasMethod && isRestvarde && hasTarget && !targetIsSet) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['restvarde_target'],
|
||||
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
|
||||
})
|
||||
}
|
||||
if (hasMethod && !isRestvarde && hasTarget && targetIsSet) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['restvarde_target'],
|
||||
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'assets.get',
|
||||
@@ -62,6 +88,51 @@ export const PATCH = withRouteContext(
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const validation = await validateBody(request, UpdateAssetSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
// K3 component depreciation gating + cross-sum check.
|
||||
// The Zod refinement cannot see the existing asset's acquisition_cost,
|
||||
// so we do both the framework check and the sum validation here at
|
||||
// route level before delegating to updateAsset().
|
||||
if (validation.data.k3_components !== undefined && validation.data.k3_components !== null) {
|
||||
const [{ data: company }, existing] = await Promise.all([
|
||||
supabase
|
||||
.from('companies')
|
||||
.select('accounting_framework')
|
||||
.eq('id', companyId)
|
||||
.single(),
|
||||
getAsset(supabase, companyId, id),
|
||||
])
|
||||
if (!company || company.accounting_framework !== 'k3') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'K3_REQUIRED_FOR_COMPONENTS',
|
||||
message: 'Komponentuppdelning (k3_components) kräver att företaget tillämpar K3 (BFNAR 2012:1).',
|
||||
},
|
||||
},
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
|
||||
}
|
||||
const { errors } = validateComponents({
|
||||
acquisition_cost: Number(existing.acquisition_cost),
|
||||
k3_components: validation.data.k3_components,
|
||||
})
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'INVALID_K3_COMPONENTS',
|
||||
message: errors.join(' '),
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = await updateAsset(supabase, companyId, id, validation.data)
|
||||
return NextResponse.json({ data: asset })
|
||||
|
||||
+102
-15
@@ -3,7 +3,9 @@ import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { K3ComponentSchema } from '@/lib/api/schemas'
|
||||
import { createAsset, listAssets } from '@/lib/bokslut/assets/asset-service'
|
||||
import { validateComponents } from '@/lib/bokslut/assets/k3-components'
|
||||
import type { AssetCategory, DepreciationMethod } from '@/types'
|
||||
|
||||
const ASSET_CATEGORIES: readonly AssetCategory[] = [
|
||||
@@ -17,18 +19,16 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
|
||||
'other_tangible',
|
||||
] as const
|
||||
|
||||
// The DB enum keeps all three methods so future phases can add support
|
||||
// without a migration, but the engine only implements linear today. Reject
|
||||
// the unsupported methods at create to avoid silently producing wrong
|
||||
// (linear) numbers under a misleading method label.
|
||||
// All four depreciation methods are now implemented by the engine. The DB
|
||||
// CHECK constraint mirrors this list (see
|
||||
// 20260526120100_restvardeavskrivning.sql).
|
||||
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
|
||||
'linear',
|
||||
'declining_balance_30',
|
||||
'declining_balance_20',
|
||||
'restvardesavskrivning_25',
|
||||
] as const
|
||||
|
||||
const SUPPORTED_DEPRECIATION_METHODS: readonly DepreciationMethod[] = ['linear'] as const
|
||||
|
||||
const CreateAssetSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
@@ -41,18 +41,21 @@ const CreateAssetSchema = z
|
||||
useful_life_months: z.number().int().positive(),
|
||||
depreciation_method: z
|
||||
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
|
||||
.optional()
|
||||
.refine(
|
||||
(m) => m === undefined || (SUPPORTED_DEPRECIATION_METHODS as readonly string[]).includes(m),
|
||||
{
|
||||
message:
|
||||
'Only "linear" depreciation is supported by the engine today. ' +
|
||||
'Declining-balance methods are reserved for a future phase.',
|
||||
},
|
||||
),
|
||||
.optional(),
|
||||
// Restvärde-target floor for restvärdeavskrivning. Required iff
|
||||
// depreciation_method = 'restvardesavskrivning_25'. The DB CHECK enforces
|
||||
// the same biconditional; we mirror it in the API for an early, Swedish
|
||||
// error message rather than a Postgres check_violation surfacing.
|
||||
restvarde_target: z.number().nonnegative().nullable().optional(),
|
||||
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
|
||||
// K3 component depreciation (BFNAR 2012:1 ch.17.4). Only meaningful for
|
||||
// companies with accounting_framework='k3' — the route handler rejects
|
||||
// K3_REQUIRED_FOR_COMPONENTS for K2 companies. When present, the engine
|
||||
// dispatches to per-component linear depreciation instead of the
|
||||
// asset-level depreciation_method.
|
||||
k3_components: z.array(K3ComponentSchema).nullable().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -60,8 +63,70 @@ const CreateAssetSchema = z
|
||||
// outside the legitimate range for the asset category so the chart stays
|
||||
// BAS-aligned and INK2R mappings continue to work.
|
||||
validateBasOverrides(value, ctx)
|
||||
validateRestvardeTarget(value, ctx)
|
||||
validateK3Components(value, ctx)
|
||||
})
|
||||
|
||||
function validateK3Components(
|
||||
value: {
|
||||
acquisition_cost: number
|
||||
k3_components?: { name: string; cost: number; useful_life_months: number; salvage_value?: number }[] | null
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
): void {
|
||||
if (value.k3_components === undefined || value.k3_components === null) return
|
||||
const { errors } = validateComponents({
|
||||
acquisition_cost: value.acquisition_cost,
|
||||
k3_components: value.k3_components,
|
||||
})
|
||||
for (const message of errors) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['k3_components'],
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function validateRestvardeTarget(
|
||||
value: {
|
||||
depreciation_method?: DepreciationMethod
|
||||
restvarde_target?: number | null
|
||||
acquisition_cost?: number
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
): void {
|
||||
const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
|
||||
const hasTarget = value.restvarde_target !== undefined && value.restvarde_target !== null
|
||||
if (isRestvarde && !hasTarget) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['restvarde_target'],
|
||||
message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
|
||||
})
|
||||
}
|
||||
if (!isRestvarde && hasTarget) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['restvarde_target'],
|
||||
message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
|
||||
})
|
||||
}
|
||||
if (
|
||||
isRestvarde &&
|
||||
hasTarget &&
|
||||
value.acquisition_cost !== undefined &&
|
||||
(value.restvarde_target as number) >= value.acquisition_cost
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['restvarde_target'],
|
||||
message:
|
||||
'restvarde_target måste vara lägre än anskaffningsvärdet — annars finns inget kvar att skriva av.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function validateBasOverrides(
|
||||
value: {
|
||||
category: AssetCategory
|
||||
@@ -151,6 +216,28 @@ export const POST = withRouteContext(
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const validation = await validateBody(request, CreateAssetSchema)
|
||||
if (!validation.success) return validation.response
|
||||
// K3_REQUIRED_FOR_COMPONENTS: K3 component depreciation is only
|
||||
// meaningful when the company applies the K3 framework. Reject the
|
||||
// write with 422 (Unprocessable Entity) rather than silently dropping
|
||||
// the field so the user knows their input was discarded.
|
||||
if (validation.data.k3_components !== undefined && validation.data.k3_components !== null) {
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('accounting_framework')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
if (!company || company.accounting_framework !== 'k3') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'K3_REQUIRED_FOR_COMPONENTS',
|
||||
message: 'Komponentuppdelning (k3_components) kräver att företaget tillämpar K3 (BFNAR 2012:1).',
|
||||
},
|
||||
},
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const asset = await createAsset(supabase, companyId, user.id, validation.data)
|
||||
return NextResponse.json({ data: asset })
|
||||
|
||||
Reference in New Issue
Block a user