diff --git a/DECISIONS.md b/DECISIONS.md index 8a820a5c..e0e8c95c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -259,6 +259,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-21] Treat signer rosters as mutable only while unbound and immutable once attached to an annual-report version: corrected versions either clone the latest roster or bind a newly supplied roster, while database serialization advances the version only after the final signature. [2026-07-21] Keep Bolagsverket idnummer server-side for webhook correlation and troubleshooting but omit it from dashboard and MCP responses: the v2.1 contract marks it as a technical identifier that must not be shown to users. [2026-07-21] Store retention_expires_at as the first legally permitted deletion date, 1 January of the eighth following calendar year: BFL 7 kap. 2 § requires preservation through the end of the seventh following calendar year, so period_end plus seven years ends too early for non-calendar fiscal years. +[2026-07-21] Retention correction trigger runs after migration 017 on every fiscal-period update, while the period-start validator runs only when company_id or period_start is written: unrelated metadata backfills must neither restore the old expiry formula nor revalidate unchanged historical dates. [2026-07-21] Restrict locked annual-report creation to the server service role and preserve the profile, disclosure, and eligibility snapshots with the validation result: a caller-controlled browser RPC cannot be allowed to assert its own compliance result, and the version must remain independently auditable after the live profile changes. [2026-07-21] Keep the current K3 renderer available only as a review draft and fail closed before version locking or paper-filing readiness: its present note builder is not a complete applicability-driven K3 disclosure matrix, so claiming general K3 compliance would be misleading. [2026-07-21] Bundle Source Sans 3 and Source Serif 4 under OFL-1.1 and store company TTF/WOFF files in a dedicated private bucket: invoice rendering embeds fonts server-side without a new runtime dependency, while tenant-scoped paths, parse validation, size limits, and Helvetica fallback keep uploaded fonts private and reliable. diff --git a/lib/import/__tests__/fiscal-period-start-day.pg.test.ts b/lib/import/__tests__/fiscal-period-start-day.pg.test.ts index 37fbea02..44927dd1 100644 --- a/lib/import/__tests__/fiscal-period-start-day.pg.test.ts +++ b/lib/import/__tests__/fiscal-period-start-day.pg.test.ts @@ -71,4 +71,25 @@ describe('fiscal_periods: subsequent-period start-day trigger', () => { insertPeriod(companyId, 'Räkenskapsår 2025 (bad)', '2025-06-15', '2025-12-31'), ).rejects.toThrow(/Non-first fiscal period must start on the 1st of a month/) }) + + it('still rejects changing a subsequent period to a mid-month start', async () => { + const { companyId } = await seedCompany() + + await insertPeriod(companyId, 'Räkenskapsår 2024', '2024-01-01', '2024-12-31') + const { rows } = await insertPeriod( + companyId, + 'Räkenskapsår 2025', + '2025-01-01', + '2025-12-31', + ) + + await expect( + getPool().query( + `UPDATE public.fiscal_periods + SET period_start = '2025-06-15' + WHERE id = $1`, + [rows[0]!.id], + ), + ).rejects.toThrow(/Non-first fiscal period must start on the 1st of a month/) + }) }) diff --git a/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql b/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql index 1af96d4d..15a47cb7 100644 --- a/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql +++ b/supabase/migrations/20260721132000_correct_bfl_retention_expiry.sql @@ -20,14 +20,35 @@ $$; -- PostgreSQL fires triggers with the same timing alphabetically. The zz -- prefix makes this legal correction run after the original migration 017 --- trigger without modifying that shipped enforcement migration. +-- trigger without modifying that shipped enforcement migration. It must run +-- on every update because the original calculate_retention_expiry trigger also +-- runs on every update and would otherwise restore the old, too-early date. +DROP TRIGGER IF EXISTS zz_set_bfl_retention_expiry ON public.fiscal_periods; + CREATE TRIGGER zz_set_bfl_retention_expiry - BEFORE INSERT OR UPDATE OF period_end ON public.fiscal_periods + BEFORE INSERT OR UPDATE ON public.fiscal_periods FOR EACH ROW EXECUTE FUNCTION public.set_bfl_retention_expiry(); +-- The period-start validator depends only on company_id and period_start. +-- Restrict UPDATE execution to those columns so retention and other metadata +-- backfills do not revalidate unchanged historical period dates. +DROP TRIGGER IF EXISTS enforce_period_start_day ON public.fiscal_periods; + +CREATE TRIGGER enforce_period_start_day + BEFORE INSERT OR UPDATE OF company_id, period_start ON public.fiscal_periods + FOR EACH ROW + EXECUTE FUNCTION public.enforce_first_of_month_for_subsequent_periods(); + UPDATE public.fiscal_periods SET retention_expires_at = make_date( extract(year FROM period_end)::integer + 8, 1, 1 +) +WHERE retention_expires_at IS DISTINCT FROM make_date( + extract(year FROM period_end)::integer + 8, + 1, + 1 ); + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/bfl-retention-expiry.pg.test.ts b/tests/pg/bfl-retention-expiry.pg.test.ts index 81536336..348a6594 100644 --- a/tests/pg/bfl-retention-expiry.pg.test.ts +++ b/tests/pg/bfl-retention-expiry.pg.test.ts @@ -44,4 +44,76 @@ describe('BFL retention expiry', () => { expect(result.rows[0].retention_expires_at).toBe('2035-01-01') }) + + it('does not restore the old expiry calculation on unrelated updates', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const fiscalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2025-07-01', + periodEnd: '2026-06-30', + name: '2025/2026', + }) + + await getPool().query( + `UPDATE public.fiscal_periods + SET name = 'Updated 2025/2026' + WHERE id = $1`, + [fiscalPeriodId], + ) + + const result = await getPool().query<{ retention_expires_at: string }>( + `SELECT retention_expires_at::text + FROM public.fiscal_periods + WHERE id = $1`, + [fiscalPeriodId], + ) + + expect(result.rows[0].retention_expires_at).toBe('2034-01-01') + }) + + it('allows retention backfills when a historical mid-month start is unchanged', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const historicalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2025-10-20', + periodEnd: '2025-12-31', + name: 'Historical period', + }) + + // Adding an earlier period makes the existing mid-month row match the + // historical state that used to abort unrelated fiscal-period updates. + await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2024-01-01', + periodEnd: '2024-12-31', + name: 'Earlier period', + }) + + await expect( + getPool().query( + `UPDATE public.fiscal_periods + SET retention_expires_at = make_date( + extract(year FROM period_end)::integer + 8, + 1, + 1 + ) + WHERE id = $1`, + [historicalPeriodId], + ), + ).resolves.toBeDefined() + + const result = await getPool().query<{ retention_expires_at: string }>( + `SELECT retention_expires_at::text + FROM public.fiscal_periods + WHERE id = $1`, + [historicalPeriodId], + ) + + expect(result.rows[0].retention_expires_at).toBe('2033-01-01') + }) })