feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219) (#2255)

* feat(dimensions): show the value's name after picking, and let an unused custom dimension be deleted (#2219)

Two things from the same Discord report, both in bookkeeping from the
transaction view:

1. After picking a kostnadsställe the field showed only the code ("1").
   DimensionCombobox now writes the value's full name under the field once
   a code is committed, exactly as AccountCombobox does for the account
   name (looked up in the full registry so an archived code stays
   readable). The input text itself stays the code: the blur/revert logic
   keys on it.

2. A self-created dimension could not be removed at all: the DB already
   allowed it (enforce_dimension_registry_guards lets a non-system
   dimension go when no posted/reversed line carries its number, and the
   value retention trigger fires on the cascade), but no route or UI
   asked. New DELETE /api/dimensions/[id]: 400 DIMENSION_SYSTEM_DELETE for
   kostnadsställe/projekt, the guard's own Swedish P0001 verbatim as 409
   DIMENSION_REFERENCED, 404, and a happy path; the register gets a quiet
   "Ta bort dimension" link for the active custom dimension behind a
   DestructiveConfirmDialog. Keys added to sv and en.

Closes #2219

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

* test: satisfy the TypeScript ratchet for two test files main inherited from #2247 and #2242

accounts-route.test.ts built SyncResult literals without the
requestedFromDate / historyNarrowed fields #2247 added (vitest does not
typecheck, so it passed locally); fiscal-periods route.test.ts got two
more one-argument POST(req) calls from #2242 in a file already at its
ratchet baseline. Both files now typecheck; the ratchet runs clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 19:20:09 +02:00
committed by GitHub
parent 2c3ffaf51c
commit a48508e5b0
10 changed files with 269 additions and 9 deletions
+5
View File
@@ -1531,6 +1531,11 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-03] KPI monthly breakdown counts reversed originals (#2201): the monthly section of get_kpi_report_aggregates (new migration 20260903160000) and lib/reports/monthly-breakdown.ts now use tb_ex_year_end's entry set verbatim (posted + reversed, minus the undone year-end chain) instead of posted-only. A same-year storno then cancels inside the months as it does in the year total, so sum(months) = Nettoresultat; the reversal shows as negative revenue in its own month, which is the honest month view. The pg-real pin "in tb, not in monthly" was flipped, not worked around. Unblocks the per-month sum on Nyckeltal (#2196).
[2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903170000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error.
[2026-09-03] AGI kvittens cron: dropped the apigw_config bucket (#963) and its warn-once suppression for ACCESS_DENIED (#2226). The bucket existed because the APIGW client was known to lack the AGI hantera subscription in Utvecklarportalen; with that subscription expected in place, a gateway refusal is a regression and belongs in the ordinary error path (error level, generic 'error' status) rather than a status that hides it as a known gap. Same pass: the connector-mode gateway-refusal message names the connector operator by host instead of "kontakta supporten", because hosted is itself a Connect installation for the canary companies and "support" no longer says whose. Merging before the portal subscription is active means the 15-minute cron logs at error level per pending declaration until it is.
[2026-09-03] Payment terms 0 days (#2070): customer and supplier forms plus the three API schemas accept whole days 0-365 (0 = betalning direkt), and every `|| 30` fallback became `?? 30` so a stored 0 does not reopen as 30. Upper bound 365 is a sanity cap, not law; the invoice schema's own cap (90) is unchanged.
[2026-09-03] getErrorMessage passthrough (#2086): kept the keyword list as the first gate and added looksLikeUserFacingSwedish as a second (å/ä/ö, a strong Swedish word, or two weak function words, and no technical-leak pattern) instead of flipping to "show unless technical": several routes still return English free text ("Failed to disconnect") that must keep falling through to the status/context fallback, which existing tests pin. A registry-wide test asserts every message_sv passes.
[2026-09-03] SIE IB-imbalance warning (#2082): names the cause and links the manual IB wizard, but does NOT let the SIE import skip IB (the import refuses a period whose IB is already set), so the copy says the file is not imported on the manual path. Plugging the diff to 2010/2019 for enskild firma and running findUntransferredResults at parse time are separate founder calls.
[2026-09-03] Proforma at creation (#2217): a "Ny proformafaktura" split-button entry driven by ?proforma=1, mirroring ?quote=1, rather than surfacing the Förval document-type select earlier in the editor: the split button is where a Fortnox user looks, and the editor's own title already tracks the type.
[2026-09-03] Delete unused custom dimension (#2219): new DELETE /api/dimensions/[id] leans on the existing DB registry guard (enforce_dimension_registry_guards: system dims undeletable, any posted/reversed line tagged with the number refuses) and surfaces its P0001 verbatim as 409 DIMENSION_REFERENCED; the route only pre-empts the system-dimension case with 400 DIMENSION_SYSTEM_DELETE. No separate "is it used" query: the trigger is the single source of truth. The picker shows the value's name under the code after a pick, as AccountCombobox does, instead of changing the input text (the blur/revert logic keys on the code). Decision lines for #2070, #2086, #2082 and #2217 ride here for the same append-only-log reason as the first batch.
[2026-09-03] Fiscal period "first" = no existing period starts earlier (#2237): POST /api/bookkeeping/fiscal-periods decided first-period from `allPeriods.length === 0`, which refused a mid-month first räkenskapsår backfilled after a Fortnox import (2022-07-22) while the DB trigger enforce_first_of_month_for_subsequent_periods would have accepted the row. The route now mirrors the trigger instead of adding a second definition; the 1st-of-month rule (BFL 3 kap. 1 §) keeps binding subsequent years and its message now says why and what is allowed.
[2026-09-03] Tenant brand logos render with next/image `unoptimized` (#2203): the optimizer's remotePatterns allowlist is fixed at build time and the generic Docker image bakes a sentinel for NEXT_PUBLIC_SUPABASE_URL, so /_next/image rejected the runtime Supabase host with 400 and the sidebar mark broke on self-hosted while the favicon (same URL) worked. Chosen over a same-origin proxy route (more code, a second fetch hop for a 26px image) and over a runtime-configurable allowlist (Next.js has none). The remotePatterns block stays for builds that know the URL.
[2026-09-03] Danger zone (#2214): both company actions (Radera företag, Starta om migrering) and the account deletion already open a dialog that requires the company name / e-mail typed in, so nothing destructive can fire from a tap or swipe. The report came from a user who did not dare press the link to find that out, so the fix is copy on the row ("Inget händer direkt: du bekräftar i nästa steg ...") rather than a second dialog or a different control.
@@ -384,7 +384,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
overlapping: [],
})
const req = createMockRequest({ name: '2022/2023', period_start: '2022-07-22', period_end: '2023-12-31' })
const res = await POST(req)
const res = await POST(req, { params: Promise.resolve({}) })
expect(res.status).toBe(200)
})
@@ -393,7 +393,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
allPeriods: [{ id: 'p2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
})
const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-15', period_end: '2025-12-31' })
const res = await POST(req)
const res = await POST(req, { params: Promise.resolve({}) })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/1st of a month/)
+66
View File
@@ -1,5 +1,6 @@
/**
* PATCH /api/dimensions/[id]: update a dimension (name / is_active / sort_order).
* DELETE /api/dimensions/[id]: remove a custom dimension nobody has booked on.
*
* Guard rails:
* - Renaming an is_system dimension (1 = Kostnadsställe, 6 = Projekt) is
@@ -7,6 +8,13 @@
* döpas om"). Archiving (is_active=false) and reordering remain allowed.
* - sie_dim_no / is_system are immutable at the DB level
* (enforce_dimension_registry_guards) and not accepted here at all.
* - DELETE (issue #2219): a system dimension answers 400
* DIMENSION_SYSTEM_DELETE before the DB is asked. A custom dimension whose
* number is tagged on any posted/reversed line is refused by the same DB
* guard with a P0001 that names the dimension; that message rides the 409
* DIMENSION_REFERENCED envelope verbatim. Values cascade (ON DELETE
* CASCADE) and the value retention trigger fires on the cascade too, so
* nothing booked can ever be pulled out from under a verifikat.
*/
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
@@ -77,3 +85,61 @@ export const PATCH = withRouteContext(
},
{ requireWrite: true },
)
export const DELETE = withRouteContext(
'dimension.delete',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const opLog = log.child({ dimensionId: id })
const { data: existing, error: fetchError } = await supabase
.from('dimensions')
.select('id, name, is_system')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (fetchError) {
opLog.error('dimension fetch failed', fetchError)
return errorResponse(fetchError, opLog, { requestId })
}
if (!existing) {
return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId })
}
if (existing.is_system) {
return errorResponseFromCode('DIMENSION_SYSTEM_DELETE', opLog, { requestId })
}
const { data, error } = await supabase
.from('dimensions')
.delete()
.eq('id', id)
.eq('company_id', companyId)
.select('id')
if (error) {
// P0001 = plpgsql RAISE EXCEPTION: the registry guard (or the value
// retention trigger on the cascade) refusing the delete. Surface its
// Swedish message verbatim: it names the dimension / code.
if (error.code === 'P0001') {
return errorResponseFromCode('DIMENSION_REFERENCED', opLog, {
requestId,
messageSv: error.message,
})
}
opLog.error('dimension delete failed', error)
return errorResponseFromCode('DIMENSION_DELETE_FAILED', opLog, {
requestId,
details: { reason: getUserErrorMessage(error) },
})
}
if (!data || data.length === 0) {
return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
+60 -2
View File
@@ -1,5 +1,6 @@
/**
* Tests for PATCH /api/dimensions/[id] (update dimension).
* Tests for PATCH /api/dimensions/[id] (update dimension) and
* DELETE /api/dimensions/[id] (remove an unused custom dimension, #2219).
*
* Covers: 401, empty-body validation (400), 404, the is_system name-lock
* ("Systemdimensioner kan inte döpas om", 400 DIMENSION_SYSTEM_RENAME),
@@ -34,7 +35,7 @@ vi.mock('@/lib/auth/require-write', () => ({
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { PATCH } from '../[id]/route'
import { PATCH, DELETE } from '../[id]/route'
const params = () => createMockRouteParams({ id: 'dim-1' })
@@ -137,3 +138,60 @@ describe('PATCH /api/dimensions/[id]', () => {
expect(body.data.sort_order).toBe(30)
})
})
describe('DELETE /api/dimensions/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
const del = () => DELETE(createMockRequest('/api/dimensions/dim-1', { method: 'DELETE' }), params())
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await del()
expect(response.status).toBe(401)
})
it('returns 404 when the dimension does not belong to the company', async () => {
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await del())
expect(status).toBe(404)
expect(body.error.code).toBe('DIMENSION_NOT_FOUND')
})
it('refuses a system dimension with 400 DIMENSION_SYSTEM_DELETE before touching the DB', async () => {
enqueue({ data: { id: 'dim-1', name: 'Kostnadsställe', is_system: true } })
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(await del())
expect(status).toBe(400)
expect(body.error.code).toBe('DIMENSION_SYSTEM_DELETE')
expect(body.error.message).toContain('avaktivera')
})
it('surfaces the DB guard verbatim as 409 DIMENSION_REFERENCED when posted lines carry the number', async () => {
enqueue({ data: { id: 'dim-1', name: 'Avdelning', is_system: false } })
enqueue({
data: null,
error: {
code: 'P0001',
message: 'Dimensionen 7 (Avdelning) används på bokförda verifikat och kan inte tas bort — avaktivera den istället.',
},
})
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(await del())
expect(status).toBe(409)
expect(body.error.code).toBe('DIMENSION_REFERENCED')
expect(body.error.message).toContain('Avdelning')
})
it('deletes an unused custom dimension (happy path)', async () => {
enqueue({ data: { id: 'dim-1', name: 'Avdelning', is_system: false } })
enqueue({ data: [{ id: 'dim-1' }] })
const { status, body } = await parseJsonResponse<{ success: boolean }>(await del())
expect(status).toBe(200)
expect(body.success).toBe(true)
})
})
+27 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { Loader2, Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -65,6 +65,13 @@ export default function DimensionCombobox({
() => dimension?.values.filter((v) => v.is_active) ?? [],
[dimension],
)
// The committed value's registry row, looked up in the FULL list: an
// archived code stays readable in history even though it is never offered.
const selected = useMemo(
() => (value ? dimension?.values.find((v) => v.code === value) ?? null : null),
[dimension, value],
)
const selectedNameId = useId()
const [isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
@@ -249,6 +256,14 @@ export default function DimensionCombobox({
}, 150)
}
// After a pick the field holds only the code ("1"), which told the user
// nothing (issue #2219). Write the value's full name under it, exactly as
// AccountCombobox does for the account name, whenever the field shows the
// committed code and the name adds something beyond the code itself.
const showSelectedName = Boolean(
selected && selected.name !== selected.code && value !== null && search === value,
)
return (
<div ref={containerRef} className="relative">
<Input
@@ -261,8 +276,19 @@ export default function DimensionCombobox({
disabled={disabled}
className={`font-mono ${className ?? ''}`.trim()}
autoComplete="off"
aria-describedby={showSelectedName ? selectedNameId : undefined}
/>
{showSelectedName && selected ? (
<p
id={selectedNameId}
data-ph-mask=""
className="mt-1 break-words px-1 text-xs leading-snug text-muted-foreground"
>
{selected.name}
</p>
) : null}
{/* Dropdown */}
{isOpen && !disabled && (
<div
@@ -12,6 +12,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import {
Dialog,
@@ -98,6 +99,8 @@ export default function DimensionsManager() {
const [dialog, setDialog] = useState<DialogState>(null)
const [isSaving, setIsSaving] = useState(false)
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
const [confirmDeleteDimOpen, setConfirmDeleteDimOpen] = useState(false)
const [isDeletingDim, setIsDeletingDim] = useState(false)
const [isCreatingDimension, setIsCreatingDimension] = useState(false)
useEffect(() => {
@@ -270,6 +273,34 @@ export default function DimensionsManager() {
}
}
// Remove a custom dimension nobody has booked on (issue #2219). The DB
// registry guard refuses the delete with a Swedish P0001 when any posted
// line carries the number; the route passes that message through, so the
// toast says exactly which dimension and why.
async function handleDeleteDimension() {
if (!activeDim || activeDim.is_system) return
setIsDeletingDim(true)
try {
const res = await fetch(`/api/dimensions/${activeDim.id}`, { method: 'DELETE' })
if (!res.ok) {
const json = await res.json().catch(() => null)
toast({
title: t('delete_failed_title'),
description: getErrorMessage(json, { locale: errorLocale }),
variant: 'destructive',
})
return
}
toast({ title: t('dimension_deleted_title') })
setConfirmDeleteDimOpen(false)
setActiveDimId(null)
setSearchTerm('')
await loadDimensions()
} finally {
setIsDeletingDim(false)
}
}
async function handleDeleteValue() {
if (!activeDim || dialog?.mode !== 'edit') return
setIsSaving(true)
@@ -398,6 +429,17 @@ export default function DimensionsManager() {
>
{t('new_dimension')}
</button>
{activeDim && !activeDim.is_system && (
<button
type="button"
className={cn(QUIET_LINK_CLASS, 'disabled:pointer-events-none disabled:opacity-50')}
disabled={!canWrite || isDeletingDim}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
onClick={() => setConfirmDeleteDimOpen(true)}
>
{t('delete_dimension')}
</button>
)}
<Button
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
@@ -515,6 +557,18 @@ export default function DimensionsManager() {
{/* New dimension dialog — the content (and thus the form state)
unmounts on close, so each open starts from a blank form. */}
{activeDim && !activeDim.is_system && (
<DestructiveConfirmDialog
open={confirmDeleteDimOpen}
onOpenChange={setConfirmDeleteDimOpen}
title={t('delete_dimension_confirm_title')}
description={t('delete_dimension_confirm_description', { name: activeDim.name })}
confirmLabel={t('delete_confirm_label')}
cancelLabel={t('delete_cancel_label')}
onConfirm={handleDeleteDimension}
/>
)}
<Dialog
open={newDimDialogOpen}
onOpenChange={(open) => !open && setNewDimDialogOpen(false)}
@@ -508,6 +508,8 @@ describe('PATCH /accounts (enable-banking)', () => {
describe('inline initial backfill', () => {
it('runs inline sync on pending_selection→active and writes initial-sync metadata', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 47,
duplicates: 3,
errors: 0,
@@ -577,6 +579,8 @@ describe('PATCH /accounts (enable-banking)', () => {
it('suppresses auto-categorization and reconciles per ledger account when the window overlaps an SIE import (renewal-flood guard)', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 22,
duplicates: 0,
errors: 0,
@@ -663,6 +667,8 @@ describe('PATCH /accounts (enable-banking)', () => {
it('runs no reconciliation sweep and keeps categorization on without SIE overlap', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 10,
duplicates: 0,
errors: 0,
@@ -705,6 +711,8 @@ describe('PATCH /accounts (enable-banking)', () => {
it('gives viewers rawInsertOnly and no sweep even with SIE overlap (viewers cannot write links)', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 5,
duplicates: 0,
errors: 0,
@@ -747,6 +755,8 @@ describe('PATCH /accounts (enable-banking)', () => {
it('skips the sweep for an account whose cash_accounts row did not resolve instead of widening to the pooled form', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 6,
duplicates: 0,
errors: 0,
@@ -790,6 +800,8 @@ describe('PATCH /accounts (enable-banking)', () => {
it('keeps the backfill successful when the reconciliation sweep throws (non-critical)', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 8,
duplicates: 0,
errors: 0,
@@ -823,6 +835,8 @@ describe('PATCH /accounts (enable-banking)', () => {
it('does NOT run inline sync when connection is already active (selection edit)', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 99,
duplicates: 0,
errors: 0,
@@ -896,6 +910,8 @@ describe('PATCH /accounts (enable-banking)', () => {
it('clamps initial_lookback_days to [30, 365]', async () => {
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 0,
duplicates: 0,
errors: 0,
@@ -931,6 +947,8 @@ describe('PATCH /accounts (enable-banking)', () => {
// show a retry warning; the cron will gate on initial_sync_completed_at IS NULL
// and self-heal on its next run.
mockedSync.mockResolvedValue({
requestedFromDate: '2026-01-01',
historyNarrowed: false,
imported: 12,
duplicates: 0,
errors: 0,
@@ -975,7 +993,7 @@ describe('PATCH /accounts (enable-banking)', () => {
describe('per-account ledger mapping (account_mappings)', () => {
it('persists ledger_account from account_mappings into accounts_data JSONB', async () => {
mockedSync.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 })
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
@@ -1132,7 +1150,7 @@ describe('PATCH /accounts (enable-banking)', () => {
// the effective cash_accounts assignment, so the cleared account gets a
// fresh allocation instead of an undefined that silently falls back to
// 1930 at mirror time.
mockedSync.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 })
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
@@ -1236,7 +1254,7 @@ describe('PATCH /accounts (enable-banking)', () => {
// claim release still point at the revoked connection. They must not
// count as foreign claims: the save goes through and upsertFromPsd2
// promotes the orphaned row in place.
mockedSync.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 })
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
mockGetRevokedConnectionIds.mockResolvedValue(new Set(['conn-REVOKED']))
const stub: SupabaseStub = {
@@ -1283,7 +1301,7 @@ describe('PATCH /accounts (enable-banking)', () => {
})
it('allocates distinct ledgers for legacy accounts with no mapping at all', async () => {
mockedSync.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 })
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
+25
View File
@@ -4130,6 +4130,31 @@ const DIMENSION: Record<string, StructuredErrorEntry> = {
message_sv: 'Dimensionen kunde inte uppdateras.',
message_en: 'Failed to update dimension.',
},
DIMENSION_SYSTEM_DELETE: {
httpStatus: 400,
message_sv: 'Systemdimensioner (kostnadsställe och projekt) kan inte tas bort: avaktivera dem istället.',
message_en: 'System dimensions (kostnadsställe/projekt) cannot be deleted: archive (inactivate) them instead.',
},
// The DB registry guard (enforce_dimension_registry_guards) raises when any
// posted/reversed line is tagged with the dimension's number, and the value
// retention trigger fires on the cascade to dimension_values. Routes surface
// the trigger's own Swedish message via `messageSv`.
DIMENSION_REFERENCED: {
httpStatus: 409,
message_sv:
'Dimensionen används på bokförda verifikat och kan inte tas bort: avaktivera den istället.',
message_en:
'The dimension is referenced by posted vouchers and cannot be deleted: archive (inactivate) it instead.',
remediation: {
description:
'Archive the dimension instead: PATCH /api/dimensions/[id] with { "is_active": false }. Numbers tagged on posted lines are retained for the BFL 7-year period.',
},
},
DIMENSION_DELETE_FAILED: {
httpStatus: 500,
message_sv: 'Dimensionen kunde inte tas bort.',
message_en: 'Failed to delete dimension.',
},
DIMENSION_VALUE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Dimensionsvärdet kunde inte hittas.',
+4
View File
@@ -5398,6 +5398,10 @@
"settings_open_register": "Open cost centres & projects",
"new_dimension": "New dimension",
"new_dimension_title": "New dimension",
"delete_dimension": "Delete dimension",
"delete_dimension_confirm_title": "Delete the dimension?",
"delete_dimension_confirm_description": "“{name}” and all its values are deleted permanently. Only possible while no voucher is booked on the dimension; otherwise archive it instead.",
"dimension_deleted_title": "Dimension deleted",
"dim_form_name_invalid": "Enter a name (160 characters).",
"dim_form_number_label": "Number",
"dim_form_number_help": "Leave empty for the next free number (20+). SIE reserves 119.",
+4
View File
@@ -5398,6 +5398,10 @@
"settings_open_register": "Öppna kostnadsställen & projekt",
"new_dimension": "Ny dimension",
"new_dimension_title": "Ny dimension",
"delete_dimension": "Ta bort dimension",
"delete_dimension_confirm_title": "Ta bort dimensionen?",
"delete_dimension_confirm_description": "”{name}” och alla dess värden tas bort permanent. Det går bara när inget verifikat är bokfört på dimensionen, annars avaktiverar du den istället.",
"dimension_deleted_title": "Dimension borttagen",
"dim_form_name_invalid": "Ange ett namn (160 tecken).",
"dim_form_number_label": "Nummer",
"dim_form_number_help": "Lämna tomt för nästa lediga (20+). SIE reserverar 119.",