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:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user