diff --git a/app/api/bookkeeping/journal-entries/[id]/route.ts b/app/api/bookkeeping/journal-entries/[id]/route.ts index 120a6c22..4bdbea14 100644 --- a/app/api/bookkeeping/journal-entries/[id]/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/route.ts @@ -4,6 +4,7 @@ import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' import { ensureInitialized } from '@/lib/init' import { eventBus } from '@/lib/events/bus' +import { getErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -59,7 +60,7 @@ export async function DELETE( if (error) { return NextResponse.json( - { error: error.message }, + { error: getErrorMessage(error, { context: 'journal_entry', statusCode: 400 }) }, { status: 400 } ) } diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index a79c0663..8a89f373 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -88,6 +88,30 @@ const ERROR_PATTERN_MAP: [RegExp, string | null][] = [ /Entry date .+ is outside fiscal period/i, 'Datumet ligger utanför det valda räkenskapsåret.', ], + [ + /Only company owners and admins can delete vouchers/i, + 'Endast ägare och administratörer kan radera verifikationer.', + ], + [ + /Journal entry not found/i, + 'Verifikationen kunde inte hittas.', + ], + [ + /Only posted entries can be deleted/i, + 'Endast bokförda verifikationer kan raderas.', + ], + [ + /Cannot delete voucher in a closed fiscal period/i, + 'Verifikationen kan inte raderas — räkenskapsåret är stängt.', + ], + [ + /Cannot delete voucher in a locked fiscal period/i, + 'Verifikationen kan inte raderas — perioden är låst.', + ], + [ + /Cannot delete: other entries reference this voucher/i, + 'Verifikationen kan inte raderas eftersom andra verifikationer (t.ex. storno eller rättelse) refererar till den.', + ], [ /timed out after \d+m?s/i, 'Anslutningen mot tjänsten tog för lång tid. Försök igen.', diff --git a/lib/import/__tests__/fiscal-period-start-day.pg.test.ts b/lib/import/__tests__/fiscal-period-start-day.pg.test.ts new file mode 100644 index 00000000..ae679fa1 --- /dev/null +++ b/lib/import/__tests__/fiscal-period-start-day.pg.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { seedCompany } from '@/tests/pg/fixtures' + +// Covers enforce_first_of_month_for_subsequent_periods (migration +// 20260418120000). The trigger only blocks mid-month period_start when a +// strictly earlier period exists — so importing a company's chronologically +// first fiscal year (förlängt första räkenskapsår) via SIE must succeed even +// after a later period was created during onboarding. +describe('fiscal_periods: subsequent-period start-day trigger', () => { + async function insertPeriod( + companyId: string, + name: string, + periodStart: string, + periodEnd: string, + ) { + return getPool().query( + `INSERT INTO public.fiscal_periods + (company_id, name, period_start, period_end, is_closed, opening_balances_set) + VALUES ($1, $2, $3, $4, false, false) + RETURNING id`, + [companyId, name, periodStart, periodEnd], + ) + } + + it('allows a mid-month start when no earlier period exists', async () => { + const { companyId } = await seedCompany() + + const { rows } = await insertPeriod( + companyId, + 'Räkenskapsår 2025', + '2025-06-15', + '2026-06-30', + ) + expect(rows[0]!.id).toBeTruthy() + }) + + it('allows importing an earlier mid-month period after a later day-1 period exists', async () => { + const { companyId } = await seedCompany() + + // Onboarding-created period (day 1, year N). + await insertPeriod(companyId, 'Räkenskapsår 2025', '2025-01-01', '2025-12-31') + + // SIE import of förlängt första räkenskapsår — earlier in time, + // mid-month start. This used to fail with the old trigger. + const { rows } = await insertPeriod( + companyId, + 'Räkenskapsår 2023/2024', + '2023-06-15', + '2024-12-31', + ) + expect(rows[0]!.id).toBeTruthy() + }) + + it('still rejects mid-month start when a strictly earlier period already exists', async () => { + const { companyId } = await seedCompany() + + await insertPeriod(companyId, 'Räkenskapsår 2024', '2024-01-01', '2024-12-31') + + await expect( + insertPeriod(companyId, 'Räkenskapsår 2025 (bad)', '2025-06-15', '2026-06-30'), + ).rejects.toThrow(/Non-first fiscal period must start on the 1st of a month/) + }) +}) diff --git a/supabase/migrations/20260427160000_restore_retroactive_first_fiscal_year.sql b/supabase/migrations/20260427160000_restore_retroactive_first_fiscal_year.sql new file mode 100644 index 00000000..6b09474e --- /dev/null +++ b/supabase/migrations/20260427160000_restore_retroactive_first_fiscal_year.sql @@ -0,0 +1,38 @@ +-- Restore retroactive first fiscal year fix. +-- +-- Migration 20260418120000 (applied to prod as 20260420092940 +-- "allow_retroactive_first_fiscal_year") relaxed the trigger so that a +-- mid-month period_start is allowed when no existing period starts earlier. +-- +-- However, on prod the migration "sie_files_and_fiscal_period_sync" was +-- recorded with version 20260421194554 — *after* the fix — and its body +-- contains a CREATE OR REPLACE FUNCTION with the strict pre-fix logic, +-- which clobbered the relaxed trigger. As a result the SIE import flow +-- for förlängt första räkenskapsår started failing again on prod. +-- +-- This migration re-applies the relaxed trigger so that mid-month start +-- is allowed iff no existing period starts earlier. + +CREATE OR REPLACE FUNCTION public.enforce_first_of_month_for_subsequent_periods() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXTRACT(DAY FROM NEW.period_start) = 1 THEN + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.fiscal_periods + WHERE company_id = NEW.company_id + AND id IS DISTINCT FROM NEW.id + AND period_start < NEW.period_start + ) THEN + RAISE EXCEPTION 'Non-first fiscal period must start on the 1st of a month'; + END IF; + + RETURN NEW; +END; +$$; + +NOTIFY pgrst, 'reload schema';