diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 8c165307..1f07aa1f 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -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[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) diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index e8daf213..42e41a2e 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -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 }) diff --git a/scripts/migrate-sie-files-to-company-paths.ts b/scripts/migrate-sie-files-to-company-paths.ts new file mode 100644 index 00000000..6bff60db --- /dev/null +++ b/scripts/migrate-sie-files-to-company-paths.ts @@ -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 { + 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) +}) diff --git a/supabase/migrations/20260416120000_sie_files_and_fiscal_period_sync.sql b/supabase/migrations/20260416120000_sie_files_and_fiscal_period_sync.sql new file mode 100644 index 00000000..3a71d0df --- /dev/null +++ b/supabase/migrations/20260416120000_sie_files_and_fiscal_period_sync.sql @@ -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';