fix(migrations): adjust retention expiry trigger and validation for fiscal periods (#1104)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user