feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
+4 -4
View File
@@ -10,12 +10,12 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
sieType: 4,
program: 'TestProg',
programVersion: '1.0',
generatedDate: new Date(2024, 0, 1),
generatedDate: '2024-01-01',
format: 'PC8',
companyName: 'Test AB',
orgNumber: '5566778899',
address: null,
fiscalYears: [{ yearIndex: 0, start: new Date(2024, 0, 1), end: new Date(2024, 11, 31) }],
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
currency: 'SEK',
kontoPlanType: null,
},
@@ -49,8 +49,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
totalAccounts: 3,
totalVouchers: 1,
totalTransactionLines: 3,
fiscalYearStart: new Date(2024, 0, 1),
fiscalYearEnd: new Date(2024, 11, 31),
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
...overrides,
}
+5 -5
View File
@@ -175,8 +175,8 @@ describe('parseSIEFile', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.fiscalYears).toHaveLength(1)
expect(result.header.fiscalYears[0].yearIndex).toBe(0)
expect(result.header.fiscalYears[0].start).toEqual(new Date(2024, 0, 1))
expect(result.header.fiscalYears[0].end).toEqual(new Date(2024, 11, 31))
expect(result.header.fiscalYears[0].start).toBe('2024-01-01')
expect(result.header.fiscalYears[0].end).toBe('2024-12-31')
})
it('parses currency from #VALUTA', () => {
@@ -198,7 +198,7 @@ describe('parseSIEFile', () => {
it('parses generated date', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.generatedDate).toEqual(new Date(2024, 0, 1))
expect(result.header.generatedDate).toBe('2024-01-01')
})
it('parses SIE type 1', () => {
@@ -351,8 +351,8 @@ describe('parseSIEFile', () => {
it('sets fiscal year start/end from RAR 0', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.stats.fiscalYearStart).toEqual(new Date(2024, 0, 1))
expect(result.stats.fiscalYearEnd).toEqual(new Date(2024, 11, 31))
expect(result.stats.fiscalYearStart).toBe('2024-01-01')
expect(result.stats.fiscalYearEnd).toBe('2024-12-31')
})
it('returns null fiscal year dates when no RAR', () => {
+65 -30
View File
@@ -101,6 +101,31 @@ export async function checkDuplicateImport(
return data as SIEImport | null
}
/**
* Check if a completed SIE import already exists for the same fiscal year period.
* Prevents importing two different SIE files that cover the same accounting period,
* which would create duplicate verifikationer violating BFNAR 2013:2.
* Only blocks on status='completed' — failed/pending imports don't prevent retries.
*/
export async function checkDuplicatePeriodImport(
supabase: SupabaseClient,
companyId: string,
fiscalYearStart: string,
fiscalYearEnd: string
): Promise<SIEImport | null> {
const { data } = await supabase
.from('sie_imports')
.select('*')
.eq('company_id', companyId)
.eq('fiscal_year_start', fiscalYearStart)
.eq('fiscal_year_end', fiscalYearEnd)
.eq('status', 'completed')
.limit(1)
.maybeSingle()
return data as SIEImport | null
}
/**
* Clean up stale pending/failed import records for a given file hash.
* Prevents UNIQUE constraint conflicts when re-importing after a failure.
@@ -121,21 +146,22 @@ async function cleanupStaleImportRecords(
}
/**
* Create a fiscal period if one doesn't exist for the date range
* Create a fiscal period if one doesn't exist for the date range.
* Dates are ISO strings "YYYY-MM-DD" to avoid timezone issues.
*/
async function ensureFiscalPeriod(
supabase: SupabaseClient,
companyId: string,
startDate: Date,
endDate: Date
startDate: string,
endDate: string
): Promise<string> {
// Check for an existing period that contains the SIE date range
const { data: containing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', formatDate(startDate))
.gte('period_end', formatDate(endDate))
.lte('period_start', startDate)
.gte('period_end', endDate)
.single()
if (containing) {
@@ -148,8 +174,8 @@ async function ensureFiscalPeriod(
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', formatDate(endDate))
.gte('period_end', formatDate(startDate))
.lte('period_start', endDate)
.gte('period_end', startDate)
.order('period_start', { ascending: false })
.limit(1)
@@ -158,8 +184,8 @@ async function ensureFiscalPeriod(
}
// Create new fiscal period
const startYear = startDate.getFullYear()
const endYear = endDate.getFullYear()
const startYear = parseInt(startDate.substring(0, 4), 10)
const endYear = parseInt(endDate.substring(0, 4), 10)
const name = startYear === endYear
? `Räkenskapsår ${startYear}`
: `Räkenskapsår ${startYear}/${endYear}`
@@ -169,8 +195,8 @@ async function ensureFiscalPeriod(
.insert({
company_id: companyId,
name,
period_start: formatDate(startDate),
period_end: formatDate(endDate),
period_start: startDate,
period_end: endDate,
is_closed: false,
opening_balances_set: false,
})
@@ -316,8 +342,7 @@ async function createOpeningBalanceEntry(
}
}
const fiscalYearStart = parsed.stats.fiscalYearStart
const entryDate = fiscalYearStart ? formatDate(fiscalYearStart) : formatDate(new Date())
const entryDate = parsed.stats.fiscalYearStart ?? formatDate(new Date())
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
@@ -908,8 +933,7 @@ async function createMigrationAdjustmentEntry(
}
// Date the adjustment at fiscal year end
const fiscalYearEnd = parsed.stats.fiscalYearEnd
const entryDate = fiscalYearEnd ? formatDate(fiscalYearEnd) : formatDate(new Date())
const entryDate = parsed.stats.fiscalYearEnd ?? formatDate(new Date())
// Fix 4: Build structured description with skipped voucher details
const skippedIds = skippedDetails.map(d => d.voucherId)
@@ -1023,12 +1047,8 @@ async function createPendingImportRecord(
org_number: parsed.header.orgNumber,
company_name: parsed.header.companyName,
sie_type: parsed.header.sieType,
fiscal_year_start: parsed.stats.fiscalYearStart
? formatDate(parsed.stats.fiscalYearStart)
: null,
fiscal_year_end: parsed.stats.fiscalYearEnd
? formatDate(parsed.stats.fiscalYearEnd)
: null,
fiscal_year_start: parsed.stats.fiscalYearStart ?? null,
fiscal_year_end: parsed.stats.fiscalYearEnd ?? null,
accounts_count: parsed.stats.totalAccounts,
transactions_count: 0,
status: 'pending',
@@ -1237,6 +1257,17 @@ export async function executeSIEImport(
return result
}
// Safety net: reject if a completed import already exists for this period
const periodDuplicate = await checkDuplicatePeriodImport(
supabase, companyId, fiscalYearStart, fiscalYearEnd
)
if (periodDuplicate) {
result.errors.push(
`En SIE-import för perioden ${fiscalYearStart} – ${fiscalYearEnd} finns redan (ID: ${periodDuplicate.id})`
)
return result
}
if (options.createFiscalPeriod) {
result.fiscalPeriodId = await ensureFiscalPeriod(
supabase,
@@ -1250,8 +1281,8 @@ export async function executeSIEImport(
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lte('period_start', formatDate(fiscalYearStart))
.gte('period_end', formatDate(fiscalYearEnd))
.lte('period_start', fiscalYearStart)
.gte('period_end', fiscalYearEnd)
.single()
if (!existing) {
@@ -1359,15 +1390,19 @@ export async function executeSIEImport(
const earliestVoucher = new Date(Math.min(...voucherDates))
const latestVoucher = new Date(Math.max(...voucherDates))
// Parse fiscal year string dates for comparison (append T00:00:00 to avoid UTC shift)
const fyStart = new Date(fiscalYearStart + 'T00:00:00')
const fyEnd = new Date(fiscalYearEnd + 'T00:00:00')
// Allow 30 days margin from fiscal year start/end for partial detection
const msPerDay = 86400000
const startGap = earliestVoucher.getTime() - fiscalYearStart.getTime()
const endGap = fiscalYearEnd.getTime() - latestVoucher.getTime()
const startGap = earliestVoucher.getTime() - fyStart.getTime()
const endGap = fyEnd.getTime() - latestVoucher.getTime()
if (startGap > 60 * msPerDay || endGap > 60 * msPerDay) {
result.warnings.push(
`SIE-filen verkar innehålla ett ofullständigt räkenskapsår: verifikationer ${formatDate(earliestVoucher)}–${formatDate(latestVoucher)}, ` +
`räkenskapsår ${formatDate(fiscalYearStart)}–${formatDate(fiscalYearEnd)}. ` +
`räkenskapsår ${fiscalYearStart}–${fiscalYearEnd}. ` +
`Omföringsverifikationen kan bli felaktig om #UB/#RES avser hela året men verifikationerna bara täcker en del.`
)
}
@@ -1480,10 +1515,10 @@ export async function executeSIEImport(
sourceSystem: parsed.header.program,
sourceVersion: parsed.header.programVersion,
sieType: parsed.header.sieType,
generatedDate: parsed.header.generatedDate ? formatDate(parsed.header.generatedDate) : null,
generatedDate: parsed.header.generatedDate ?? null,
fiscalYear: {
start: formatDate(fiscalYearStart),
end: formatDate(fiscalYearEnd),
start: fiscalYearStart,
end: fiscalYearEnd,
},
importedAt: new Date().toISOString(),
importedBy: companyId,
@@ -1512,7 +1547,7 @@ export async function executeSIEImport(
voucherStats.skippedSingleLine + voucherStats.skippedEmpty
result.details = {
fiscalYear: fiscalYearStart && fiscalYearEnd
? { start: formatDate(fiscalYearStart), end: formatDate(fiscalYearEnd) }
? { start: fiscalYearStart, end: fiscalYearEnd }
: undefined,
skippedVouchers: totalSkippedForDetails > 0 ? {
unbalanced: voucherStats.skippedUnbalanced,
+36 -4
View File
@@ -183,7 +183,8 @@ export function decodeBuffer(buffer: ArrayBuffer, encoding: SIEEncoding): string
}
/**
* Parse a date from SIE format (YYYYMMDD)
* Parse a date from SIE format (YYYYMMDD) into a Date object.
* Used for voucher dates where Date arithmetic is needed.
*/
function parseSIEDate(dateStr: string): Date | null {
if (!dateStr || dateStr.length !== 8) {
@@ -208,6 +209,37 @@ function parseSIEDate(dateStr: string): Date | null {
return date
}
/**
* Parse a date from SIE format (YYYYMMDD) into an ISO date string "YYYY-MM-DD".
* Used for fiscal year dates and generated dates to avoid timezone issues
* during JSON serialization (Date objects shift when crossing UTC boundaries).
*/
function parseSIEDateString(dateStr: string): string | null {
if (!dateStr || dateStr.length !== 8) {
return null
}
const year = dateStr.substring(0, 4)
const month = dateStr.substring(4, 6)
const day = dateStr.substring(6, 8)
const y = parseInt(year, 10)
const m = parseInt(month, 10)
const d = parseInt(day, 10)
if (isNaN(y) || isNaN(m) || isNaN(d)) {
return null
}
// Validate by round-tripping through Date (rejects Feb 30, etc.)
const date = new Date(y, m - 1, d)
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
return null
}
return `${year}-${month}-${day}`
}
/**
* Parse a quoted string field from SIE
* Handles: "value" or value
@@ -405,7 +437,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
case 'GEN':
if (fields[1]) {
header.generatedDate = parseSIEDate(fields[1])
header.generatedDate = parseSIEDateString(fields[1])
}
break
@@ -435,8 +467,8 @@ export function parseSIEFile(content: string): ParsedSIEFile {
case 'RAR': {
// #RAR yearIndex start end
const yearIndex = parseInt(fields[1], 10)
const start = parseSIEDate(fields[2])
const end = parseSIEDate(fields[3])
const start = parseSIEDateString(fields[2])
const end = parseSIEDateString(fields[3])
if (start && end) {
header.fiscalYears.push({ yearIndex, start, end })
+7 -7
View File
@@ -28,7 +28,7 @@ export interface SIEHeader {
sieType: SIEType
program: string | null // #PROGRAM
programVersion: string | null
generatedDate: Date | null // #GEN
generatedDate: string | null // #GEN — "YYYY-MM-DD"
format: string | null // #FORMAT (PC8 = CP437)
// Company info
@@ -47,8 +47,8 @@ export interface SIEHeader {
*/
export interface FiscalYearInfo {
yearIndex: number // 0 = current, -1 = previous, etc.
start: Date
end: Date
start: string // "YYYY-MM-DD"
end: string // "YYYY-MM-DD"
}
/**
@@ -134,8 +134,8 @@ export interface ParsedSIEFile {
totalAccounts: number
totalVouchers: number
totalTransactionLines: number
fiscalYearStart: Date | null
fiscalYearEnd: Date | null
fiscalYearStart: string | null // "YYYY-MM-DD"
fiscalYearEnd: string | null // "YYYY-MM-DD"
}
}
@@ -305,8 +305,8 @@ export interface ImportPreview {
orgNumber: string | null
// Fiscal year
fiscalYearStart: Date | null
fiscalYearEnd: Date | null
fiscalYearStart: string | null // "YYYY-MM-DD"
fiscalYearEnd: string | null // "YYYY-MM-DD"
// Statistics
accountCount: number