feat: add ensureFiscalPeriod function and related tests for fiscal period validation (#264)

This commit is contained in:
Mattsson
2026-04-18 10:05:32 +02:00
committed by GitHub
parent b6e32aaae6
commit c8a5f044c3
4 changed files with 366 additions and 5 deletions
+84 -1
View File
@@ -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)
+37 -4
View File
@@ -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 })