Files
accounted/app/api/dimensions/rules/route.ts
T
Jakob Wennberg 764348e99c feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement (#886)
* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement

The final rung of the dimensions ladder
(dev_docs/dimensions_implementation_plan.md §7 row 10):

- custom dimensions: POST /api/dimensions creates registry dims (next free
  SIE number >= 20 when omitted; explicit numbers allowed — SIE import
  already mints reserved ones); register gets a 'Ny dimension' dialog with
  a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries
  parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 —
  this exposes it)
- account_dimension_rules (migration 20260703120000): one rule per
  (account, dimension) — required / default / fixed, per-rule is_active,
  company-scoped RLS, composite FK to the registry, value-presence CHECK
- enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical;
  deliberately NO settings toggle — a rule that exists but is ignored is
  worse than either extreme): default/fixed apply onto line bags at draft
  creation (fixed overwrites, default fills); required asserts at
  commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every
  account + dimension; the bulk-book route runs the same policy before its
  RPC; storno/correction paths never pass through commitEntry so history
  always reverses regardless of policy; rule fetches fail open incl.
  thrown exceptions
- chart of accounts: per-account Dimensionsregler section in
  EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch),
  gated on the existing dimensions toggle, quiet when empty
- pickers: LineDimensionFields is registry-driven (one combobox per active
  dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount
  lights up custom dims with zero changes
- agent briefing: per-dimension required_on_accounts/default_on_accounts
  so agents self-correct instead of bouncing off the policy error
- rules CRUD API with existence/active/company validation and qualified
  DTO ids; firm_id FK deferred until the firms table lands (per plan)

39 new tests (pure-fn rules, engine enforcement, both new API surfaces,
pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration
replayed on a fresh container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: renumber migration to 20260703200000 — version collision with prod

The concurrent session shipped pending_operations_add_link_document_to_voucher
as 20260703120000 today; the Supabase preview branch (cloned from prod)
rejected the duplicate version key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round — auto-pick retry on collision, fail-open warnings, query schema

- POST /api/dimensions retries once past a concurrent number claim when the
  number was auto-picked (explicit choices still 409)
- every fail-open skip of the dimension-rules policy now logs a structured
  warning (engine draft/commit paths + bulk-book) — deliberate fail-open,
  but observable
- GET /api/dimensions/rules validates its query through
  ListDimensionRulesQuerySchema instead of an inline regex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:50:28 +02:00

158 lines
5.7 KiB
TypeScript

/**
* /api/dimensions/rules — per-account dimension policy (dimensions PR10).
*
* GET ?account_number=4010 (optional) → every rule (or the account's).
* POST → create a rule. 'required' blocks posting on the account without a
* value for the dimension (enforced at commitEntry + the bulk-book route);
* 'default' pre-fills at draft creation; 'fixed' always applies.
*
* Opt-in by construction: zero rules = the engine behaves exactly as before.
* There is deliberately NO settings toggle for enforcement — a rule that
* exists but is ignored would be worse than either extreme; pausing a single
* rule is what is_active is for.
*/
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { CreateAccountDimensionRuleSchema, ListDimensionRulesQuerySchema } from '@/lib/api/schemas'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { RULE_SELECT, toRuleDto, type RawRule } from './dto'
ensureInitialized()
export const GET = withRouteContext(
'dimension.rules.list',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const queryValidation = validateQuery(request, ListDimensionRulesQuerySchema, {
log,
operation: 'dimension.rules.list',
})
if (!queryValidation.success) return queryValidation.response
const { account_number: accountNumber } = queryValidation.data
let query = supabase
.from('account_dimension_rules')
.select(RULE_SELECT)
.eq('company_id', companyId)
.order('account_number', { ascending: true })
if (accountNumber) {
query = query.eq('account_number', accountNumber)
}
const { data, error } = await query
if (error) {
log.error('dimension rule list failed', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({
data: { rules: ((data ?? []) as unknown as RawRule[]).map(toRuleDto) },
})
},
)
export const POST = withRouteContext(
'dimension.rules.create',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateAccountDimensionRuleSchema)
if (!validation.success) return validation.response
const body = validation.data
// The dimension must belong to this company (RLS backstops; this gives a
// clean Swedish 400 instead of an FK error).
const { data: dimension, error: dimensionError } = await supabase
.from('dimensions')
.select('id, is_active')
.eq('id', body.dimension_id)
.eq('company_id', companyId)
.maybeSingle()
if (dimensionError) return errorResponse(dimensionError, log, { requestId })
if (!dimension) {
return NextResponse.json(
{ error: { code: 'DIMENSION_NOT_FOUND', message: 'Dimensionen finns inte i registret.' } },
{ status: 404 },
)
}
// default/fixed: the value must belong to the SAME dimension + company
// and be active — a rule pointing at a foreign or archived value would
// make every booking on the account fail registry validation.
if (body.value_id) {
const { data: value, error: valueError } = await supabase
.from('dimension_values')
.select('id, is_active')
.eq('id', body.value_id)
.eq('company_id', companyId)
.eq('dimension_id', body.dimension_id)
.maybeSingle()
if (valueError) return errorResponse(valueError, log, { requestId })
if (!value) {
return NextResponse.json(
{ error: { code: 'DIMENSION_VALUE_NOT_FOUND', message: 'Värdet finns inte under den valda dimensionen.' } },
{ status: 404 },
)
}
if (!value.is_active) {
return NextResponse.json(
{ error: { code: 'DIMENSION_VALUE_ARCHIVED', message: 'Värdet är arkiverat — återaktivera det innan det används i en regel.' } },
{ status: 400 },
)
}
}
// The account must exist and be active in the company chart — a rule on
// a nonexistent account can never fire and only confuses.
const { data: account, error: accountError } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.eq('account_number', body.account_number)
.eq('is_active', true)
.maybeSingle()
if (accountError) return errorResponse(accountError, log, { requestId })
if (!account) {
return NextResponse.json(
{ error: { code: 'ACCOUNT_NOT_FOUND', message: `Konto ${body.account_number} finns inte som aktivt konto i kontoplanen.` } },
{ status: 404 },
)
}
const { data: rule, error: insertError } = await supabase
.from('account_dimension_rules')
.insert({
company_id: companyId,
account_number: body.account_number,
dimension_id: body.dimension_id,
rule_type: body.rule_type,
value_id: body.value_id ?? null,
is_active: body.is_active ?? true,
})
.select(RULE_SELECT)
.single()
if (insertError) {
if (insertError.code === '23505') {
return NextResponse.json(
{ error: { code: 'DIMENSION_RULE_EXISTS', message: `Konto ${body.account_number} har redan en regel för den dimensionen.` } },
{ status: 409 },
)
}
log.error('dimension rule create failed', insertError)
return errorResponse(insertError, log, { requestId })
}
return NextResponse.json(
{ data: { rule: toRuleDto(rule as unknown as RawRule) } },
{ status: 201 },
)
},
{ requireWrite: true },
)