style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)

Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-04 15:58:06 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent a5154ee884
commit ec27228a8e
1588 changed files with 28388 additions and 10890 deletions
@@ -143,7 +143,7 @@ describe('POST /api/account/delete', () => {
ban_duration: expect.any(String),
})
)
// Email must NOT be scrubbed — retaining it is what blocks re-signup
// Email must NOT be scrubbed: retaining it is what blocks re-signup
// with the same address. Recovery goes through support instead.
const updatePayload = updateUserById.mock.calls[0][1]
expect(updatePayload).not.toHaveProperty('email')
+4 -4
View File
@@ -25,7 +25,7 @@ const DeleteAccountSchema = z.object({
*
* Precondition: the user must own zero non-archived companies. The RPC
* enforces this at the DB level and raises SQLSTATE P0001 with a message
* if the precondition fails — we return 409 in that case.
* if the precondition fails: we return 409 in that case.
*/
export async function POST(request: Request) {
const supabase = await createClient()
@@ -92,16 +92,16 @@ export async function POST(request: Request) {
// Note: auth.users.email is intentionally NOT scrubbed. The original
// address is retained as a legitimate-interest tombstone so that:
// (1) re-signup with the same email is blocked by Supabase's unique
// constraint — deletion must feel permanent, not trivially
// constraint: deletion must feel permanent, not trivially
// reversible by re-registering
// (2) support can verify identity when a former user asks to recover
// BFL-retained räkenskapsinformation
// This must be documented in the privacy policy under legitimate
// interest (GDPR Art. 6(1)(f)). The email is never read by the app
// after this point — login is impossible (row is banned) and the
// after this point: login is impossible (row is banned) and the
// profile is anonymized, so no UI ever surfaces it.
//
// user_metadata / app_metadata ARE wiped — they may contain display
// user_metadata / app_metadata ARE wiped: they may contain display
// name, avatar, or provider info that isn't needed for recovery.
// The admin API replaces (not merges) these, so passing {} clears them.
const service = createServiceClient()
@@ -131,7 +131,7 @@ describe('POST /api/account/password', () => {
expect(status).toBe(200)
expect(body.data?.ok).toBe(true)
// Did NOT go through the user session — that path would fail with AAL2.
// Did NOT go through the user session: that path would fail with AAL2.
expect(updateUser).not.toHaveBeenCalled()
// Password set via admin
expect(passwordSetCall(updateUserById)).toEqual([
+3 -3
View File
@@ -29,8 +29,8 @@ const SetPasswordSchema = z.object({
* Two paths depending on whether the user already has a real password:
*
* - First-time set (`app_metadata.has_password !== true`): write via the
* admin API. BankID-only users — and legacy users whose `has_password`
* flag was set to false by the backfill — sit at AAL1 with a TOTP factor
* admin API. BankID-only users (and legacy users whose `has_password`
* flag was set to false by the backfill) sit at AAL1 with a TOTP factor
* enrolled, and `updateUser` on the user session would be rejected with
* "AAL2 session is required to update email or password when MFA is
* enabled". Setting an initial password has no existing credential to
@@ -42,7 +42,7 @@ const SetPasswordSchema = z.object({
*
* This route is the single write path for setting a password. SecuritySettings,
* the reset-password page, and the /account/set-password page all funnel
* through here so the flag stays in sync — see lib/auth/has-password.ts.
* through here so the flag stays in sync: see lib/auth/has-password.ts.
*
* If the password update succeeds but the flag write fails, we log and still
* return success: the user has a working password and the banner will show one
+1 -1
View File
@@ -55,7 +55,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'No active company' }, { status: 400 })
}
// Defense in depth alongside RLS — confirm membership before composing.
// Defense in depth alongside RLS: confirm membership before composing.
const { data: membership } = await supabase
.from('company_members')
.select('role')
+2 -2
View File
@@ -38,7 +38,7 @@ export async function GET(
if (convErr) return NextResponse.json({ error: convErr.message }, { status: 500 })
if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
// Defense in depth alongside RLS — verify caller is a member of the
// Defense in depth alongside RLS: verify caller is a member of the
// conversation's company AND owns the conversation row. Conversations are
// user-scoped within a company; one team member should not see another's.
if (conv.user_id !== user.id) {
@@ -91,7 +91,7 @@ export async function PATCH(
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
// Defense in depth — verify ownership before update so a 404 is returned
// Defense in depth: verify ownership before update so a 404 is returned
// (instead of relying solely on RLS, which would silently 0-row).
const { data: existing } = await supabase
.from('agent_conversations')
+1 -1
View File
@@ -7,7 +7,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
// Query params:
// archived: 'true' | 'false' (default 'false')
// pinned: 'true' filters to pinned only
// intent: 'general.help' (or any intent id) — filter
// intent: 'general.help' (or any intent id), filter
// q: case-insensitive substring match on title/context_ref
// limit: 1..200, default 50
//
+8 -8
View File
@@ -10,7 +10,7 @@ import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
// Make sure extensions are loaded — the chat loop dispatches against the
// Make sure extensions are loaded: the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load.
ensureInitialized()
@@ -62,7 +62,7 @@ const BodySchema = z.object({
// POST /api/agent/invoke
//
// Streams NDJSON events from the chat loop. Each line is a JSON object whose
// `kind` identifies the event type — see lib/agent/chat/run-turn.ts StreamEvent.
// `kind` identifies the event type: see lib/agent/chat/run-turn.ts StreamEvent.
//
// Auth: the user must be a member of the resolved company.
//
@@ -72,7 +72,7 @@ export async function POST(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Generous per-user rate limit — bounds runaway Bedrock spend (loop-firing
// Generous per-user rate limit: bounds runaway Bedrock spend (loop-firing
// sessions). Fails open on infra error.
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
@@ -108,7 +108,7 @@ export async function POST(request: Request) {
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// No Anthropic Bedrock calls in the sandbox — the demo runs entirely on
// No Anthropic Bedrock calls in the sandbox: the demo runs entirely on
// seed data and the assistant is gated to a "look, don't touch" preview.
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
@@ -116,12 +116,12 @@ export async function POST(request: Request) {
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
if (capBlocked) return capBlocked
// onboarding.intake completion signal — once the user has actually
// onboarding.intake completion signal: once the user has actually
// engaged (typed a real reply, not the auto-fired greeting prompt that
// mounts the chat), stamp intake_completed_at on the profile so re-entry
// logic and opportunistic follow-up logic in other intents can tell the
// intake happened. Idempotent: the IS NULL guard ensures we never
// overwrite the first engagement timestamp. Best-effort — failure here
// overwrite the first engagement timestamp. Best-effort: failure here
// doesn't break the chat; the next user turn retries.
if (
body.intent_id === 'onboarding.intake' &&
@@ -136,7 +136,7 @@ export async function POST(request: Request) {
.eq('company_id', companyId)
.is('intake_completed_at', null)
} catch {
// ignored — see comment above
// ignored: see comment above
}
}
@@ -210,7 +210,7 @@ export async function POST(request: Request) {
}
}
// Stream — NDJSON events from the chat loop.
// Stream: NDJSON events from the chat loop.
const encoder = new TextEncoder()
// Conversation id is set above; capture into a non-null local for the
// streaming closure's first emission.
+3 -3
View File
@@ -8,13 +8,13 @@ import { requireWritePermission } from '@/lib/auth/require-write'
// Mutate a single memory entry. Powers the list/edit/pin/dismiss affordances
// on /settings/agent-memory (plan §11).
//
// content — edit the durable text (kind never changes; that would
// content: edit the durable text (kind never changes; that would
// muddle the audit lineage). Append-only is preserved by
// superseded_by chains when an upstream caller wants it; the
// transparency UI is allowed to overwrite in place because
// the row's `updated_at` already documents the edit.
// is_pinned — boost into the top-N prompt block regardless of score.
// is_active — false = dismiss (removes from ranking pool); true = restore.
// is_pinned: boost into the top-N prompt block regardless of score.
// is_active: false = dismiss (removes from ranking pool); true = restore.
//
// RLS scopes to user_company_ids(); we ALSO re-verify membership for the
// row's company_id as defense in depth before mutating.
+1 -1
View File
@@ -55,7 +55,7 @@ describe('GET /api/agent/memory', () => {
{
id: 'mem-1',
kind: 'fact',
content: 'Räkenskapsår jan–dec',
content: 'Räkenskapsår jan-dec',
source: 'composer',
source_ref: null,
relevance_score: 0.5,
+15 -15
View File
@@ -20,7 +20,7 @@ const BodySchema = z.object({
company_id: z.string().uuid().optional(),
})
// 10s — the user is on a wait-screen with visible progress, so we can afford
// 10s: the user is on a wait-screen with visible progress, so we can afford
// the longer budget. The prior 5s clipped legitimate fetches (TIC fans out to
// ~13 Lens calls upstream) into the fallback bucket while still burning the
// in-flight upstream calls against quota. See actions.ts:182-189 for the
@@ -58,17 +58,17 @@ interface ProfilePayload {
// POST /api/agent/onboarding/stream
//
// Streams real-timed progress for the agent build sequence (plan §7 Phase A).
// Each step runs on its actual latency — no artificial delays. On timeout or
// Each step runs on its actual latency: no artificial delays. On timeout or
// failure, the step emits `fallback` and the pipeline continues with a
// deterministic default so the user always reaches Phase B.
//
// Response: application/x-ndjson — one JSON event per line.
// Response: application/x-ndjson, one JSON event per line.
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Generous per-user rate limit — bounds reload-spam of the onboarding build
// Generous per-user rate limit: bounds reload-spam of the onboarding build
// (each run fires 2 LLM calls). Fails open on infra error.
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
@@ -103,7 +103,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
}
// No live composer run for sandbox companies — they ship with a pre-built
// No live composer run for sandbox companies: they ship with a pre-built
// verified agent_profile so the chrome is visible without burning Bedrock.
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
@@ -119,12 +119,12 @@ export async function POST(request: Request) {
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
} catch {
// Stream was cancelled (user navigated away). Subsequent enqueues
// would throw — we just stop emitting.
// would throw: we just stop emitting.
}
}
try {
// Step 1 — TIC: read companies.tic_snapshot; if missing or stale,
// Step 1: TIC: read companies.tic_snapshot; if missing or stale,
// live-fetch from the TIC extension (cookies forwarded from the
// incoming request) and persist. Falls through gracefully when TIC is
// disabled, the company has no org_number, or the request times out.
@@ -146,7 +146,7 @@ export async function POST(request: Request) {
// bounded to companies actively creating an agent, so the TIC
// budget stays safe even when upgrading pre-v2 snapshots.
// timeoutMs: lift the internal fetch signal to match the outer
// budget — otherwise the 5s default fires first and we get the
// budget: otherwise the 5s default fires first and we get the
// pre-fix behavior even with the longer outer budget.
ensureTicSnapshot({
supabase,
@@ -163,10 +163,10 @@ export async function POST(request: Request) {
status: ticResult.snapshot ? 'success' : 'fallback',
})
// Gather inputs once — used by select + narrative + persistence.
// Gather inputs once: used by select + narrative + persistence.
const inputs = await gatherComposerInputs(supabase, companyId)
// Step 2 — Opus atom selection with timeout + deterministic fallback.
// Step 2: Opus atom selection with timeout + deterministic fallback.
send({ step: 'select', status: 'in_progress' })
let selection: AtomSelection
try {
@@ -181,14 +181,14 @@ export async function POST(request: Request) {
// path produced the selection. fallbackAtomSelection generates a
// generic template that doesn't know about KÄNDA FAKTA; the Opus
// path is also re-filtered in case the model strayed. Cheap belt-
// and-braces — same function used for both.
// and-braces: same function used for both.
selection.verification_questions = filterRedundantQuestions(
selection.verification_questions,
inputs,
selection.modifier_atoms,
)
// Step 3 — Sonnet narrative with timeout + plain fallback.
// Step 3: Sonnet narrative with timeout + plain fallback.
send({ step: 'narrative', status: 'in_progress' })
let narrative: string
try {
@@ -199,7 +199,7 @@ export async function POST(request: Request) {
send({ step: 'narrative', status: 'fallback', narrative })
}
// Step 4 — Persist the profile so Phase B has a row to edit.
// Step 4: Persist the profile so Phase B has a row to edit.
const composedAt = new Date().toISOString()
const sourceSignals = inputsToSourceSignals(inputs)
const { error: upsertErr } = await supabase
@@ -243,7 +243,7 @@ export async function POST(request: Request) {
},
})
// Step 5 — fire-and-forget cache pre-warm. The client renders the
// Step 5: fire-and-forget cache pre-warm. The client renders the
// review card already; pre-warm just buys a faster first chat turn.
send({ step: 'prewarm', status: 'in_progress' })
const allIds = [
@@ -288,7 +288,7 @@ export async function POST(request: Request) {
}
// Run a promise against a wall-clock budget. The underlying work continues to
// completion on the server when the budget elapses — we just stop waiting for
// completion on the server when the budget elapses: we just stop waiting for
// it. For Anthropic calls that's fine: a slow Opus turn finishing later still
// warms its own cache.
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
+4 -4
View File
@@ -11,7 +11,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
//
// PATCH updates field_overrides (timestamped, merged with existing) and
// optionally rewrites the atom arrays from the review UI. Does not touch
// verified_at — that flows through /verify.
// verified_at: that flows through /verify.
const AtomArrays = z.object({
horizontal_atoms: z.array(z.string()).optional(),
@@ -24,7 +24,7 @@ const PatchBody = z.object({
field_overrides: z.record(z.string(), z.unknown()).optional(),
atoms: AtomArrays.optional(),
profile_summary: z.string().min(1).max(2000).optional(),
// Agent personalization — name shown on the FAB and chat headers, and
// Agent personalization: name shown on the FAB and chat headers, and
// avatar key into the static AVATAR_OPTIONS registry. Both nullable so
// the user can clear them back to defaults.
display_name: z.string().min(1).max(60).nullable().optional(),
@@ -41,7 +41,7 @@ export async function GET(request: Request) {
url.searchParams.get('company_id') ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// Defense in depth alongside RLS — confirm membership before reading.
// Defense in depth alongside RLS: confirm membership before reading.
const { data: membership } = await supabase
.from('company_members')
.select('role')
@@ -81,7 +81,7 @@ export async function PATCH(request: Request) {
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// RLS guards reads/updates by company_id; defense in depth — confirm membership.
// RLS guards reads/updates by company_id; defense in depth: confirm membership.
const { data: membership } = await supabase
.from('company_members')
.select('role')
+2 -2
View File
@@ -6,7 +6,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
// POST /api/agent/profile/verify
//
// Stamps verified_at + verified_by_user_id on agent_profiles when the user
// clicks "Det här ser rätt ut — kör" in Phase B. Idempotent: re-verifying
// clicks "Det här ser rätt ut, kör" in Phase B. Idempotent: re-verifying
// updates the timestamp; this is desirable for "Bygg om" rebuild flows.
const BodySchema = z.object({
@@ -31,7 +31,7 @@ export async function POST(request: Request) {
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// Defense in depth alongside RLS — confirm membership for the target
// Defense in depth alongside RLS: confirm membership for the target
// company; a non-viewer role is required to stamp verified_at.
const { data: membership } = await supabase
.from('company_members')
+3 -3
View File
@@ -5,10 +5,10 @@ import { getActiveCompanyId } from '@/lib/company/context'
// GET /api/agent/skills
//
// Read-only transparency surface for the in-app bookkeeping assistant's domain
// knowledge ("atoms"). Powers /settings/agent-skills — the companion to
// knowledge ("atoms"). Powers /settings/agent-skills: the companion to
// /settings/agent-memory. Memory is what the assistant *learned* about this
// company (user-editable); skills are the Swedish-accounting expertise it
// *ships* with — authored in .claude/skills/**/SKILL.md, seeded into
// *ships* with: authored in .claude/skills/**/SKILL.md, seeded into
// agent_atom_registry, read-only for users, curated via mcp_exposed.
//
// Two shapes off one route:
@@ -19,7 +19,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
//
// "Active for this company": horizontal atoms are regulatory and shared by
// every Swedish company, so always active. Vertical/modifier atoms are active
// only when the composer selected them into this company's agent_profile —
// only when the composer selected them into this company's agent_profile:
// others are shown dormant so the user sees both the full library and what's
// tuned for them. The profile arrays store full ids ("vertical/konsult-it"),
// matched directly against agent_atom_registry.id (see lib/agent/chat/system-prompt.ts).
+1 -1
View File
@@ -66,7 +66,7 @@ export const PATCH = withRouteContext(
}
}
// Sparse update — only the fields the caller actually sent.
// Sparse update: only the fields the caller actually sent.
const updateData: Record<string, unknown> = {}
for (const key of [
'name', 'name_en', 'type', 'unit', 'price_excl_vat', 'vat_rate',
+1 -1
View File
@@ -62,7 +62,7 @@ describe('GET/POST /api/articles', () => {
it('POST rejects a 3xxx revenue_account unknown to both chart and BAS catalogue', async () => {
// Non-3xxx numbers are already stopped by the Zod schema; the route-level
// 'invalid' branch covers 3xxx numbers that exist nowhere — no chart row
// 'invalid' branch covers 3xxx numbers that exist nowhere, no chart row
// and not in the BAS reference (3041 is not a BAS 2026 account).
enqueue({ data: null })
+1 -1
View File
@@ -12,7 +12,7 @@ import type { Article } from '@/types'
ensureInitialized()
// GET /api/articles — list the active company's articles. `?include_inactive=1`
// GET /api/articles: list the active company's articles. `?include_inactive=1`
// returns soft-deactivated ones too (the register page can show an archive view).
export const GET = withRouteContext(
'article.list',
+1 -1
View File
@@ -32,7 +32,7 @@ const DisposeAssetSchema = z
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 —
// accumulated_depreciation is intentionally NOT accepted from the client:
// disposeAsset sums depreciation_schedules server-side so callers cannot
// inflate the book-value calculation.
})
+1 -1
View File
@@ -32,7 +32,7 @@ const UpdateAssetSchema = z
notes: z.string().nullable().optional(),
// Acquisition-basis corrections. The service (updateAsset) only permits
// these while the asset is neither disposed nor depreciated, returning
// ASSET_CORRECTION_BLOCKED (409) otherwise — they redefine the
// ASSET_CORRECTION_BLOCKED (409) otherwise: they redefine the
// depreciation basis, so a post-posting change must go through storno.
category: z
.enum(ASSET_CATEGORIES as unknown as [AssetCategory, ...AssetCategory[]])
+7 -7
View File
@@ -34,7 +34,7 @@ const CreateAssetSchema = z
name: z.string().min(1),
category: z.enum(ASSET_CATEGORIES as unknown as [AssetCategory, ...AssetCategory[]]),
acquisition_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// Positive — a zero-value asset would dodge the depreciation engine and
// Positive: a zero-value asset would dodge the depreciation engine and
// create a no-op row that confuses the balance sheet.
acquisition_cost: z.number().positive(),
salvage_value: z.number().nonnegative().optional(),
@@ -51,7 +51,7 @@ const CreateAssetSchema = z
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
// 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.
@@ -122,7 +122,7 @@ function validateRestvardeTarget(
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.',
'restvarde_target måste vara lägre än anskaffningsvärdet: annars finns inget kvar att skriva av.',
})
}
}
@@ -141,7 +141,7 @@ function validateBasOverrides(
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_asset_account'],
message: `Account must be in range ${ranges.asset[0]}–${ranges.asset[1]} for ${value.category}`,
message: `Account must be in range ${ranges.asset[0]}-${ranges.asset[1]} for ${value.category}`,
})
}
if (
@@ -151,14 +151,14 @@ function validateBasOverrides(
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_accumulated_account'],
message: `Account must be in range ${ranges.accumulated[0]}–${ranges.accumulated[1]} for ${value.category}`,
message: `Account must be in range ${ranges.accumulated[0]}-${ranges.accumulated[1]} for ${value.category}`,
})
}
if (value.bas_expense_account && !inRange(value.bas_expense_account, ranges.expense)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_expense_account'],
message: `Account must be in range ${ranges.expense[0]}–${ranges.expense[1]} for ${value.category}`,
message: `Account must be in range ${ranges.expense[0]}-${ranges.expense[1]} for ${value.category}`,
})
}
// Anskaffningskonto and ackumulerade-avskrivningar-konto live in the same
@@ -207,7 +207,7 @@ export const GET = withRouteContext('assets.list', async (request, ctx) => {
// Annotate each asset with whether any depreciation has been posted
// against it. The UI uses this to lock the acquisition-basis fields
// (date/cost/category) — once avskrivningar are booked a correction must
// (date/cost/category): once avskrivningar are booked a correction must
// go through storno (the service enforces the same rule server-side).
const postedAssetIds = new Set<string>()
if (data.length > 0) {
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { insertAuthUser } from '@/tests/pg/fixtures'
describe('audit-log.pg — append-only immutability', () => {
describe('audit-log.pg: append-only immutability', () => {
it('rejects UPDATE on audit_log rows', async () => {
const userId = await insertAuthUser()
const auditId = randomUUID()
+1 -1
View File
@@ -7,7 +7,7 @@ import { getStripe } from '@/lib/stripe/client'
/**
* Create a Stripe Billing Customer Portal session so the user can manage,
* upgrade/downgrade, or cancel their subscription. Stripe handles all the
* compliance/PCI surface — we never build those flows ourselves.
* compliance/PCI surface: we never build those flows ourselves.
*/
export async function POST() {
const { user, supabase, error } = await requireAuth()
@@ -38,7 +38,7 @@ export async function GET(request: Request) {
const companyId = await requireCompanyId(supabase, user.id)
// Find the fiscal period containing as_of (any state — we want a reference
// Find the fiscal period containing as_of (any state: we want a reference
// saldo even for closed/locked periods).
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
@@ -8,7 +8,7 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
* The full BAS 2026 catalogue (~1,276 accounts), projected to the fields the
* AccountCombobox needs to search and render. This lets the manual bookkeeping
* flow surface accounts by name even when they aren't in the company's chart
* yet — selecting one routes through the existing activate-on-commit rail
* yet: selecting one routes through the existing activate-on-commit rail
* (ACCOUNTS_NOT_IN_CHART → ActivateAccountsDialog → /accounts/activate).
*
* The payload is static reference data for the deploy and identical for every
@@ -18,7 +18,7 @@ ensureInitialized()
* "Lös upp nu": books the schedule's remaining months in ONE verifikat dated
* today (clamped by lock date) and completes the schedule. Used when the
* underlying service ends early or the user wants the rest expensed now.
* Cancelling-with-storno only happens via the credit flows — a standalone
* Cancelling-with-storno only happens via the credit flows: a standalone
* cancel would strand the interim-account balance.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
@@ -29,7 +29,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
try {
const result = await dissolveScheduleNow(supabase, companyId!, user.id, id)
// Manual financial write — log the acting user for auditability.
// Manual financial write: log the acting user for auditability.
log.info('accrual schedule dissolved', {
userId: user.id,
companyId,
@@ -40,7 +40,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ data: result })
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown'
// Typed domain errors carry a stable code — never match Swedish prose.
// Typed domain errors carry a stable code: never match Swedish prose.
if (isAccrualError(err)) {
switch (err.code) {
case ACCRUAL_SCHEDULE_NOT_FOUND:
@@ -14,7 +14,7 @@ let installmentsResult: {
} = { data: [], error: null }
// The route loads company ids through fetchAllRows, which appends
// .range(from, to) per page — the mock slices the fixture so pagination
// .range(from, to) per page: the mock slices the fixture so pagination
// (>1000 rows) is exercised for real.
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(() => ({
@@ -9,13 +9,13 @@ import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
ensureInitialized()
/**
* GET /api/bookkeeping/accruals/post-due/cron — daily 05:15 UTC.
* GET /api/bookkeeping/accruals/post-due/cron, daily 05:15 UTC.
*
* Posts the monthly periodiseringsverifikat (source_type 'accrual') for every
* pending installment whose calendar month has begun. Companies run in
* isolated try/catch; one company's failure never blocks the rest. Per-
* installment failures are recorded on the row (last_error) by the service
* and retried on the next run — the periodiseringar page surfaces them.
* and retried on the next run: the periodiseringar page surfaces them.
*
* Idempotency: posting flips the installment pending→posted with a CAS
* claim, so a cron retry (or a concurrent manual "Bokför förfallna") can
@@ -25,7 +25,7 @@ export const GET = withCronContext('cron.accrual_postings', async (_request, ctx
const supabase = createServiceClient()
const todayIso = new Date().toISOString().slice(0, 10)
// fetchAllRows pages past PostgREST's 1000-row cap — a single unpaginated
// fetchAllRows pages past PostgREST's 1000-row cap: a single unpaginated
// select would silently drop companies once total due installments exceed
// the cap, permanently starving the ones sorted last.
let rows: Array<{ company_id: string }>
@@ -8,7 +8,7 @@ ensureInitialized()
/**
* POST /api/bookkeeping/accruals/post-due
*
* Manual "Bokför förfallna periodiseringar" for the active company —
* Manual "Bokför förfallna periodiseringar" for the active company:
* complements the daily cron (same service, same CAS idempotency), so the
* user never has to wait for the nightly run after creating a schedule with
* elapsed months or after fixing a blocked installment.
@@ -160,7 +160,7 @@ describe('PATCH /api/bookkeeping/fiscal-periods/[id]', () => {
})
})
describe('enskild firma — BFL 3 kap.', () => {
describe('enskild firma: BFL 3 kap.', () => {
it('allows förlängt räkenskapsår (15 mån, 4 okt 2020 → 31 dec 2021) on the first period', async () => {
buildMockSupabase({ earlierPeriodCount: 0 })
const res = await PATCH(
@@ -195,7 +195,7 @@ describe('PATCH /api/bookkeeping/fiscal-periods/[id]', () => {
expect(body.error).toMatch(/kalenderår/)
})
it('accepts EF subsequent period running 1 jan – 31 dec', async () => {
it('accepts EF subsequent period running 1 jan to 31 dec', async () => {
buildMockSupabase({
period: { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false },
earlierPeriodCount: 1,
@@ -226,7 +226,7 @@ describe('PATCH /api/bookkeeping/fiscal-periods/[id]', () => {
// Defense-in-depth: the EF end-date guard runs before validatePeriodDuration.
// A 24-month first period (2020-01-01 → 2021-12-31) ends on 31 dec, so the EF
// guard passes — duration validation must catch it as BFL 3 kap. caps the
// guard passes: duration validation must catch it as BFL 3 kap. caps the
// first period at 18 months.
it('rejects a 24-month first period via the duration cap', async () => {
buildMockSupabase({ earlierPeriodCount: 0 })
@@ -29,7 +29,7 @@ export const GET = withRouteContext(
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
// 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 []
@@ -54,7 +54,7 @@ export const GET = withRouteContext(
// - 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
// 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}$/
@@ -212,11 +212,11 @@ export const POST = withRouteContext(
// Mark the entry's description with the reversal date so future
// bookkeepers (and a future cron) can spot the periodisering. The
// accrual_reversals cron is follow-up infra — once it lands, the
// accrual_reversals cron is follow-up infra: once it lands, the
// entry's source_type or a metadata column can drive the auto-flip.
//
// Vacation-liability adjustments deliberately have empty reverses_on
// since 2920 carries forward — emit a different description in that
// since 2920 carries forward: emit a different description in that
// case so future readers don't expect a Jan 1 reversal.
const description = proposal.reverses_on
? `Periodisering: ${proposal.label} (vänds ${proposal.reverses_on})`
@@ -245,7 +245,7 @@ type PostItem = z.infer<typeof PostItemSchema>
/**
* Find a previously-posted accrual journal entry for the same kind in the
* same period — used by the POST handler to enforce idempotency. Matches
* same period, used by the POST handler to enforce idempotency. Matches
* on the description prefix that each calculator emits (see
* accrual-detector.ts and the POST handler's description construction).
*
@@ -7,7 +7,7 @@ import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-documen
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl
*
* Generates the iXBRL (XHTML) årsredovisning for the period. The document IS
* the presentation (per TILLAMPNINGSANVISNING) — the wizard renders it in an
* the presentation (per TILLAMPNINGSANVISNING): the wizard renders it in an
* iframe as the authoritative preview, and `?download=1` hands the same bytes
* to the user for manual filing at bolagsverket.se (the self-hosted path).
*
@@ -25,7 +25,7 @@ const PostSchema = z.object({
// Match the DB CHECK lengths exactly so a payload that would fail at the
// storage layer instead returns a clean 400 here. Free-text fields are
// rendered verbatim into the årsredovisning PDF, so we strip ASCII
// control bytes (NUL, ESC, etc.) at the schema layer — otherwise a
// control bytes (NUL, ESC, etc.) at the schema layer: otherwise a
// tampered payload could corrupt PDF output or hide content from auditors.
description: sanitizedText(4000).nullable().optional(),
important_events: sanitizedText(4000).nullable().optional(),
@@ -48,7 +48,7 @@ const PostSchema = z.object({
// Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden. All
// optional; null clears the override and the builder falls back to
// boilerplate ("Inga." / "Inga skulder förfaller efter mer än fem år.").
// Cap at 1 trillion SEK — well above any realistic Swedish company's
// Cap at 1 trillion SEK, well above any realistic Swedish company's
// long-term debt (Volvo Group ~500 G SEK), prevents overflow in PDF
// formatting and downstream numeric handling.
long_term_debt_over_five_years: z
@@ -62,7 +62,7 @@ const PostSchema = z.object({
parent_company_name: sanitizedText(200).nullable().optional(),
// Swedish organisationsnummer NNNNNN-NNNN. Third digit ≥ 2 distinguishes
// legal-entity org numbers from personnummer (whose third digit forms part
// of a month, 0-1). ÅRL 5:13–15 disclosure is about parent legal entities,
// of a month, 0-1). ÅRL 5:13-15 disclosure is about parent legal entities,
// so personnummer-shaped values are out of scope and a GDPR Art.5(1)(c)
// data-minimisation concern if persisted. Empty string clears the override.
parent_company_org_number: z
@@ -112,9 +112,9 @@ export const POST = withRouteContext(
if (!validation.success) return validation.response
try {
// Verify the fiscal period belongs to the authenticated company before
// writing — defense-in-depth alongside RLS, gives a cleaner 404 than
// writing: defense-in-depth alongside RLS, gives a cleaner 404 than
// the RLS rejection envelope. Also refuse mutations on locked/closed
// periods (BFL 5 kap 5 § — räkenskapsinformation immutability).
// periods (BFL 5 kap 5 §, räkenskapsinformation immutability).
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, is_closed, locked_at, closing_entry_id')
@@ -12,7 +12,7 @@ export const GET = withRouteContext(
const { supabase, companyId, log, requestId } = ctx
try {
// Narrative edits come from arsredovisning_narratives now, loaded
// inside buildArsredovisningData. The URL stays clean — no narrative
// inside buildArsredovisningData. The URL stays clean, no narrative
// text in query params, access logs, or browser history.
const data = await buildArsredovisningData(supabase, companyId, id)
// Dispatch on the framework recorded in the data. K3 documents need
@@ -27,14 +27,14 @@ export const GET = withRouteContext(
// "-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
// (defensive — unlikely to ever happen) can't break the header.
// (defensive, unlikely to ever happen) can't break the header.
const safePeriodEnd = data.fiscal_period.period_end.replace(/[^\w.-]/g, '_')
const filename = `arsredovisning-${safePeriodEnd}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${filename}"`,
// ÅR contains company financials + officer names — don't let any
// ÅR contains company financials + officer names: don't let any
// intermediary cache the document.
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
@@ -10,9 +10,9 @@ import { validateBody } from '@/lib/api/validate'
//
// Hardening on every UPDATE:
// - .eq('id', signatureId) + .eq('company_id', companyId)
// - .eq('fiscal_period_id', id from URL) — enforces the REST contract so
// - .eq('fiscal_period_id', id from URL): enforces the REST contract so
// /periods/A/signatures/SIG_FROM_B can't bypass the path scope
// - .eq('status', 'pending') — state-machine guard so a signed or declined
// - .eq('status', 'pending'): state-machine guard so a signed or declined
// row can't be flipped back
const PatchSchema = z.object({
status: z.enum(['signed', 'declined']),
@@ -8,10 +8,10 @@ import {
listSignatureRequests,
} from '@/lib/bokslut/arsredovisning/signature-service'
// Roles are constrained to the underskrifter set ÅRL allows — keeps the API
// Roles are constrained to the underskrifter set ÅRL allows: keeps the API
// from accepting arbitrary "Administrator" / "CEO" strings that the UI
// dropdown doesn't expose. Name capped at 200 chars per GDPR data-min
// (Art.25.2) — Swedish names are well under that; bound is a defense.
// (Art.25.2): Swedish names are well under that; bound is a defense.
const CreateSchema = z.object({
role: z.enum(['Styrelseledamot', 'Styrelseordförande', 'VD', 'Verkställande direktör']),
signer_name: z.string().min(1).max(200),
@@ -4,7 +4,7 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildBokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
/**
* GET: aggregated bokslut readiness report — combines validateYearEndReadiness
* GET: aggregated bokslut readiness report: combines validateYearEndReadiness
* (legal blockers) with bank-reconciliation status and informational reminders
* for the bokslutsdispositioner that are still booked manually until Phase 2+
* ships their calculators. One fetch backs the wizard's preflight step.
@@ -51,13 +51,13 @@ 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,
// 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,
}
// ============================================================
// GET — return proposal snapshot with defaults
// GET: return proposal snapshot with defaults
// ============================================================
export const GET = withRouteContext(
'period.bokslutsdispositioner_preview',
@@ -81,7 +81,7 @@ export const GET = withRouteContext(
)
// ============================================================
// POST — commit a list of dispositions chosen by the user
// POST: commit a list of dispositions chosen by the user
// ============================================================
const ItemSchema = z.discriminatedUnion('kind', [
z.object({
@@ -115,13 +115,13 @@ const ItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('overavskrivningar'),
additionalAmount: z.number(),
/** Asset category for BAS account selection — defaults to maskiner &
/** Asset category for BAS account selection: defaults to maskiner &
* inventarier (8853/2153), the dominant K2 case. */
category: z
.enum(['machinery_equipment', 'building', 'immaterial', 'group'])
.optional(),
}),
// K3 only — uppskjuten skatt provision. Server recomputes the amount from
// 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'),
@@ -160,7 +160,7 @@ export const POST = withRouteContext(
const created: { kind: string; entry: JournalEntry }[] = []
// Process items in canonical bokslut order regardless of client array
// ordering — each computation pulls the current income statement, so
// ordering: each computation pulls the current income statement, so
// återföring must post before avsättning sees its cap base; över-
// avskrivningar must post before bolagsskatt; SLP and bolagsskatt last.
const sortedItems = [...validation.data.items].sort(
@@ -168,7 +168,7 @@ export const POST = withRouteContext(
)
// KNOWN LIMITATION (SOC 2 PI1.3): the loop is not wrapped in a database
// transaction — each item posts its own journal entry via the engine.
// transaction: each item posts its own journal entry via the engine.
// A failure midway leaves earlier items committed and later ones not.
// Recovery: the UI can re-POST omitting already-committed kinds; each
// calculator re-derives from the current trial balance so the next run
@@ -210,11 +210,11 @@ async function computeProposal(
switch (item.kind) {
case 'bolagsskatt': {
// Dispositioner are booked as source_type='year_end', which the income
// statement excludes — so net_result alone overstates resultat före skatt.
// statement excludes, so net_result alone overstates resultat före skatt.
// Add the already-posted dispositions back (avsättning −, återföring +,
// SLP −, överavskrivningar −); bolagsskatt is sorted LAST so they are
// committed by now. Without this the booked tax ignores the avsättning
// (the original customer bug — too-high tax, ÅR/INK2 mismatch).
// (the original customer bug, too-high tax, ÅR/INK2 mismatch).
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const dispositionsEffect = await sumPostedYearEndDispositions(
supabase,
@@ -278,7 +278,7 @@ async function computeProposal(
schablonintaktRate: item.schablonintaktRate,
})
// Combine multiple cohort reversals into a single voucher with multiple
// lines so we don't blow up voucher numbering — but each fond is its own
// lines so we don't blow up voucher numbering, but each fond is its own
// line pair already. Build a merged ProposedDisposition.
if (result.proposals.length === 0) return null
return mergeAteforingProposals(result.proposals)
@@ -291,7 +291,7 @@ async function computeProposal(
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.
// sends no amount: the calculator owns the K3 split.
return buildLatentTaxProposal({
supabase,
companyId,
@@ -9,7 +9,7 @@ import {
} from '@/lib/bokslut/assets/depreciation-engine'
const CommitSchema = z.object({
/** Optional whitelist — when supplied, only assets in this list are posted.
/** Optional whitelist: when supplied, only assets in this list are posted.
* Empty / omitted = post all proposed depreciations. */
asset_ids: z.array(z.string().uuid()).optional(),
})
@@ -16,7 +16,7 @@ export const POST = withRouteContext(
} catch (err) {
opLog.error('failed to lock period', err as Error)
// The service throws plain Error with messages like "Period not found"
// or "Period contains drafts" — translate to envelope codes.
// or "Period contains drafts": translate to envelope codes.
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
@@ -104,7 +104,7 @@ export async function PATCH(
const s = parseDateParts(newStart)
if (s.month !== 1 || s.day !== 1) {
return NextResponse.json(
{ error: 'Enskild firma måste använda kalenderår (1 januari – 31 december) enligt BFL 3 kap.' },
{ error: 'Enskild firma måste använda kalenderår (1 januari till 31 december) enligt BFL 3 kap.' },
{ status: 400 }
)
}
@@ -16,7 +16,7 @@ export const POST = withRouteContext(
} catch (err) {
opLog.error('failed to unlock period', err as Error)
// unlockPeriod() throws plain Error with messages like "Fiscal period not
// found", "Cannot unlock a closed period" or "Period is not locked" —
// found", "Cannot unlock a closed period" or "Period is not locked":
// translate to envelope codes, mirroring the sibling lock route.
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
@@ -48,7 +48,7 @@ export const POST = withRouteContext(
const message = err instanceof Error ? err.message : ''
// The downstream errors below are matched on stable English keywords
// emitted by year-end-service. Do NOT include the raw message in
// details — it may contain DB-sourced names; UI surfacing relies on
// details: it may contain DB-sourced names; UI surfacing relies on
// the structured message_sv / message_en pair.
if (/Next fiscal period already has opening balance/i.test(message)) {
return errorResponseFromCode('YEAR_END_NEXT_PERIOD_HAS_IB', opLog, { requestId })
@@ -233,7 +233,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(body.data).toBeDefined()
})
// Partial coverage: lock-through covers only part of the period — must still block.
// Partial coverage: lock-through covers only part of the period: must still block.
it('rejects forward period creation when company-wide lock only partially covers prior period', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
@@ -285,7 +285,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
})
// Regression (2026-06-16): a company with FY 2024 + FY 2026 but no
// FY 2025 could not create the missing year — the old code only allowed
// FY 2025 could not create the missing year: the old code only allowed
// chaining before the earliest or after the latest period. A period that
// exactly fills an interior gap must be allowed.
it('allows filling a gap between two existing periods', async () => {
@@ -310,7 +310,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
{ id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
],
})
// Starts 2025-02-01 instead of 2025-01-01 — would leave a hole after FY 2024.
// Starts 2025-02-01 instead of 2025-01-01: would leave a hole after FY 2024.
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-02-01', period_end: '2025-12-31' })
const res = await POST(req)
expect(res.status).toBe(400)
@@ -321,7 +321,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
// Regression: the start check only constrains the predecessor side. A gap-fill
// period that starts correctly (day after FY 2024) but ends BEFORE the day
// before FY 2026 would leave a fresh sub-gap while still relinking FY 2026's
// previous_period_id onto it — a broken continuity chain. The end-adjacency
// previous_period_id onto it, a broken continuity chain. The end-adjacency
// guard must reject it.
it('rejects a gap-fill period that does not end the day before the successor', async () => {
buildMockSupabase({
@@ -330,7 +330,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
{ id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
],
})
// Correct start (2025-01-01) but ends 2025-11-30 — leaves a hole before FY 2026.
// Correct start (2025-01-01) but ends 2025-11-30: leaves a hole before FY 2026.
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-11-30' })
const res = await POST(req)
expect(res.status).toBe(400)
@@ -339,7 +339,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
})
// A gap fill is a backfill (like backward chaining), so the "prior year must be
// locked" guard must NOT apply — otherwise the two open neighbours would block it.
// locked" guard must NOT apply: otherwise the two open neighbours would block it.
it('allows a gap fill even when both neighbouring years are open', async () => {
buildMockSupabase({
allPeriods: [
+5 -5
View File
@@ -83,7 +83,7 @@ export async function POST(request: Request) {
if (isPrepend) {
// Prepend before the earliest period: new period_end must be the day before
// the earliest period starts. Skip the "no open prior period" constraint —
// the earliest period starts. Skip the "no open prior period" constraint:
// backfilling an earlier year needs that year to stay open.
const expectedEnd = new Date(earliest.period_start + 'T12:00:00Z')
expectedEnd.setUTCDate(expectedEnd.getUTCDate() - 1)
@@ -97,7 +97,7 @@ export async function POST(request: Request) {
} else {
// Forward-like: either append a new latest year OR fill an interior gap
// between two existing years. Both must chain onto their immediate
// predecessor — i.e. start the day after it ends. When appending, the
// predecessor, i.e. start the day after it ends. When appending, the
// predecessor IS the latest period (original forward-chaining behaviour);
// when filling a gap, it's the year just before the hole.
if (predecessor) {
@@ -112,10 +112,10 @@ export async function POST(request: Request) {
}
}
// No predecessor here means the new period reaches back over the earliest
// existing period (an overlap) — the overlap check below returns 409.
// existing period (an overlap): the overlap check below returns 409.
// Gap fill: the new period must also butt up against its SUCCESSOR — end
// exactly the day before the successor starts — so it fills the hole
// Gap fill: the new period must also butt up against its SUCCESSOR (end
// exactly the day before the successor starts) so it fills the hole
// completely. The predecessor check above only constrains the start side.
// Without this end check a too-short period would leave a fresh sub-gap
// yet still get the successor's previous_period_id relinked onto it
@@ -16,8 +16,8 @@
* its own (still-active) accrual JE.
*
* Remediation per affected payment:
* 1. reverseEntry(payment_je) — storno cancels Dr 1930 / Cr 30xx / Cr 26xx
* 2. createInvoicePaymentJournalEntry — posts the correct Dr 1930 / Cr 1510
* 1. reverseEntry(payment_je): storno cancels Dr 1930 / Cr 30xx / Cr 26xx
* 2. createInvoicePaymentJournalEntry: posts the correct Dr 1930 / Cr 1510
* 3. Re-link invoice_payments + transactions to the new JE
*
* Net effect on the books: 30xx and 26xx are restored to their correct
@@ -79,7 +79,7 @@ export async function DELETE(
// Authorization is company-scoped, not user-scoped: any non-viewer member
// of the active company may revoke any exemption in that company. The flag
// is a shared bookkeeping artefact (same model as booking_template_library,
// mapping_rules, etc.) — exemptions are reviewed as a team. The audit_log
// mapping_rules, etc.): exemptions are reviewed as a team. The audit_log
// trigger captures the DELETE with actor_id so accountability is preserved.
const { error } = await supabase
.from('journal_entry_no_doc_required')
@@ -5,10 +5,10 @@ import { getJournalEntryUnderlagReferences } from '@/lib/core/bookkeeping/journa
/**
* GET /api/bookkeeping/journal-entries/[id]/references
*
* Resolves the verifikation's followable underlag references — the linked
* Resolves the verifikation's followable underlag references, the linked
* customer / supplier invoices that identify the affärshändelse. Lets the
* verifikat view make the verifieringskedja traceable from the verifikat side,
* not only from the invoice side (BFL 5 kap 7§ — hänvisning till underlag;
* not only from the invoice side (BFL 5 kap 7§, hänvisning till underlag;
* BFNAR 2013:2). Read-only.
*
* An id that doesn't belong to the active company resolves to no references
@@ -1,6 +1,6 @@
/**
* Tests for GET /api/bookkeeping/journal-entries/[id]/retag-log
* (dimensions plan PR6 — the immutable retag history).
* (dimensions plan PR6, the immutable retag history).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
@@ -4,7 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
/**
* GET /api/bookkeeping/journal-entries/[id]/retag-log
*
* The entry's dimension retag history (dimensions plan PR6) — the immutable
* The entry's dimension retag history (dimensions plan PR6), the immutable
* before/after trail behind every Tier-2 retag, newest first.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
@@ -110,10 +110,10 @@ export async function DELETE(
}
/**
* PATCH — edit a DRAFT verifikat in place (header + lines). Only drafts are
* PATCH: edit a DRAFT verifikat in place (header + lines). Only drafts are
* editable; updateDraftEntry rejects committed entries with a 409, and the DB
* immutability trigger is the backstop. Uses withRouteContext (MFA + write gate)
* — the GET/DELETE above predate that wrapper and are intentionally left as-is.
* immutability trigger is the backstop. Uses withRouteContext (MFA + write gate):
* the GET/DELETE above predate that wrapper and are intentionally left as-is.
*/
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal_entry.update',
@@ -7,11 +7,11 @@ import { seedCompany, insertBalancedLines } from '@/tests/pg/fixtures'
// list_fiscal_period_entries_with_related (migrations 20260621130500 +
// 20260629160000).
// - exclude_draft: drafts kept off the committed list (own "Utkast" surface).
// - collapse_corrections: a correction group renders as ONE row — the live
// - collapse_corrections: a correction group renders as ONE row: the live
// correction; the storno and the reversed original it replaced are hidden.
// - series: voucher-series filter; pushed into the RPC so total_count reflects
// the filtered set (the route used to post-filter and recompute count from
// one page, breaking pagination — #798).
// one page, breaking pagination, #798).
// total_count must stay in lockstep with the filtered set so pagination holds.
describe('list_fiscal_period_entries_with_related: draft + correction filters', () => {
// Insert a journal_entry directly so we can set the storno/correction link
@@ -143,7 +143,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters',
expect(Number(seriesB[0]!.total_count)).toBe(3)
// The #798 regression: with a page smaller than the filtered set, the page
// truncates but total_count still reports the full filtered count (3) — the
// truncates but total_count still reports the full filtered count (3): the
// paginator can reach every B entry instead of stopping after one page.
const firstPage = await callRpc(companyId, fiscalPeriodId, { series: 'B', limit: 2 })
expect(firstPage).toHaveLength(2)
@@ -76,7 +76,7 @@ describe('GET /api/bookkeeping/journal-entries', () => {
date_to: '2024-12-31',
limit: '10',
offset: '5',
// Strict period filtering — exercises the PostgREST path, not the RPC.
// Strict period filtering: exercises the PostgREST path, not the RPC.
include_related: 'false',
},
})
+5 -5
View File
@@ -40,14 +40,14 @@ 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
// '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
// Free-text search over the voucher description (verifikationstext). When set,
// we take the direct-query path below (the include_related RPC can't search),
// which filters strictly by fiscal_period_id. So search is scoped to the
// selected fiscal period / company and — like voucher sort — does NOT surface
// selected fiscal period / company and (like voucher sort) does NOT surface
// cross-period follow-up entries: every result stays inside the selected
// year's series (the BFL-compliant per-year view). It narrows the period, it
// never widens it.
@@ -68,10 +68,10 @@ export async function GET(request: Request) {
// Voucher-sort path: include_related RPC doesn't support voucher ordering,
// so fall through to the direct query below. This means voucher sort is
// *strict by fiscal_period_id* — cross-period follow-up entries that the
// *strict by fiscal_period_id*: cross-period follow-up entries that the
// RPC normally surfaces under date sort are excluded under voucher sort.
// That's intentional: voucher numbers are series-scoped within a fiscal
// year (BFL 5 kap 6–7 §§), so showing series A1, A2 … alongside entries
// year (BFL 5 kap 6-7 §§), so showing series A1, A2 … alongside entries
// belonging to a different year's series would be misleading. The trade-off
// is that the visible row count may differ between sort modes for the same
// period; the strict count is the BFL-compliant view of that year.
@@ -153,7 +153,7 @@ export async function GET(request: Request) {
if (search) {
// Escape LIKE wildcards (\ % _) so they match literally, and cap the needle
// length (≤200 chars) — both handled by the shared escapeLikePattern helper.
// length (≤200 chars): both handled by the shared escapeLikePattern helper.
// The cap bounds DB work against oversized/pathological inputs (compliance
// A.8.28 / ASVS V1.2.5); escaping prevents silent over-matching on values
// like "50%". Supabase parameterises the value, so this is not about SQLi.
@@ -1,6 +1,6 @@
/**
* Tests for POST /api/bookkeeping/journal-entry-lines/[lineId]/retag
* (dimensions plan PR6 — Tier-2 retro-tagging via the audited RPC).
* (dimensions plan PR6: Tier-2 retro-tagging via the audited RPC).
*
* Covers: 401, validation 400 (bad bag / short reason), the rule-violation
* 409 passthrough (Swedish RPC errors surface verbatim), unexpected RPC
@@ -81,7 +81,7 @@ describe('POST /api/bookkeeping/journal-entry-lines/[lineId]/retag', () => {
it('passes rule violations through as 409 with the Swedish message', async () => {
rpcMock.mockResolvedValue({
data: null,
error: { code: 'P0001', message: 'Perioden är stängd — använd rättelseverifikat (storno) för att ändra dimensioner.' },
error: { code: 'P0001', message: 'Perioden är stängd: använd rättelseverifikat (storno) för att ändra dimensioner.' },
})
const response = await POST(
@@ -8,8 +8,8 @@ import { RetagLineDimensionsSchema } from '@/lib/api/schemas'
*
* Tier-2 retro-tagging (dimensions plan PR6): change ONLY the dimension tags
* on a posted line, through the audited retag_line_dimensions RPC. The RPC
* enforces everything — posted status, open period, company lock date,
* active registry values, writer role — and writes the immutable
* enforces everything (posted status, open period, company lock date,
* active registry values, writer role) and writes the immutable
* dimension_retag_log row before the carve-out UPDATE. Affects
* internredovisning only, never the verifikat itself.
*/
@@ -34,7 +34,7 @@ export const POST = withRouteContext<{ params: Promise<{ lineId: string }> }>(
if (error) {
// Classify by SQLSTATE, not message text (#867 review): every rule
// violation in the RPC is a plain RAISE EXCEPTION (P0001) with a
// human-readable Swedish message — surface those verbatim as 409 so
// human-readable Swedish message: surface those verbatim as 409 so
// the dialog shows the specific rule. The tenant guard raises 42501.
// Anything else is unexpected infrastructure failure → 500 + log.
const message = error.message ?? 'Kunde inte ändra dimensioner'
@@ -16,7 +16,7 @@ const BatchNoDocSchema = z.object({
* underlag" in one action instead of toggling each one.
*
* The exemption is shared bookkeeping metadata (company-scoped, like mapping
* rules) — the audit_log trigger records the actor.
* rules): the audit_log trigger records the actor.
*/
export const POST = withRouteContext(
'journal_entry.batch_no_document_required',
@@ -26,7 +26,7 @@ const isoDate = z.string().refine(
const BulkMissingSchema = z.object({
period_id: z.string().uuid().nullable().optional(),
// Single uppercase verifikationsserie (A–Z); the list sends null for "all".
// Single uppercase verifikationsserie (A-Z); the list sends null for "all".
series: z.string().regex(/^[A-Z]$/).nullable().optional(),
date_from: isoDate.nullable().optional(),
date_to: isoDate.nullable().optional(),
@@ -40,7 +40,7 @@ const BulkMissingSchema = z.object({
/**
* Mark every posted, document-requiring verifikat that currently lacks an
* underlag AND matches the active list filters (period / series / date / search)
* as "Inget underlag krävs" — across all pages, in one action. This is the
* as "Inget underlag krävs", across all pages, in one action. This is the
* scalable remedy for the "thousands of saknade underlag after a migration"
* problem; the per-entry batch route handles selective marking.
*
@@ -200,7 +200,7 @@ describe('GET /api/bookkeeping/voucher-sequences/next', () => {
expect(response.status).toBe(200)
// No mapping for invoice_cash_payment → engine-matching fallback of 'A'
// (the global default is intentionally NOT used here — no consolidation).
// (the global default is intentionally NOT used here, no consolidation).
expect(body.data).toEqual({ next: 1, series: 'A', fiscal_period_id: 'period-1' })
})
@@ -138,7 +138,7 @@ describe('POST /api/company/[id]/delete', () => {
it('rejects the stale companies.name when a settings name exists (only the displayed name is accepted)', async () => {
// Security: the UI only ever shows company_settings.company_name when set, so
// the server must not accept the stale companies.name as an alternative
// confirmation — that would be a delete path the user was never shown.
// confirmation: that would be a delete path the user was never shown.
mockAuth('user-1')
mockService({
companies: [{ data: { id: 'c1', name: 'Gammalt namn', archived_at: null }, error: null }],
+2 -2
View File
@@ -50,7 +50,7 @@ export async function POST(
// and user.id for defense in depth.
const service = createServiceClient()
// 1. Fetch company — must exist and be active. Query via service client
// 1. Fetch company: must exist and be active. Query via service client
// because we need the raw row regardless of RLS visibility, but we still
// enforce membership below.
const { data: company, error: fetchError } = await service
@@ -97,7 +97,7 @@ export async function POST(
// (companies.name may be stale) and CompanyDangerZone gates on that value, so
// the server must accept ONLY that single name. Accepting the stale
// companies.name as an alternative would open a confirmation path the user was
// never shown — weakening the gate on an irreversible action (ASVS V8.2.1).
// never shown, weakening the gate on an irreversible action (ASVS V8.2.1).
// Case-sensitive trim, mirror of the client-side check.
const { data: companySettings } = await service
.from('company_settings')
+2 -2
View File
@@ -6,7 +6,7 @@ import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
* GET /api/company/check-org-number?org_number=XXXXXXXXXX
*
* Returns `{ data: { exists: boolean, companies: { id, name }[] } }` for the
* companies the CURRENT USER already has with the given organisation number —
* companies the CURRENT USER already has with the given organisation number:
* scoped to their own account only.
*
* Org-number reuse across the platform is intentionally allowed (see
@@ -18,7 +18,7 @@ import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
*
* Normalizes input with the same rule as the create action so a 12-digit form
* still matches a stored 10-digit canonical. Returns no matches for malformed
* input — the create action rejects that separately as `org_number_invalid`.
* input: the create action rejects that separately as `org_number_invalid`.
*/
export async function GET(request: Request) {
// requireAuth() (not a raw getUser()) so MFA AAL2 is enforced on hosted before
+5 -5
View File
@@ -25,7 +25,7 @@ const K3_LATENT_TAX_ACCOUNTS = ['2240', '8940'] as const
* when a tab was hidden/backgrounded during a switch in another tab) and
* force a hard reload on mismatch.
*
* Never cached — the whole point is that the response reflects the current
* Never cached: the whole point is that the response reflects the current
* authoritative value in user_preferences.
*/
export async function GET() {
@@ -68,7 +68,7 @@ const PatchBodySchema = z.object({
* company. Separate from /api/settings (which writes to `company_settings`)
* because the columns live on different tables.
*
* Currently scoped to `accounting_framework` (K2 / K3) — only meaningful for
* Currently scoped to `accounting_framework` (K2 / K3), only meaningful for
* entity_type='aktiebolag'. The handler rejects K3 for non-AB to prevent
* impossible chart-of-accounts states downstream.
*/
@@ -90,7 +90,7 @@ export async function PATCH(request: Request) {
const updates: Record<string, unknown> = {}
if (validation.data.accounting_framework !== undefined) {
// Only AB can opt in to K3 — EF stays on the simpler EF rules and never
// Only AB can opt in to K3; EF stays on the simpler EF rules and never
// touches K2/K3. Fetch the entity_type before applying.
const { data: company } = await supabase
.from('companies')
@@ -116,7 +116,7 @@ export async function PATCH(request: Request) {
}
if (Object.keys(updates).length === 0) {
// Nothing to write — surface the current row so the client can refresh
// Nothing to write: surface the current row so the client can refresh
// its local state without a no-op write.
const { data } = await supabase
.from('companies')
@@ -142,7 +142,7 @@ export async function PATCH(request: Request) {
// them for K2 companies via k2_excluded=true, so without this backfill
// the engine cannot resolve account_id for the first latent-tax post.
// Wrapped in try/catch so a CoA insert failure does not block the
// framework update — the user can still re-trigger the seed later.
// framework update: the user can still re-trigger the seed later.
// The reverse switch (K3 → K2) intentionally keeps the rows for audit
// history; the legal record of past K3 postings must remain intact.
if (data.accounting_framework === 'k3') {
+1 -1
View File
@@ -169,7 +169,7 @@ export async function POST(request: Request) {
console.error('[company/members/invite] email send failed:', result.error)
}
} else {
console.warn('[company/members/invite] email service not configured — skipping send', {
console.warn('[company/members/invite] email service not configured: skipping send', {
to: email,
})
}
+2 -2
View File
@@ -3,7 +3,7 @@
*
* Verifies that POST /api/customers returns 403 when the caller's
* requireWritePermission check returns an error response. This is a
* canary test — if it breaks, the wiring between mutating routes and
* canary test: if it breaks, the wiring between mutating routes and
* requireWritePermission has drifted.
*
* The full per-role behavior of requireWritePermission itself is
@@ -40,7 +40,7 @@ vi.mock('@/lib/auth/require-write', () => ({
import { POST } from '../route'
describe('POST /api/customers — viewer role gate', () => {
describe('POST /api/customers: viewer role gate', () => {
const mockUser = { id: 'user-1', email: 'viewer@test.se' }
beforeEach(() => {
+1 -1
View File
@@ -5,7 +5,7 @@ import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET /api/deadlines/status/cron — daily 06:00 UTC.
* GET /api/deadlines/status/cron: daily 06:00 UTC.
* Updates deadline statuses across all companies.
*/
export const GET = withCronContext('cron.deadlines_status', async (_request, ctx) => {
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* PATCH /api/dimensions/[id] — update a dimension (name / is_active / sort_order).
* PATCH /api/dimensions/[id]: update a dimension (name / is_active / sort_order).
*
* Guard rails:
* - Renaming an is_system dimension (1 = Kostnadsställe, 6 = Projekt) is
@@ -50,7 +50,7 @@ export const PATCH = withRouteContext(
return errorResponseFromCode('DIMENSION_SYSTEM_RENAME', opLog, { requestId })
}
// Sparse update — only the fields the caller actually sent.
// Sparse update: only the fields the caller actually sent.
const updateData: Record<string, unknown> = {}
for (const key of ['name', 'is_active', 'sort_order'] as const) {
if (body[key] !== undefined) updateData[key] = body[key]
@@ -1,12 +1,12 @@
/**
* PATCH /api/dimensions/[id]/values/[valueId] — update a dimension value
* PATCH /api/dimensions/[id]/values/[valueId]: update a dimension value
* (name / is_active / start_date / end_date; `code` is immutable in v1).
* DELETE /api/dimensions/[id]/values/[valueId] — delete an UNREFERENCED value.
* DELETE /api/dimensions/[id]/values/[valueId]: delete an UNREFERENCED value.
*
* Deleting a value referenced by posted/reversed lines is blocked by the DB
* retention trigger (enforce_dimension_value_retention, BFL 7-year
* philosophy). Its Swedish message ("Värdet "X" används på bokförda verifikat
* och kan inte tas bort — arkivera det istället.") is surfaced verbatim as a
* och kan inte tas bort: arkivera det istället.") is surfaced verbatim as a
* 409 DIMENSION_VALUE_REFERENCED so the register UI can toast it and offer
* archive instead.
*/
@@ -58,7 +58,7 @@ export const PATCH = withRouteContext(
}
}
// Sparse update — only the fields the caller actually sent. `code` is
// Sparse update: only the fields the caller actually sent. `code` is
// deliberately absent from the schema: renaming a code would silently
// orphan every line tagged with it.
const updateData: Record<string, unknown> = {}
@@ -107,7 +107,7 @@ export const DELETE = withRouteContext(
.select('id')
if (error) {
// P0001 = plpgsql RAISE EXCEPTION — the retention trigger refusing the
// P0001 = plpgsql RAISE EXCEPTION: the retention trigger refusing the
// delete. Surface its Swedish message verbatim (it names the code).
if (error.code === 'P0001') {
return errorResponseFromCode('DIMENSION_VALUE_REFERENCED', opLog, {
+4 -4
View File
@@ -1,12 +1,12 @@
/**
* POST /api/dimensions/[id]/values — create a dimension value (SIE #OBJEKT).
* POST /api/dimensions/[id]/values: create a dimension value (SIE #OBJEKT).
*
* Codes are validated against the strict Fortnox format
* (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) for user-created values — the DB CHECK is
* (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) for user-created values: the DB CHECK is
* looser by design so legacy free-text codes survive the backfill/SIE import,
* but new registry codes minted here stay portable. Duplicate codes within the
* dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE with a Swedish message.
* `code` is immutable after creation (v1: no rename — retag instead).
* `code` is immutable after creation (v1: no rename, retag instead).
*/
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
@@ -32,7 +32,7 @@ export const POST = withRouteContext(
const body = result.data
// The dimension must exist and belong to the active company (defense in
// depth alongside the composite FK — a foreign dimension id 404s here).
// depth alongside the composite FK: a foreign dimension id 404s here).
const { data: dimension, error: dimError } = await supabase
.from('dimensions')
.select('id, resets_annually')
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for PATCH /api/dimensions/[id] (update dimension).
*
* Covers: 401, empty-body validation (400), 404, the is_system name-lock
* ("Systemdimensioner kan inte döpas om" — 400 DIMENSION_SYSTEM_RENAME),
* ("Systemdimensioner kan inte döpas om", 400 DIMENSION_SYSTEM_RENAME),
* archiving a system dimension (allowed), and the happy rename of a custom
* dimension.
*/
@@ -80,7 +80,7 @@ describe('POST /api/dimensions/import-existing', () => {
})
// Existing values: BUTIK is already registered under dim 1.
enqueue({ data: [{ dimension_id: 'dim-1', code: 'BUTIK' }] })
// Upsert of the two missing values succeeds — created counts returned rows.
// Upsert of the two missing values succeeds: created counts returned rows.
enqueue({ data: [{ id: 'nv1' }, { id: 'nv2' }] })
const response = await POST(request(), noParams)
@@ -114,7 +114,7 @@ describe('POST /api/dimensions/import-existing', () => {
expect(body.created).toBe(2)
})
it('tolerates duplicates in the batch — created counts only the rows the upsert returned', async () => {
it('tolerates duplicates in the batch: created counts only the rows the upsert returned', async () => {
enqueue({ data: null }) // ensure RPC
enqueue({
data: [
@@ -125,7 +125,7 @@ describe('POST /api/dimensions/import-existing', () => {
enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims
enqueue({ data: [] }) // existing-values snapshot missed a raced KS02
// ignoreDuplicates upsert skips the conflicting row instead of aborting
// the batch — only KS01 comes back.
// the batch: only KS01 comes back.
enqueue({ data: [{ id: 'nv1' }] })
const response = await POST(request(), noParams)
@@ -138,7 +138,7 @@ describe('POST /api/dimensions/import-existing', () => {
it('creates a registry dimension for an unregistered dim number found on lines', async () => {
enqueue({ data: null }) // ensure RPC
enqueue({ data: [{ id: 'l1', dimensions: { '7': 'AVD-A' } }] }) // lines
// Registry only has the system dims — dim 7 is missing.
// Registry only has the system dims: dim 7 is missing.
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1 },
@@ -94,7 +94,7 @@ describe('PATCH /api/dimensions/[id]/values/[valueId]', () => {
expect(status).toBe(200)
expect(body.data.is_active).toBe(false)
// Code untouched — immutable in v1.
// Code untouched: immutable in v1.
expect(body.data.code).toBe('BUTIK')
})
@@ -116,7 +116,7 @@ describe('PATCH /api/dimensions/[id]/values/[valueId]', () => {
})
it('allows clearing dates (explicit null) without checking the dimension', async () => {
// start_date: null clears the field — a harmless no-op on any dimension,
// start_date: null clears the field, a harmless no-op on any dimension,
// so the route must not spend a dimension fetch on it. The single queued
// result feeds the update itself.
enqueue({
@@ -157,7 +157,7 @@ describe('DELETE /api/dimensions/[id]/values/[valueId]', () => {
it('surfaces the retention trigger as 409 with the trigger\'s Swedish message', async () => {
const triggerMessage =
'Värdet "BUTIK" används på bokförda verifikat och kan inte tas bort — arkivera det istället.'
'Värdet "BUTIK" används på bokförda verifikat och kan inte tas bort: arkivera det istället.'
enqueue({ error: { code: 'P0001', message: triggerMessage } })
const request = createMockRequest(URL_PATH, { method: 'DELETE' })
+1 -1
View File
@@ -97,7 +97,7 @@ describe('POST /api/dimensions/[id]/values', () => {
})
it('rejects start/end dates on a resets-annually dimension with 400', async () => {
// Kostnadsställe-style dims reset annually — value date ranges are only
// Kostnadsställe-style dims reset annually: value date ranges are only
// meaningful on accumulating dims (projekt).
enqueue({ data: { id: 'dim-1', resets_annually: true } })
Binary file not shown.
+4 -4
View File
@@ -1,12 +1,12 @@
/**
* GET /api/dimensions — the dimension registry (kostnadsställe/projekt + custom
* GET /api/dimensions: the dimension registry (kostnadsställe/projekt + custom
* dims) with nested values, for the register page and pickers.
*
* Calls ensure_company_dimensions first so the system dims (1 = Kostnadsställe,
* 6 = Projekt) always exist — lazy seeding keeps core zero-config for companies
* 6 = Projekt) always exist: lazy seeding keeps core zero-config for companies
* that never touch dimensions (dev_docs/dimensions_implementation_plan.md §6).
*
* Response contract (PR2 — the register UI builds against this exactly):
* Response contract (PR2: the register UI builds against this exactly):
* 200 { dimensions: [{ id, sie_dim_no, name, resets_annually, is_system,
* is_active, sort_order, values: [{ id, code, name, is_active,
* start_date, end_date }] }] }
@@ -45,7 +45,7 @@ interface DimensionRow {
export const GET = withRouteContext(
'dimension.list',
async (_request, ctx) => {
// dimensions_enabled is deliberately NOT enforced here — it is a
// dimensions_enabled is deliberately NOT enforced here: it is a
// UI-visibility flag only (dev_docs/dimensions_implementation_plan.md §2).
// Agents/MCP and SIE import must operate on the registry regardless of the
// toggle; the security boundary is company scoping (withRouteContext + RLS).
@@ -4,7 +4,7 @@
*
* Covers: 401, body validation (400 for empty line_ids / short reason / bad
* dimensions bag), the happy path (per-line RPC fan-out with p_user_id and
* changed/unchanged aggregation) and partial failure — the route returns 200
* changed/unchanged aggregation) and partial failure: the route returns 200
* with the raw Swedish RPC message per failed line.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -157,7 +157,7 @@ describe('POST /api/dimensions/tagging/apply', () => {
enqueue({
error: {
message:
'Perioden är låst — använd rättelseverifikat (storno) för att ändra dimensioner.',
'Perioden är låst: använd rättelseverifikat (storno) för att ändra dimensioner.',
},
})
@@ -172,7 +172,7 @@ describe('POST /api/dimensions/tagging/apply', () => {
line_id: LINE_B,
// Raw Swedish RPC message passes through untouched.
error:
'Perioden är låst — använd rättelseverifikat (storno) för att ändra dimensioner.',
'Perioden är låst: använd rättelseverifikat (storno) för att ändra dimensioner.',
},
])
})
@@ -138,7 +138,7 @@ describe('GET /api/dimensions/tagging/lines', () => {
}),
],
})
// Step 2: the COMPLETE line sets — entry-1 has a line the account filter
// Step 2: the COMPLETE line sets; entry-1 has a line the account filter
// would not have matched; it must still be present (whole-verifikat
// contract, null dimensions normalized to {}).
enqueue({
@@ -212,7 +212,7 @@ describe('GET /api/dimensions/tagging/lines', () => {
it('include_annulled=1 pulls in a counter-voucher that fell outside the filters', async () => {
// Step 1 returns only the storno leg (its original, entry-0, is outside
// the date range). The route must fetch entry-0 anyway — otherwise the
// the date range). The route must fetch entry-0 anyway: otherwise the
// workbench's motverifikat guard cannot see the missing leg and one-sided
// tagging slips through silently.
enqueue({ data: [makeRawEntry({ reverses_id: 'entry-0', entry_date: '2026-05-01' })] })
@@ -257,7 +257,7 @@ describe('GET /api/dimensions/tagging/lines', () => {
makeRawEntry({ id: 'entry-2', reverses_id: 'entry-1', voucher_number: 43 }),
],
})
// No counter query — next enqueued result is the line fetch.
// No counter query: next enqueued result is the line fetch.
enqueue({
data: [
makeRawLine(),
+3 -3
View File
@@ -1,9 +1,9 @@
/**
* POST /api/dimensions/tagging/apply — bulk retag of posted lines through the
* POST /api/dimensions/tagging/apply: bulk retag of posted lines through the
* ONE audited write path, the retag_line_dimensions RPC (dimensions plan PR6
* §3, migration 20260702170000).
*
* The body carries ONE dimensions object for ALL listed lines — the workbench
* The body carries ONE dimensions object for ALL listed lines: the workbench
* groups selected lines by their computed resulting map client-side and issues
* one POST per distinct map. The RPC is called per line (it locks, validates
* tier boundaries, writes the immutable before/after log and performs the
@@ -13,7 +13,7 @@
*
* 200 { data: { retagged, unchanged, failed: [{ line_id, error }] } }
*
* RPC error messages pass through as-is — they are already Swedish domain
* RPC error messages pass through as-is: they are already Swedish domain
* errors (closed/locked period, lock date, archived/unknown codes, drafts).
*/
import { NextResponse } from 'next/server'
+8 -8
View File
@@ -1,13 +1,13 @@
/**
* GET /api/dimensions/tagging/lines — posted VOUCHERS (with their complete
* GET /api/dimensions/tagging/lines: posted VOUCHERS (with their complete
* line sets) for the bulk retro-tagging workbench (dimensions plan PR6 §3,
* voucher-level rework).
*
* The verifikat is the unit of work: filters select qualifying vouchers
* (period, entry-date range, free text, "has a line in the account range",
* "has an untagged line") and the response carries every line of each
* qualifying voucher so tagging a voucher means tagging a complete verifikat
* — never the filtered subset of one.
* qualifying voucher so tagging a voucher means tagging a complete verifikat,
* never the filtered subset of one.
*
* Reversal pairs are EXCLUDED by default: an annulled entry and its storno
* net to zero in every dimension bucket as long as both sides carry the same
@@ -67,7 +67,7 @@ export const GET = withRouteContext(
const q = validation.data
// Step 1: qualifying vouchers. Line-level filters (account range, only
// untagged) become "voucher HAS such a line" via the inner join — the
// untagged) become "voucher HAS such a line" via the inner join: the
// parent row appears once regardless of how many lines match.
let entryQuery = supabase
.from('journal_entries')
@@ -75,13 +75,13 @@ export const GET = withRouteContext(
'id, entry_date, voucher_number, voucher_series, description, reversed_by_id, reverses_id, fiscal_period_id, journal_entry_lines!inner(id)',
)
.eq('company_id', companyId)
// Posted only — drafts are edited directly in the voucher editor and the
// Posted only: drafts are edited directly in the voucher editor and the
// retag RPC rejects them anyway.
.eq('status', 'posted')
if (q.include_annulled !== '1') {
// Default view: no reversal pairs. Both sides net to zero in every
// dimension bucket when kept together, so they are pure noise here —
// dimension bucket when kept together, so they are pure noise here,
// and hiding them makes tagging one side without the other impossible.
entryQuery = entryQuery.is('reversed_by_id', null).is('reverses_id', null)
}
@@ -90,7 +90,7 @@ export const GET = withRouteContext(
if (q.date_from) entryQuery = entryQuery.gte('entry_date', q.date_from)
if (q.date_to) entryQuery = entryQuery.lte('entry_date', q.date_to)
if (q.text) {
// Escape LIKE wildcards (\ % _) so they match literally — same posture
// Escape LIKE wildcards (\ % _) so they match literally, same posture
// as the journal-entries list route.
entryQuery = entryQuery.ilike('description', `%${escapeLikePattern(q.text)}%`)
}
@@ -131,7 +131,7 @@ export const GET = withRouteContext(
// counter-entry fell outside the filters (e.g. the storno is in a later
// month than the date range), pull the counter in anyway. Without it the
// workbench's motverifikat guard cannot see the missing leg and the user
// could tag one side alone — exactly the P&L skew the guard exists to
// could tag one side alone, exactly the P&L skew the guard exists to
// prevent (Srf U 14 gross reporting). Counters ride on top of the cap:
// they are required for correctness, not part of the browsed page.
if (q.include_annulled === '1') {
@@ -9,15 +9,15 @@ import { requireCompanyId } from '@/lib/company/context'
// touching storage (no signed URL creation per poll).
//
// Derived status:
// running — extracted_at IS NULL (pipeline hasn't stamped yet)
// succeeded — extracted_at IS NOT NULL AND extracted_data IS NOT NULL
// unsupported — extraction_model = 'skipped:*' (HEIC, ZIP, …)
// failed — extracted_at IS NOT NULL AND extracted_data IS NULL AND
// running : extracted_at IS NULL (pipeline hasn't stamped yet)
// succeeded : extracted_at IS NOT NULL AND extracted_data IS NOT NULL
// unsupported : extraction_model = 'skipped:*' (HEIC, ZIP, …)
// failed : extracted_at IS NOT NULL AND extracted_data IS NULL AND
// extraction_model = 'failed:*'
// disabled — the document-extraction extension isn't enabled (column
// disabled : the document-extraction extension isn't enabled (column
// stays untouched indefinitely). Client times out and shows
// a quiet fallback. We don't distinguish this from running
// server-side — the client decides based on elapsed time.
// server-side: the client decides based on elapsed time.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
+3 -3
View File
@@ -10,7 +10,7 @@ import { createServiceClient } from '@/lib/supabase/server'
* an <iframe> or <img> tag.
*
* Supabase Storage signed URLs return `Content-Disposition: attachment`,
* which browsers refuse to render inline — that triggers the
* which browsers refuse to render inline: that triggers the
* "Det här innehållet har blockerats" error in journal entry previews.
*
* Defense in depth: the user's cookie-bound client authorizes access
@@ -31,7 +31,7 @@ const EXTENSION_MIME_MAP: Record<string, string> = {
* `mime_type = null` or `application/octet-stream` (browsers sometimes
* report empty File.type for files dragged from certain sources). Combined
* with the new `X-Content-Type-Options: nosniff` header on this route,
* that broke Chrome's PDF viewer for older rows — the plugin would load
* that broke Chrome's PDF viewer for older rows: the plugin would load
* via <object type="application/pdf"> but refuse to parse a response
* served as octet-stream. Falling back to the file extension covers every
* legacy row without a DB backfill.
@@ -95,7 +95,7 @@ export async function GET(
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
'Content-Disposition': `inline; filename="${safeFileName}"`,
'Cache-Control': 'private, max-age=300',
// Block MIME sniffing — Content-Type is derived from DB metadata
// Block MIME sniffing: Content-Type is derived from DB metadata
// (with extension fallback for legacy rows), never from response
// content. Without nosniff a tampered file_name extension could
// serve a stored document under an attacker-chosen MIME type.
@@ -25,7 +25,7 @@ import { validateDocumentMagicBytes } from '@/lib/core/documents/document-servic
import { NextResponse } from 'next/server'
// v4 UUIDs (variant 'a' / 'b' in the 4th group) so Zod's stricter validators
// accept them — the looser `2222...` style fails on the variant check.
// accept them: the looser `2222...` style fails on the variant check.
const mockUser = { id: '11111111-1111-4111-a111-111111111111' }
const validDocId = '22222222-2222-4222-a222-222222222222'
const companyId = '33333333-3333-4333-a333-333333333333'
@@ -159,7 +159,7 @@ describe('GET /api/documents/[id]/integrity', () => {
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data.valid).toBe(false)
// The whole point of the V1.2.5 / Art 25(2) hardening — internal text
// The whole point of the V1.2.5 / Art 25(2) hardening: internal text
// never appears in the response.
expect(body.data.reason).toBeUndefined()
expect(JSON.stringify(body)).not.toContain('storage/v1/object')
@@ -194,7 +194,7 @@ describe('GET /api/documents/[id]/integrity', () => {
it('does not import the service-role supabase client', async () => {
// The download must go through the per-request user-scoped client so
// RLS on storage.objects can act as defense-in-depth. Statically
// verifying the source is the cleanest check — runtime mocking of the
// verifying the source is the cleanest check: runtime mocking of the
// service-client export would not catch a future regression where
// someone added an import but conditionally used it.
const fs = await import('node:fs/promises')
@@ -208,7 +208,7 @@ describe('GET /api/documents/[id]/integrity', () => {
// The route's first `from('document_attachments')` chain must include
// `.eq('is_current_version', true)` so superseded versions cannot have
// their bytes probed via this surface. Source-level check rather than
// runtime spying — the proxy-based queued mock collapses every chained
// runtime spying: the proxy-based queued mock collapses every chained
// method into the same handler, so introspecting individual .eq calls
// is not feasible without rebuilding the mock.
const fs = await import('node:fs/promises')
+4 -4
View File
@@ -12,7 +12,7 @@ const ParamsSchema = z.object({ id: z.string().uuid() })
* GET /api/documents/:id/integrity
*
* Probes the actual stored bytes against the declared MIME type. Used by the
* Bilagor modal to surface a clear "this file is corrupt — please re-upload"
* Bilagor modal to surface a clear "this file is corrupt: please re-upload"
* warning instead of relying on the browser's PDF viewer error UI, which
* only fires after the user has already tried to view the file.
*
@@ -21,7 +21,7 @@ const ParamsSchema = z.object({ id: z.string().uuid() })
* after those rows were written. This endpoint lets the UI detect and steer
* the user toward replacing them.
*
* Response shape is intentionally minimal — { valid: boolean } only. The
* Response shape is intentionally minimal: { valid: boolean } only. The
* reason for an invalid result is logged server-side rather than returned
* to the client to avoid information disclosure (V1.2.5 / GDPR Art 25(2))
* and to keep this from being a probe surface for storage internals.
@@ -91,8 +91,8 @@ export async function GET(
}
// Only the first 16 bytes are needed for magic-byte detection (PDF/PNG
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth — the
// full blob is already downloaded — but it makes the intent explicit and
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth (the
// full blob is already downloaded) but it makes the intent explicit and
// keeps memory churn off the hot path for large PDFs.
const headerBuffer = await blob.slice(0, 16).arrayBuffer()
const magicError = validateDocumentMagicBytes(headerBuffer, doc.mime_type)
@@ -30,7 +30,7 @@ import { NextResponse } from 'next/server'
const mockUser = { id: 'user-1', email: 'test@test.se' }
// Body fields are uuid-validated (LinkDocumentSchema) — request fixtures must
// Body fields are uuid-validated (LinkDocumentSchema): request fixtures must
// be real UUIDs. The enqueue mock rows keep their short ids; the queued mock
// doesn't correlate request input with mocked output.
const JE_ID = '11111111-1111-4111-8111-111111111111'
@@ -152,7 +152,7 @@ describe('POST /api/documents/[id]/link', () => {
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
})
it('tolerates a failing transaction pin — the JE link is the primary effect', async () => {
it('tolerates a failing transaction pin: the JE link is the primary effect', async () => {
enqueue({ data: { id: 'je-1' } }) // journal entry company check
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update
enqueue({ data: null, error: { message: 'rls denied' } }) // transaction pin fails
@@ -199,7 +199,7 @@ describe('POST /api/documents/[id]/link', () => {
})
it('rejects a journal entry outside the active company with DOC_LINK_ENTRY_NOT_FOUND', async () => {
// The company-scoped lookup finds no row — same result whether the id is
// The company-scoped lookup finds no row: same result whether the id is
// bogus or belongs to another tenant. The document must never be updated.
enqueue({ data: null }) // journal entry company check → no match
const res = await POST(
+6 -6
View File
@@ -8,13 +8,13 @@ import { LinkDocumentSchema } from '@/lib/api/schemas'
ensureInitialized()
/**
* POST /api/documents/[id]/link — link a document to a journal entry.
* POST /api/documents/[id]/link: link a document to a journal entry.
*
* Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string, transaction_id?: string }
*
* When `inbox_item_id` is supplied (the "choose from inbox" flow), the inbox
* item is stamped with the verifikat id after a successful link so it drops out
* of the active inbox into "Bokförda" — reusing the inbox's own
* of the active inbox into "Bokförda": reusing the inbox's own
* created_journal_entry_id lifecycle. The document link is the legally-relevant
* write and happens first; the inbox stamp is operational housekeeping, so a
* stamp failure is logged but does not fail the request (the doc is correctly
@@ -23,7 +23,7 @@ ensureInitialized()
* When `transaction_id` is supplied (booking-flow callers that link underlag
* right after booking a bank transaction), the doc is also pinned to the
* transaction row (transactions.document_id) so the /transactions list shows
* the underlag indicator. Only set when the tx has no pin yet — first linked
* the underlag indicator. Only set when the tx has no pin yet: first linked
* doc wins, and an existing räkenskapsinformation pin is never swapped (which
* would trip the immutability trigger). Same best-effort posture as the inbox
* stamp: a failure is logged but does not fail the request.
@@ -64,13 +64,13 @@ export const POST = withRouteContext(
.update({ created_journal_entry_id: body.journal_entry_id })
.eq('id', body.inbox_item_id)
.eq('company_id', companyId!)
// Only stamp the inbox item that actually owns this document — a
// Only stamp the inbox item that actually owns this document: a
// mismatched pairing becomes a safe no-op rather than mis-marking an
// unrelated item as consumed.
.eq('document_id', id)
.select('id')
if (inboxError) {
// Non-fatal — the verifikat ↔ underlag link already succeeded.
// Non-fatal: the verifikat ↔ underlag link already succeeded.
opLog.warn('inbox item stamp after link failed', {
inboxItemId: body.inbox_item_id,
reason: inboxError.message,
@@ -96,7 +96,7 @@ export const POST = withRouteContext(
// for multi-doc bookings and avoids the BFL immutability trigger.
.is('document_id', null)
if (pinError) {
// Non-fatal — the verifikat ↔ underlag link already succeeded; the
// Non-fatal: the verifikat ↔ underlag link already succeeded; the
// pin is row-level UX on the /transactions list.
opLog.warn('transaction pin after link failed', {
transactionId: body.transaction_id,
+1 -1
View File
@@ -72,7 +72,7 @@ export async function GET(
/**
* DELETE /api/documents/:id
* Remove an uploaded document. Only permitted when the document is not yet
* linked to a journal entry — once linked, it is räkenskapsinformation under
* linked to a journal entry: once linked, it is räkenskapsinformation under
* BFL 7 kap 2§ and must be retained for 7 years. For linked docs the caller
* should use POST /api/documents/:id/versions to supersede via a new version.
*/
+4 -4
View File
@@ -7,15 +7,15 @@ import type { InvoiceExtractionResult } from '@/types'
ensureInitialized()
/**
* GET /api/documents/inbox-available — list invoice-inbox documents that are
* GET /api/documents/inbox-available: list invoice-inbox documents that are
* available to attach as underlag to a verifikat.
*
* Returns only *unconsumed* inbox items: those that have a file but have not
* yet become a supplier invoice, a direct journal entry, or been matched to a
* bank transaction — and whose underlying document is not already linked to a
* bank transaction, and whose underlying document is not already linked to a
* verifikation. This mirrors the inbox's own "Att göra" set, narrowed to items
* with an attachable file. Re-pointing an already-linked document is forbidden
* (BFL 7 kap — räkenskapsinformation is immutable), so those are excluded here
* (BFL 7 kap: räkenskapsinformation is immutable), so those are excluded here
* and the DB immutability trigger is the backstop.
*
* `invoice_inbox_items` is a core table, so a core route may read it directly
@@ -43,7 +43,7 @@ interface DocRow {
export const GET = withRouteContext('document.inbox_available', async (_request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
// 1) Eligible inbox items — company-scoped (defense in depth alongside RLS),
// 1) Eligible inbox items: company-scoped (defense in depth alongside RLS),
// unconsumed, with a document.
const { data: inboxRows, error: inboxError } = await supabase
.from('invoice_inbox_items')
+3 -3
View File
@@ -8,7 +8,7 @@ import type { DocumentUploadSource } from '@/types'
ensureInitialized()
/**
* POST /api/documents — upload a document to the WORM archive.
* POST /api/documents: upload a document to the WORM archive.
*
* multipart/form-data:
* file: the document file
@@ -72,7 +72,7 @@ export const POST = withRouteContext(
})
}
// Magic-byte validation rejections (validateDocumentMagicBytes) are a
// client problem, not a storage failure — surface as 400 with an
// client problem, not a storage failure: surface as 400 with an
// accurate message instead of the misleading "kunde inte sparas".
if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) {
opLog.warn('document upload rejected by content validation', { reason: message })
@@ -91,7 +91,7 @@ export const POST = withRouteContext(
)
/**
* GET /api/documents — list documents.
* GET /api/documents: list documents.
*
* Query params:
* journal_entry_id: filter by JE
+1 -1
View File
@@ -4,7 +4,7 @@ import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* GET /api/documents/verify/cron — weekly Sunday 03:00 UTC.
* GET /api/documents/verify/cron: weekly Sunday 03:00 UTC.
* Spot-checks WORM archive integrity by recomputing SHA-256 for the next
* batch of documents and writing INTEGRITY_FAILURE rows to the audit log
* for any mismatches.
@@ -36,7 +36,7 @@ vi.mock('@/lib/supabase/server', () => ({
capture.filters.push({ method: 'not', args })
return chain
})
// Thenable — awaiting the builder resolves the queued result.
// Thenable: awaiting the builder resolves the queued result.
chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve)
return chain
}),
@@ -89,7 +89,7 @@ describe('GET /api/events/cleanup/cron', () => {
['event_type', 'like', 'agent.%'],
])
// Pass 2: 180-day cutoff, no exclusions — sweeps the telemetry rows.
// Pass 2: 180-day cutoff, no exclusions, sweeps the telemetry rows.
const pass2 = deleteCalls[1]
const lt2 = pass2.filters.find((f) => f.method === 'lt')!
expect(daysAgo(lt2.args[1] as string)).toBeCloseTo(180, 0)
+3 -3
View File
@@ -4,13 +4,13 @@ import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
/**
* GET /api/events/cleanup/cron — daily 02:00 UTC.
* GET /api/events/cleanup/cron: daily 02:00 UTC.
*
* Differentiated retention:
* - Delivery events (invoice.created, transaction.synced, …): 30 days. They
* exist for external automation polling (n8n/Make/Zapier) and go stale fast.
* - Agent telemetry (mcp.*, agent.*): 180 days. Error-rate trends and
* skill-load correlation need more than one month of signal — a 30-day
* skill-load correlation need more than one month of signal: a 30-day
* window made it impossible to tell whether a tool or skill change actually
* moved failure rates.
*
@@ -41,7 +41,7 @@ export const GET = withCronContext('cron.events_cleanup', async (_request, ctx)
return errorResponse(deliveryError, ctx.log, { requestId: ctx.requestId })
}
// Pass 2: everything past 180 days — catches the telemetry rows pass 1 skipped.
// Pass 2: everything past 180 days, catches the telemetry rows pass 1 skipped.
const { error: telemetryError, count: telemetryCount } = await supabase
.from('event_log')
.delete({ count: 'exact' })
+1 -1
View File
@@ -25,7 +25,7 @@ export async function GET(request: Request) {
let supabase: SupabaseClient
// When authenticated via an API key, the key is BOUND to a specific company.
// Honor that binding (least privilege) rather than resolving the user's
// active company — otherwise a key scoped to company A would leak company B's
// active company: otherwise a key scoped to company A would leak company B's
// events whenever the user's active_company_id happened to point elsewhere.
let keyCompanyId: string | null = null
+1 -1
View File
@@ -9,7 +9,7 @@ import type { Article } from '@/types'
/**
* GET /api/export/articles[?format=csv][&include_inactive=1]
*
* Downloads the article register as xlsx (default) or csv. Read-only — viewers
* Downloads the article register as xlsx (default) or csv. Read-only: viewers
* may export. Column headers match the article importer's detector keywords so
* the file round-trips (export → edit → re-import).
*/
+1 -1
View File
@@ -9,7 +9,7 @@ import type { Customer } from '@/types'
/**
* GET /api/export/customers[?format=csv]
*
* Downloads the customer register as xlsx (default) or csv. Read-only — viewers
* Downloads the customer register as xlsx (default) or csv. Read-only: viewers
* may export. Headers match the customer importer's detector keywords so files
* round-trip.
*/
+1 -1
View File
@@ -9,7 +9,7 @@ import type { Supplier } from '@/types'
/**
* GET /api/export/suppliers[?format=csv]
*
* Downloads the supplier register as xlsx (default) or csv. Read-only — viewers
* Downloads the supplier register as xlsx (default) or csv. Read-only: viewers
* may export. Headers match the supplier importer's detector keywords so files
* round-trip.
*/
@@ -17,7 +17,7 @@ import type { GoogleDriveSchedule } from '@/extensions/general/cloud-backup/type
* is either unset or more than 20 hours old. Triggers a full Drive backup for
* each qualifying company via the shared `performSync()` helper.
*
* Uses the service role client — no user session, no RLS. Each row in
* Uses the service role client: no user session, no RLS. Each row in
* `extension_data` carries its own `user_id` (the user who configured the
* schedule), which we use as the "actor" when writing back the sync result.
*/
@@ -74,7 +74,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
}
const startTime = Date.now()
const TIME_BUDGET_MS = 250_000 // 4m10s — leaves 50s margin below Vercel's 300s Pro limit
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
const results: {
companyId: string
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock dependencies — factory must not reference outer variables
// Mock dependencies: factory must not reference outer variables
const mockCreateSession = vi.fn()
const mockGetAccountBalance = vi.fn()
vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({
@@ -70,7 +70,7 @@ describe('GET /api/extensions/enable-banking/callback', () => {
// Find pending connection by oauth_state
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
}
// Update connection — capture the payload, then chain returns the
// Update connection: capture the payload, then chain returns the
// updated row via .select().single() for the audit event emission.
const chain: Record<string, unknown> = {}
chain.update = vi.fn((payload: Record<string, unknown>) => {
@@ -8,7 +8,7 @@ import { upsertFromPsd2 } from '@/lib/cash-accounts/service'
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
// load so registerEventLogHandler() has subscribed before the first emit() —
// load so registerEventLogHandler() has subscribed before the first emit();
// otherwise the audit row is silently dropped on a cold instance where this
// redirect route is the first event-emitting code path to execute.
ensureInitialized()
@@ -163,7 +163,7 @@ export async function GET(request: Request) {
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
// currency). Balances are bank account financial data — we don't fetch
// currency). Balances are bank account financial data: we don't fetch
// them here. The first sync (after the user enables specific accounts)
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
// user deselects never have their balance pulled.
@@ -231,7 +231,7 @@ export async function GET(request: Request) {
})
// Persist the failure to event_log so a security review can see that
// a PSD2 account returned by the bank was not mirrored into our
// routing table — otherwise this is only visible in console output
// routing table; otherwise this is only visible in console output
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
try {
await eventBus.emit({
@@ -74,7 +74,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
}
const startTime = Date.now()
const TIME_BUDGET_MS = 50_000 // 50s — leave 10s margin for Vercel timeout
const TIME_BUDGET_MS = 50_000 // 50s: leave 10s margin for Vercel timeout
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const results: {
@@ -95,7 +95,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
}
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.bank_sync))) {
ctx.log.info('skip — capability not entitled', { companyId: connection.company_id })
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
continue
}
@@ -138,13 +138,13 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
const toDate = new Date().toISOString().split('T')[0]
// First sync: 90-day lookback (PSD2 max). Subsequent: 7-day window.
// Gate on initial_sync_completed_at, not last_synced_at — manual "Sync now"
// Gate on initial_sync_completed_at, not last_synced_at: manual "Sync now"
// sets last_synced_at without doing the deep backfill, and we want the cron
// to still fall back to 90 days if the inline activation backfill failed.
const isFirstSync = !connection.initial_sync_completed_at
const lookbackDays = isFirstSync ? 90 : 7
if (isFirstSync) {
ctx.log.info('first sync for connection — using 90-day lookback', {
ctx.log.info('first sync for connection: using 90-day lookback', {
connectionId: connection.id,
lookbackDays,
})
@@ -160,7 +160,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
const accounts = allAccounts.filter(a => a.enabled !== false)
if (accounts.length === 0) {
ctx.log.info('all accounts disabled — skipping sync', {
ctx.log.info('all accounts disabled: skipping sync', {
connectionId: connection.id,
totalAccounts: allAccounts.length,
})
@@ -177,7 +177,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
continue
}
// Detect SIE overlap — skip auto-categorization if the sync range
// Detect SIE overlap: skip auto-categorization if the sync range
// overlaps with a completed SIE import to prevent double-booking
const { data: sieOverlap } = await supabase
.from('sie_imports')
@@ -189,7 +189,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
.maybeSingle()
// First sync uses strategy=longest to pull the deepest history available
// from the ASPSP. Incremental syncs skip it — the implicit default is
// from the ASPSP. Incremental syncs skip it: the implicit default is
// faster and we already have the older data.
const syncOptions = {
...(sieOverlap ? { skipAutoCategorization: true } : {}),
@@ -274,7 +274,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
})
// A dead PSD2 session (closed/expired/invalid consent) is a re-auth
// condition, not a transient failure — flip it to 'expired' (same state
// condition, not a transient failure: flip it to 'expired' (same state
// the consent-elapsed branch uses) so the UI offers a reconnect instead
// of a retry. Other errors stay 'error'.
const isSessionDead = error instanceof SessionExpiredError
@@ -380,7 +380,7 @@ async function sendConsentExpiryNotification(
.update({ last_expiry_notification_at: new Date().toISOString() })
.eq('id', connection.id as string)
} catch (error) {
// Notification failure must not break the cron job — log only.
// Notification failure must not break the cron job: log only.
// eslint-disable-next-line no-console
console.error('[bank-sync-cron] failed to send consent expiry notification:', error)
}
@@ -48,7 +48,7 @@ function createPathParams(path: string[]) {
describe('Extension Catch-All Route', () => {
beforeEach(() => {
vi.clearAllMocks()
// clearAllMocks doesn't reset implementations — re-assert the default so the
// clearAllMocks doesn't reset implementations: re-assert the default so the
// AAL2 test's mockReturnValue(true) can't leak into later cases.
mockShouldEnforceMfa.mockReturnValue(false)
extensionRegistry.clear()
+8 -8
View File
@@ -23,7 +23,7 @@ function generateRequestId(): string {
* `Felreferens: req_…`
*
* Non-JSON responses (HTML for OAuth callbacks, file downloads) only get the
* header — body rewriting is reserved for the canonical envelope shape.
* header: body rewriting is reserved for the canonical envelope shape.
*/
async function decorateResponse(response: Response, requestId: string): Promise<Response> {
if (!response.headers.get('X-Request-Id')) {
@@ -54,7 +54,7 @@ async function decorateResponse(response: Response, requestId: string): Promise<
})
}
} catch {
// Body wasn't valid JSON — leave it alone.
// Body wasn't valid JSON: leave it alone.
}
}
}
@@ -69,13 +69,13 @@ export const maxDuration = 300
/**
* Per-extension runtime feature flags. Lets ops toggle an integration off
* without redeploying or removing it from extensions.config.json — useful
* without redeploying or removing it from extensions.config.json: useful
* for phased rollouts (dev tenants → design partners → general).
*
* The flag is checked on every request. If the env var is not exactly the
* string "true", the dispatcher returns 503 with `code: 'EXTENSION_DISABLED'`.
*
* Server-side env vars only — no NEXT_PUBLIC_ prefix. Next.js inlines
* Server-side env vars only: no NEXT_PUBLIC_ prefix. Next.js inlines
* NEXT_PUBLIC_* into the client bundle at build time, so a flip on Vercel
* without a redeploy would create split-brain (server returns 503,
* client still renders the enabled flow). UI panels detect the 503 by
@@ -197,7 +197,7 @@ async function handleRequest(
// Config sanity check: these flags are orthogonal and the combination is
// nonsensical. `skipAuth` already implies no company resolution, so adding
// `skipCompanyContext: true` is at best redundant — and if a maintainer
// `skipCompanyContext: true` is at best redundant, and if a maintainer
// intended "auth required, no company" but also wrote `skipAuth: true`,
// the auth requirement would be silently dropped (skipAuth fires first
// below). Fail loudly instead of masking the mistake.
@@ -210,7 +210,7 @@ async function handleRequest(
}
// For skipAuth routes (e.g. OAuth callbacks from external providers),
// skip user auth, toggle check, and AI consent — dispatch immediately
// skip user auth, toggle check, and AI consent: dispatch immediately
if (matchedRoute.skipAuth) {
let handlerRequest = request
if (Object.keys(extractedParams).length > 0) {
@@ -232,7 +232,7 @@ async function handleRequest(
return decorateResponse(response, requestId)
}
// Auth check — requireAuth() enforces MFA (AAL2) on hosted, which the previous
// Auth check: requireAuth() enforces MFA (AAL2) on hosted, which the previous
// inline supabase.auth.getUser() did not. This dispatcher is the single
// chokepoint for the entire enabled-extension surface (banking sync, document
// upload/booking, supplier-invoice flows, migration), so enforcing MFA here
@@ -263,7 +263,7 @@ async function handleRequest(
// Routes that are authenticated but run before a company exists (TIC
// /lookup during onboarding, for example) opt out of company resolution.
// Dispatch without a context — handlers that opt in must not rely on ctx.
// Dispatch without a context: handlers that opt in must not rely on ctx.
if (matchedRoute.skipCompanyContext) {
const response = await matchedRoute.handler(handlerRequest)
log.info('extension call completed', {

Some files were not shown because too many files have changed in this diff Show More