diff --git a/DECISIONS.md b/DECISIONS.md index b0ddc948..efc6ad82 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1531,6 +1531,11 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts index 346e91fd..e74f8156 100644 --- a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -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/) diff --git a/app/api/dimensions/[id]/route.ts b/app/api/dimensions/[id]/route.ts index 73ec1908..150ba13a 100644 --- a/app/api/dimensions/[id]/route.ts +++ b/app/api/dimensions/[id]/route.ts @@ -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 }, +) diff --git a/app/api/dimensions/__tests__/id.test.ts b/app/api/dimensions/__tests__/id.test.ts index 31d0e9ea..e9f8ba70 100644 --- a/app/api/dimensions/__tests__/id.test.ts +++ b/app/api/dimensions/__tests__/id.test.ts @@ -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) + }) +}) diff --git a/components/dimensions/DimensionCombobox.tsx b/components/dimensions/DimensionCombobox.tsx index 8e9873ff..081f5401 100644 --- a/components/dimensions/DimensionCombobox.tsx +++ b/components/dimensions/DimensionCombobox.tsx @@ -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(null) const containerRef = useRef(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 (
+ {showSelectedName && selected ? ( +

+ {selected.name} +

+ ) : null} + {/* Dropdown */} {isOpen && !disabled && (
(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')} + {activeDim && !activeDim.is_system && ( + + )}