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:
@@ -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/)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 (1–60 characters).",
|
||||
"dim_form_number_label": "Number",
|
||||
"dim_form_number_help": "Leave empty for the next free number (20+). SIE reserves 1–19.",
|
||||
|
||||
@@ -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 (1–60 tecken).",
|
||||
"dim_form_number_label": "Nummer",
|
||||
"dim_form_number_help": "Lämna tomt för nästa lediga (20+). SIE reserverar 1–19.",
|
||||
|
||||
Reference in New Issue
Block a user