feat: add ensureFiscalPeriod function and related tests for fiscal period validation (#264)
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateImportPreview, validateIBBalance, isBalanceSheetAccount } from '../sie-import'
|
||||
import {
|
||||
generateImportPreview,
|
||||
validateIBBalance,
|
||||
isBalanceSheetAccount,
|
||||
ensureFiscalPeriod,
|
||||
} from '../sie-import'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ParsedSIEFile, AccountMapping } from '../types'
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -312,6 +318,83 @@ describe('validateIBBalance', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureFiscalPeriod validation', () => {
|
||||
// Mirrors the `enforce_period_start_day` DB trigger so users get an
|
||||
// actionable Swedish error instead of a raw Postgres message.
|
||||
type Supabase = Parameters<typeof ensureFiscalPeriod>[0]
|
||||
|
||||
it('rejects mid-month start when another period exists for the company', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: null, error: null }, // containing check — no match
|
||||
{ data: [], error: null }, // overlapping check — none
|
||||
{ data: null, error: null, count: 1 }, // count existing — 1 period already
|
||||
])
|
||||
|
||||
await expect(
|
||||
ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2026-04-16',
|
||||
'2026-12-31',
|
||||
),
|
||||
).rejects.toThrow(/endast företagets första räkenskapsår får börja mitt i månaden/)
|
||||
})
|
||||
|
||||
it('rejects end date that is not the last day of the month', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: null, error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: null, error: null, count: 0 }, // first fiscal period
|
||||
])
|
||||
|
||||
await expect(
|
||||
ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2026-01-01',
|
||||
'2026-12-30', // not the last day of December
|
||||
),
|
||||
).rejects.toThrow(/måste sluta på månadens sista dag/)
|
||||
})
|
||||
|
||||
it('allows mid-month start for the company first fiscal period', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: null, error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: null, error: null, count: 0 }, // no existing periods
|
||||
{ data: { id: 'new-period-id' }, error: null }, // insert result
|
||||
])
|
||||
|
||||
const id = await ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2026-04-16',
|
||||
'2026-12-31',
|
||||
)
|
||||
|
||||
expect(id).toBe('new-period-id')
|
||||
})
|
||||
|
||||
it('reuses an existing period that contains the range (no validation needed)', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: { id: 'existing-period-id' }, error: null }, // containing match
|
||||
])
|
||||
|
||||
const id = await ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2026-04-16',
|
||||
'2026-12-31',
|
||||
)
|
||||
|
||||
expect(id).toBe('existing-period-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBalanceSheetAccount', () => {
|
||||
it('returns true for class 1 (assets)', () => {
|
||||
expect(isBalanceSheetAccount('1510')).toBe(true)
|
||||
|
||||
@@ -22,6 +22,7 @@ import { calculateFileHash } from './sie-parser'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
|
||||
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
|
||||
/**
|
||||
* Format a date to ISO date string (YYYY-MM-DD)
|
||||
@@ -211,8 +212,11 @@ async function cleanupStaleImportRecords(
|
||||
/**
|
||||
* Create a fiscal period if one doesn't exist for the date range.
|
||||
* Dates are ISO strings "YYYY-MM-DD" to avoid timezone issues.
|
||||
*
|
||||
* Exported for unit testing of the pre-validation that mirrors the
|
||||
* `enforce_period_start_day` DB trigger.
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
export async function ensureFiscalPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
startDate: string,
|
||||
@@ -246,9 +250,38 @@ async function ensureFiscalPeriod(
|
||||
return overlapping[0].id
|
||||
}
|
||||
|
||||
// Pre-validate against the DB-side enforce_period_start_day trigger so the
|
||||
// user gets an actionable Swedish error instead of a raw Postgres message.
|
||||
// Only the company's FIRST fiscal period may start mid-month (BFL 3 kap.);
|
||||
// any subsequent period must start on day 1. The trigger queries the same
|
||||
// table, so "another period exists" == the overlap check above missed it,
|
||||
// i.e. this would be a non-contiguous second period.
|
||||
const { count: existingCount } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
const startParts = parseDateParts(startDate)
|
||||
const endParts = parseDateParts(endDate)
|
||||
|
||||
if ((existingCount ?? 0) > 0 && startParts.day !== 1) {
|
||||
throw new Error(
|
||||
`SIE-filens räkenskapsår börjar ${startDate} — endast företagets första räkenskapsår får börja mitt i månaden. Efterföljande räkenskapsår måste börja den 1:a i en månad (BFL 3 kap.). Kontrollera datumen i #RAR-raden eller importera filen innan du skapar fler perioder.`
|
||||
)
|
||||
}
|
||||
|
||||
// Matches the fiscal_period_end_last_of_month CHECK constraint on prod;
|
||||
// surface it as a clean message instead of a DB error.
|
||||
const lastDayOfEndMonth = new Date(endParts.year, endParts.month, 0).getDate()
|
||||
if (endParts.day !== lastDayOfEndMonth) {
|
||||
throw new Error(
|
||||
`SIE-filens räkenskapsår slutar ${endDate} — räkenskapsår måste sluta på månadens sista dag (BFL 3 kap.). Kontrollera datumen i #RAR-raden.`
|
||||
)
|
||||
}
|
||||
|
||||
// Create new fiscal period
|
||||
const startYear = parseInt(startDate.substring(0, 4), 10)
|
||||
const endYear = parseInt(endDate.substring(0, 4), 10)
|
||||
const startYear = startParts.year
|
||||
const endYear = endParts.year
|
||||
const name = startYear === endYear
|
||||
? `Räkenskapsår ${startYear}`
|
||||
: `Räkenskapsår ${startYear}/${endYear}`
|
||||
@@ -1175,7 +1208,7 @@ async function finalizeImportRecord(
|
||||
// Archive the SIE file to Supabase Storage (BFL 7 kap 1-2§ retention)
|
||||
if (result.success) {
|
||||
const storagePath = `${companyId}/${importId}.se`
|
||||
const fileBlob = new Blob([fileContent], { type: 'text/plain; charset=cp437' })
|
||||
const fileBlob = new Blob([fileContent], { type: 'text/plain' })
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('sie-files')
|
||||
.upload(storagePath, fileBlob, { upsert: false })
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* One-off migration: re-home SIE archive files from {user_id}/ paths into
|
||||
* {company_id}/ paths so they match the new sie_files_* storage policies
|
||||
* (see supabase/migrations/20260416120000_sie_files_and_fiscal_period_sync.sql).
|
||||
*
|
||||
* Pre multi-tenant refactor (commit 1534979) SIE archives were uploaded to
|
||||
* {user_id}/{import_id}.se. After the refactor the upload path switched to
|
||||
* {company_id}/{import_id}.se, but the production storage policies weren't
|
||||
* updated — so every post-refactor archive silently failed RLS. The new
|
||||
* policies scope access by company; legacy files still live under user_id
|
||||
* prefixes and would become unreadable for anyone except the original
|
||||
* uploader. This script moves them to the canonical company_id prefix.
|
||||
*
|
||||
* Idempotent: skips rows whose file_storage_path already starts with the
|
||||
* company_id, and skips the copy step if the target object already exists.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/migrate-sie-files-to-company-paths.ts --dry-run
|
||||
* npx tsx scripts/migrate-sie-files-to-company-paths.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
||||
|
||||
interface ImportRow {
|
||||
id: string
|
||||
company_id: string
|
||||
file_storage_path: string
|
||||
}
|
||||
|
||||
async function objectExists(path: string): Promise<boolean> {
|
||||
const { data } = await supabase.storage.from('sie-files').list(
|
||||
path.split('/').slice(0, -1).join('/'),
|
||||
{ search: path.split('/').pop() }
|
||||
)
|
||||
return !!data && data.length > 0
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: rows, error } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('id, company_id, file_storage_path')
|
||||
.not('file_storage_path', 'is', null)
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to query sie_imports:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const candidates: ImportRow[] = (rows ?? []).filter(
|
||||
(r): r is ImportRow =>
|
||||
!!r.file_storage_path &&
|
||||
!r.file_storage_path.startsWith(`${r.company_id}/`)
|
||||
)
|
||||
|
||||
console.log(`${rows?.length ?? 0} archived imports total; ${candidates.length} need migration.`)
|
||||
if (dryRun) console.log('(dry run — no changes will be made)')
|
||||
|
||||
let migrated = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
|
||||
for (const row of candidates) {
|
||||
const oldPath = row.file_storage_path
|
||||
const newPath = `${row.company_id}/${row.id}.se`
|
||||
console.log(`\n${row.id}`)
|
||||
console.log(` old: ${oldPath}`)
|
||||
console.log(` new: ${newPath}`)
|
||||
|
||||
if (dryRun) continue
|
||||
|
||||
const { data: downloaded, error: dlError } = await supabase.storage
|
||||
.from('sie-files')
|
||||
.download(oldPath)
|
||||
|
||||
if (dlError || !downloaded) {
|
||||
console.error(` download failed: ${dlError?.message ?? 'no data'}`)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if (await objectExists(newPath)) {
|
||||
console.log(` target already exists — just updating DB row`)
|
||||
} else {
|
||||
const { error: upError } = await supabase.storage
|
||||
.from('sie-files')
|
||||
.upload(newPath, downloaded, {
|
||||
upsert: false,
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
|
||||
if (upError) {
|
||||
console.error(` upload failed: ${upError.message}`)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('sie_imports')
|
||||
.update({ file_storage_path: newPath })
|
||||
.eq('id', row.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error(` DB update failed: ${updateError.message}`)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
const { error: rmError } = await supabase.storage
|
||||
.from('sie-files')
|
||||
.remove([oldPath])
|
||||
|
||||
if (rmError) {
|
||||
console.warn(` old file not removed (DB already points at new path): ${rmError.message}`)
|
||||
skipped++
|
||||
}
|
||||
|
||||
migrated++
|
||||
console.log(` migrated`)
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${migrated} migrated, ${skipped} partial, ${failed} failed.`)
|
||||
if (failed > 0) process.exit(1)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Sync two schema objects that production carries but the repo lost
|
||||
-- in the PR #244 consolidation:
|
||||
-- 1. sie-files storage bucket + RLS policies (originally 20260408130000)
|
||||
-- 2. fiscal_periods trigger that only lets the FIRST period start mid-month
|
||||
-- (originally 20260409165300)
|
||||
--
|
||||
-- Both sections are idempotent so re-applying is safe on prod, staging,
|
||||
-- preview branches, and fresh installs.
|
||||
--
|
||||
-- Fixes:
|
||||
-- - Archive upload fails with "new row violates row-level security policy"
|
||||
-- because prod policies still required the path to start with auth.uid(),
|
||||
-- but the code (post multi-tenant refactor 1534979) uploads to
|
||||
-- {company_id}/{import_id}.se. New policies scope access to every member
|
||||
-- of the company that owns the path prefix.
|
||||
-- - Fresh/preview DBs had no equivalent of the non-first-of-month trigger,
|
||||
-- so local tests didn't catch the constraint that bit prod users.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. sie-files bucket
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'sie-files',
|
||||
'sie-files',
|
||||
false,
|
||||
52428800, -- 50 MB, matches MAX_FILE_SIZE in the SIE parse route
|
||||
ARRAY['text/plain', 'application/octet-stream']
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
file_size_limit = EXCLUDED.file_size_limit,
|
||||
allowed_mime_types = EXCLUDED.allowed_mime_types;
|
||||
|
||||
-- Drop the legacy user_id-scoped policies (pre multi-tenant refactor).
|
||||
DROP POLICY IF EXISTS "Users can upload SIE files to own folder" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Users can read own SIE files" ON storage.objects;
|
||||
|
||||
-- Drop the names used by this migration in case it's re-applied.
|
||||
DROP POLICY IF EXISTS sie_files_insert ON storage.objects;
|
||||
DROP POLICY IF EXISTS sie_files_select ON storage.objects;
|
||||
|
||||
CREATE POLICY sie_files_insert
|
||||
ON storage.objects
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'sie-files'
|
||||
AND (storage.foldername(name))[1]::uuid IN (SELECT public.user_company_ids())
|
||||
);
|
||||
|
||||
CREATE POLICY sie_files_select
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'sie-files'
|
||||
AND (storage.foldername(name))[1]::uuid IN (SELECT public.user_company_ids())
|
||||
);
|
||||
|
||||
-- No UPDATE or DELETE policies — WORM semantics for BFL 7 kap. retention.
|
||||
-- Service role bypasses RLS for admin/cron cleanup.
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. fiscal_periods: only the first period per company may start mid-month
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the old unconditional CHECK constraint if it's still around from
|
||||
-- 20260224190818 (it was superseded by the trigger in prod but may still
|
||||
-- exist on fresh installs that replayed the early migrations).
|
||||
ALTER TABLE public.fiscal_periods
|
||||
DROP CONSTRAINT IF EXISTS fiscal_period_start_first_of_month;
|
||||
|
||||
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
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Non-first fiscal period must start on the 1st of a month';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS enforce_period_start_day ON public.fiscal_periods;
|
||||
|
||||
CREATE TRIGGER enforce_period_start_day
|
||||
BEFORE INSERT OR UPDATE ON public.fiscal_periods
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.enforce_first_of_month_for_subsequent_periods();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user