feat: implement fiscal period date fields component and validation logic (#301)

* feat: implement fiscal period date fields component and validation logic

* feat: update fiscal period validation and naming logic
This commit is contained in:
Mattsson
2026-04-21 17:08:38 +02:00
committed by GitHub
parent 08991218ee
commit 885dd8a2e4
8 changed files with 504 additions and 257 deletions
@@ -64,12 +64,12 @@ describe('validatePeriodDuration', () => {
expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: true })).toBeNull()
})
it('allows mid-month start for first period (October)', () => {
expect(validatePeriodDuration('2025-10-15', '2025-12-31', { isFirstPeriod: true })).toBeNull()
it('allows mid-month start for first period (July)', () => {
expect(validatePeriodDuration('2025-07-15', '2025-12-31', { isFirstPeriod: true })).toBeNull()
})
it('still allows day-1 start for first period', () => {
expect(validatePeriodDuration('2025-10-01', '2025-12-31', { isFirstPeriod: true })).toBeNull()
it('still allows day-1 start for first period (6 months)', () => {
expect(validatePeriodDuration('2025-07-01', '2025-12-31', { isFirstPeriod: true })).toBeNull()
})
it('enforces end-of-month even for first period', () => {
@@ -84,6 +84,26 @@ describe('validatePeriodDuration', () => {
expect(result).toContain('18 months')
})
it('enforces 6-month minimum for first period (2 months)', () => {
expect(validatePeriodDuration('2026-03-25', '2026-05-31', { isFirstPeriod: true })).toBe(
'First fiscal period must be at least 6 months (BFL 3 kap.)'
)
})
it('enforces 6-month minimum for first period (5 months)', () => {
expect(validatePeriodDuration('2026-08-01', '2026-12-31', { isFirstPeriod: true })).toBe(
'First fiscal period must be at least 6 months (BFL 3 kap.)'
)
})
it('allows exactly 6 months for first period', () => {
expect(validatePeriodDuration('2026-07-01', '2026-12-31', { isFirstPeriod: true })).toBeNull()
})
it('realistic customer case: 2026-03-25 to 2027-02-28', () => {
expect(validatePeriodDuration('2026-03-25', '2027-02-28', { isFirstPeriod: true })).toBeNull()
})
it('returns error when end is not last day of month', () => {
expect(validatePeriodDuration('2025-01-01', '2025-12-15')).toBe(
'Period end must be the last day of a month'
@@ -62,5 +62,10 @@ export function validatePeriodDuration(start: string, end: string, options?: Val
return `Period duration ${months} months exceeds maximum 18 months (BFL 3 kap.)`
}
// First fiscal period must be at least 6 months per BFL 3 kap.
if (options?.isFirstPeriod && months < 6) {
return `First fiscal period must be at least 6 months (BFL 3 kap.)`
}
return null
}
+79
View File
@@ -6,6 +6,7 @@ import {
ensureFiscalPeriod,
importVouchers,
computeVoucherNumberRanges,
linkOpeningBalanceEntryToPeriod,
} from '../sie-import'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ParsedSIEFile, AccountMapping } from '../types'
@@ -421,6 +422,84 @@ describe('ensureFiscalPeriod validation', () => {
})
})
describe('linkOpeningBalanceEntryToPeriod', () => {
// Regression: SIE import created the opening-balance entry but never wrote
// its ID back to fiscal_periods. Without the link, getOpeningBalances falls
// through to summing all prior journal lines, which inflates balance-sheet
// accounts across multi-year imports (each year's IB double-counted against
// the prior year's UB).
type Supabase = Parameters<typeof linkOpeningBalanceEntryToPeriod>[0]
it('writes opening_balance_entry_id and opening_balances_set to the fiscal period', async () => {
const updates: Array<{ payload: Record<string, unknown>; filters: Record<string, unknown> }> = []
const supabase = {
from: (table: string) => {
if (table !== 'fiscal_periods') {
throw new Error(`Unexpected table: ${table}`)
}
let pendingPayload: Record<string, unknown> = {}
const filters: Record<string, unknown> = {}
const chain = {
update: (payload: Record<string, unknown>) => {
pendingPayload = payload
return chain
},
eq: (col: string, val: unknown) => {
filters[col] = val
return chain
},
then: (resolve: (v: unknown) => void) => {
updates.push({ payload: pendingPayload, filters: { ...filters } })
resolve({ data: null, error: null })
},
}
return chain
},
}
await linkOpeningBalanceEntryToPeriod(
supabase as unknown as Supabase,
'company-1',
'period-1',
'ob-entry-1',
)
expect(updates).toHaveLength(1)
expect(updates[0].payload).toEqual({
opening_balance_entry_id: 'ob-entry-1',
opening_balances_set: true,
})
expect(updates[0].filters).toEqual({
id: 'period-1',
company_id: 'company-1',
})
})
it('throws a descriptive error when the update fails', async () => {
const supabase = {
from: () => {
const chain = {
update: () => chain,
eq: () => chain,
then: (resolve: (v: unknown) => void) =>
resolve({ data: null, error: { message: 'permission denied' } }),
}
return chain
},
}
await expect(
linkOpeningBalanceEntryToPeriod(
supabase as unknown as Supabase,
'company-1',
'period-1',
'ob-entry-1',
),
).rejects.toThrow(/Failed to link opening balance entry.*permission denied/)
})
})
describe('isBalanceSheetAccount', () => {
it('returns true for class 1 (assets)', () => {
expect(isBalanceSheetAccount('1510')).toBe(true)
+38
View File
@@ -207,6 +207,7 @@ async function cleanupStaleImportRecords(
.eq('company_id', companyId)
.eq('file_hash', fileHash)
.in('status', ['pending', 'failed'])
.lt('created_at', oneHourAgo)
}
/**
@@ -458,6 +459,36 @@ async function createOpeningBalanceEntry(
return entry.id
}
/**
* Link an opening-balance journal entry to its fiscal period so balance-sheet
* reports use the explicit IB path in getOpeningBalances() (reads only that
* entry's lines for IB) instead of falling through to summing all prior
* journal lines — which inflates multi-year imports, because each year's IB
* is double-counted against the prior year's UB.
*
* Mirrors the pattern used by the Excel-based OB import at
* app/api/import/opening-balance/execute/route.ts:224-231.
*/
export async function linkOpeningBalanceEntryToPeriod(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
openingBalanceEntryId: string
): Promise<void> {
const { error } = await supabase
.from('fiscal_periods')
.update({
opening_balance_entry_id: openingBalanceEntryId,
opening_balances_set: true,
})
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
if (error) {
throw new Error(`Failed to link opening balance entry to fiscal period: ${error.message}`)
}
}
/**
* Create journal entries from vouchers using batch insert for performance.
*
@@ -1636,6 +1667,13 @@ export async function executeSIEImport(
if (result.openingBalanceEntryId) {
result.journalEntriesCreated++
result.journalEntryIds.push(result.openingBalanceEntryId)
await linkOpeningBalanceEntryToPeriod(
supabase,
companyId,
result.fiscalPeriodId,
result.openingBalanceEntryId
)
}
}
}