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:
co-authored by
Claude Sonnet 5
parent
a5154ee884
commit
ec27228a8e
@@ -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')
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
|
||||
@@ -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[]])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2
-2
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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.
@@ -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(),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }> },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user