fix(migrations): adjust retention expiry trigger and validation for fiscal periods (#1104)

This commit is contained in:
Mattsson
2026-07-22 00:43:46 +02:00
committed by GitHub
parent e11f70b347
commit 3920c893f4
4 changed files with 117 additions and 2 deletions
+1
View File
@@ -259,6 +259,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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/)
})
})
@@ -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';
+72
View File
@@ -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')
})
})