* fix: consolidate commit_journal_entry to single 4-arg signature Replaces the phantom-overload drop migration with an idempotent consolidation that leaves only the 4-arg-with-defaults signature, callable with either 2 or 4 named args. Fixes the "Could not choose the best candidate function" ambiguity caused when the commit-metadata migration CREATE OR REPLACE'd a 4-arg version alongside the existing 2-arg one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: preserve SIE source voucher identity on journal entries Adds source_voucher_series / source_voucher_number columns to journal_entries so per-verifikat traceability survives the importer's skip-empty-voucher logic. The SIE importer populates the original series/number even when skipped vouchers cause gnubok's target numbering to drift from the source file's sequence. Required for BFNAR 2013:2 kap 8 behandlingshistorik. - Migration adds columns + partial index + extends immutability trigger - importVouchers() records rawSeries/rawNumber per voucher - JournalEntry type + test fixtures gain the new fields - Bookkeeping detail page surfaces "Ursprungligt verifikat" when present Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: polish invoice-inbox workspace for production use - Bedrock image fit: shrink images > 5 MB via sharp before Bedrock upload so HEIC/high-res phone photos don't fail with the 5 MB cap - Swedish error mapping: toSwedishInboxError translates Bedrock / infrastructure errors to Swedish sentences stored in error_message - History timeline endpoint (GET /items/:id/history) returns the processing_history events correlated to the inbox item - Workspace UI: inline diagnostic timeline inside the convert dialog, same-email row grouping ("+N dokument" chip), inferred-VAT affordance with "needs review" signalling, Riksbanken exchange-rate prefill for foreign-currency invoices so the supplier-invoice create path populates *_sek audit columns Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: extend inbox-smart-match to supplier invoices Both receipts and supplier invoices expose structurally identical match anchors (date, amount, currency, counterparty name) so the matcher can reuse the same narrowing + LLM prompt. Adds getMatchAnchors() as a shared extractor across ReceiptExtractionResult / InvoiceExtractionResult, and updates the event handlers to process supplier_invoice items alongside receipts. LLM prompt re-phrased as "dokument" rather than "kvitto" and loosened the date-window heuristic since invoice payments can lag behind the invoice date by weeks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: drop unused category selector from TransactionForm The manual "Lägg till transaktion" dialog predates the current categorization flow (SwipeCategorizationView, BatchCategorySelector, AI suggestions). The category dropdown here never drove journal-entry creation — onSubmit fanned it out to CreateTransactionInput.category, which is optional. Removes the dropdown, the unused watch() hook, and the categories lookup table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrations): restore drop-phantom file and rebump timestamps Supabase branch DB failed with PK violation on schema_migrations because my two migrations collided with timestamps already on main: 20260421120000 → journal_entries_with_related_rpc (PR #298) 20260421130000 → drop_legacy_supplier_invoice_user_id_uniqueness (PR #296) Rebumped to 20260421140000 and 20260421150000 so each migration has a unique version (Supabase uses only the 14-digit prefix as the PK). Also restored the 20260420130000_drop_phantom_commit_journal_entry_overload migration I had deleted — CLAUDE.md rule #5 forbids modifying existing migrations. My consolidate migration is still compatible: drop_phantom drops the 4-arg overload (no-op where absent), then consolidate recreates it with defaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(inbox-smart-match): anchor invoices on dueDate with wider window The original ±7d window around invoiceDate filtered out all real payments for invoices with standard 30–60 day terms — the matcher would see zero candidates before the LLM was called, making the supplier-invoice matcher effectively dead. New anchor selection: - Receipts: receipt date ±7 days (unchanged; paid on the spot) - Invoices with dueDate: dueDate ±14 days (covers early/late payments) - Invoices without dueDate: invoiceDate -7/+45 days (covers 30-day terms) MatchAnchors now carries windowDaysBefore/After so the window can vary per document shape. Added three getMatchAnchors tests asserting window sizes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
763 lines
24 KiB
TypeScript
763 lines
24 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
||
import {
|
||
generateImportPreview,
|
||
validateIBBalance,
|
||
isBalanceSheetAccount,
|
||
ensureFiscalPeriod,
|
||
importVouchers,
|
||
computeVoucherNumberRanges,
|
||
} from '../sie-import'
|
||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||
import type { ParsedSIEFile, AccountMapping } from '../types'
|
||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
|
||
// --- Helpers ---
|
||
|
||
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||
return {
|
||
header: {
|
||
sieType: 4,
|
||
flagga: 0,
|
||
program: 'TestProg',
|
||
programVersion: '1.0',
|
||
generatedDate: '2024-01-01',
|
||
format: 'PC8',
|
||
companyName: 'Test AB',
|
||
orgNumber: '5566778899',
|
||
address: null,
|
||
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
|
||
currency: 'SEK',
|
||
kontoPlanType: null,
|
||
},
|
||
accounts: [
|
||
{ number: '1510', name: 'Kundfordringar' },
|
||
{ number: '1930', name: 'Företagskonto' },
|
||
{ number: '2440', name: 'Leverantörsskulder' },
|
||
],
|
||
openingBalances: [
|
||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||
{ yearIndex: 0, account: '1930', amount: 100000 },
|
||
{ yearIndex: 0, account: '2440', amount: -150000 },
|
||
],
|
||
closingBalances: [],
|
||
resultBalances: [],
|
||
vouchers: [
|
||
{
|
||
series: 'A',
|
||
number: 1,
|
||
date: new Date(2024, 0, 15),
|
||
description: 'Faktura 1001',
|
||
lines: [
|
||
{ account: '1510', amount: 12500 },
|
||
{ account: '3001', amount: -10000 },
|
||
{ account: '2611', amount: -2500 },
|
||
],
|
||
},
|
||
],
|
||
issues: [],
|
||
stats: {
|
||
totalAccounts: 3,
|
||
totalVouchers: 1,
|
||
totalTransactionLines: 3,
|
||
fiscalYearStart: '2024-01-01',
|
||
fiscalYearEnd: '2024-12-31',
|
||
},
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeMapping(source: string, target: string, confidence: number = 1.0): AccountMapping {
|
||
return {
|
||
sourceAccount: source,
|
||
sourceName: `Account ${source}`,
|
||
targetAccount: target,
|
||
targetName: `Target ${target}`,
|
||
confidence,
|
||
matchType: target ? 'exact' : 'manual',
|
||
isOverride: false,
|
||
}
|
||
}
|
||
|
||
// --- Tests ---
|
||
|
||
describe('generateImportPreview', () => {
|
||
describe('trial balance from IB', () => {
|
||
it('calculates debit totals from positive IB amounts', () => {
|
||
const parsed = makeParsedFile()
|
||
const mappings = [
|
||
makeMapping('1510', '1510'),
|
||
makeMapping('1930', '1930'),
|
||
makeMapping('2440', '2440'),
|
||
]
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
// Positive amounts: 50000 + 100000 = 150000
|
||
expect(preview.trialBalance.totalDebit).toBe(150000)
|
||
})
|
||
|
||
it('calculates credit totals from negative IB amounts', () => {
|
||
const parsed = makeParsedFile()
|
||
const mappings = [
|
||
makeMapping('1510', '1510'),
|
||
makeMapping('1930', '1930'),
|
||
makeMapping('2440', '2440'),
|
||
]
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
// Negative amounts: |-150000| = 150000
|
||
expect(preview.trialBalance.totalCredit).toBe(150000)
|
||
})
|
||
|
||
it('detects balanced trial balance', () => {
|
||
const parsed = makeParsedFile()
|
||
const mappings = [makeMapping('1510', '1510')]
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
// 150000 debit = 150000 credit
|
||
expect(preview.trialBalance.isBalanced).toBe(true)
|
||
})
|
||
|
||
it('detects unbalanced trial balance', () => {
|
||
const parsed = makeParsedFile({
|
||
openingBalances: [
|
||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||
{ yearIndex: 0, account: '1930', amount: 100000 },
|
||
// Missing credit side — only 150000 debit, 0 credit
|
||
],
|
||
})
|
||
const mappings = [makeMapping('1510', '1510')]
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
expect(preview.trialBalance.isBalanced).toBe(false)
|
||
})
|
||
|
||
it('handles zero opening balances', () => {
|
||
const parsed = makeParsedFile({ openingBalances: [] })
|
||
const mappings: AccountMapping[] = []
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
expect(preview.trialBalance.totalDebit).toBe(0)
|
||
expect(preview.trialBalance.totalCredit).toBe(0)
|
||
expect(preview.trialBalance.isBalanced).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('company info passthrough', () => {
|
||
it('passes company name', () => {
|
||
const parsed = makeParsedFile()
|
||
const preview = generateImportPreview(parsed, [])
|
||
expect(preview.companyName).toBe('Test AB')
|
||
})
|
||
|
||
it('passes org number', () => {
|
||
const parsed = makeParsedFile()
|
||
const preview = generateImportPreview(parsed, [])
|
||
expect(preview.orgNumber).toBe('5566778899')
|
||
})
|
||
|
||
it('handles null company info', () => {
|
||
const parsed = makeParsedFile({
|
||
header: {
|
||
...makeParsedFile().header,
|
||
companyName: null,
|
||
orgNumber: null,
|
||
},
|
||
})
|
||
const preview = generateImportPreview(parsed, [])
|
||
expect(preview.companyName).toBeNull()
|
||
expect(preview.orgNumber).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('mapping status', () => {
|
||
it('reflects mapper output counts', () => {
|
||
const parsed = makeParsedFile()
|
||
const mappings = [
|
||
makeMapping('1510', '1510'), // mapped
|
||
makeMapping('1930', '1930'), // mapped
|
||
makeMapping('2440', '', 0), // unmapped
|
||
]
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
expect(preview.mappingStatus.total).toBe(3)
|
||
expect(preview.mappingStatus.mapped).toBe(2)
|
||
expect(preview.mappingStatus.unmapped).toBe(1)
|
||
})
|
||
|
||
it('reports low confidence mappings', () => {
|
||
const mappings = [
|
||
makeMapping('1510', '1510', 1.0),
|
||
makeMapping('3400', '3001', 0.3), // low confidence
|
||
]
|
||
const parsed = makeParsedFile()
|
||
const preview = generateImportPreview(parsed, mappings)
|
||
|
||
expect(preview.mappingStatus.lowConfidence).toBe(1)
|
||
})
|
||
})
|
||
|
||
describe('statistics', () => {
|
||
it('passes account count', () => {
|
||
const parsed = makeParsedFile()
|
||
const preview = generateImportPreview(parsed, [])
|
||
expect(preview.accountCount).toBe(3)
|
||
})
|
||
|
||
it('passes voucher count', () => {
|
||
const parsed = makeParsedFile()
|
||
const preview = generateImportPreview(parsed, [])
|
||
expect(preview.voucherCount).toBe(1)
|
||
})
|
||
|
||
it('passes transaction line count', () => {
|
||
const parsed = makeParsedFile()
|
||
const preview = generateImportPreview(parsed, [])
|
||
expect(preview.transactionLineCount).toBe(3)
|
||
})
|
||
})
|
||
|
||
describe('issues passthrough', () => {
|
||
it('passes parse issues to preview', () => {
|
||
const parsed = makeParsedFile({
|
||
issues: [
|
||
{ severity: 'warning', line: 5, message: 'Okänd tagg: #FOO — ignoreras', tag: 'FOO' },
|
||
{ severity: 'error', line: 10, message: 'Invalid voucher', tag: 'VER' },
|
||
],
|
||
})
|
||
const preview = generateImportPreview(parsed, [])
|
||
|
||
expect(preview.issues).toHaveLength(2)
|
||
expect(preview.issues[0].severity).toBe('warning')
|
||
expect(preview.issues[1].severity).toBe('error')
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('validateIBBalance', () => {
|
||
it('returns 0 roundingAdjustment when IB is balanced', () => {
|
||
const parsed = makeParsedFile({
|
||
openingBalances: [
|
||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||
],
|
||
})
|
||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||
const result = validateIBBalance(parsed, accountMap)
|
||
|
||
expect(result.roundingAdjustment).toBe(0)
|
||
expect(result.fileImbalance).toBe(0)
|
||
expect(result.excludedAccountsTotal).toBe(0)
|
||
expect(result.lines).toHaveLength(2)
|
||
})
|
||
|
||
it('returns rounding adjustment for imbalance <= 1 SEK', () => {
|
||
const parsed = makeParsedFile({
|
||
openingBalances: [
|
||
{ yearIndex: 0, account: '1510', amount: 50000.50 },
|
||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||
],
|
||
})
|
||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||
const result = validateIBBalance(parsed, accountMap)
|
||
|
||
expect(result.roundingAdjustment).toBe(0.5)
|
||
expect(result.fileImbalance).toBe(0.5)
|
||
})
|
||
|
||
it('returns large adjustment for file-level imbalance (unallocated årets resultat)', () => {
|
||
// Simulates a Fortnox export where previous year result hasn't been allocated
|
||
// to equity — BS accounts don't balance because årets resultat is implicit
|
||
const parsed = makeParsedFile({
|
||
openingBalances: [
|
||
{ yearIndex: 0, account: '1510', amount: 50100 },
|
||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||
],
|
||
})
|
||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||
const result = validateIBBalance(parsed, accountMap)
|
||
|
||
// The adjustment is 100 SEK — caller should book to 2099, never reject
|
||
expect(result.roundingAdjustment).toBe(100)
|
||
expect(result.fileImbalance).toBe(100)
|
||
expect(result.excludedAccountsTotal).toBe(0)
|
||
})
|
||
|
||
it('tracks excluded accounts separately from file imbalance (Fortnox system accounts)', () => {
|
||
// Simulates Fortnox 0099 carrying IB balance — file is balanced,
|
||
// but mapped accounts are not because 0099 is excluded from mapping
|
||
const parsed = makeParsedFile({
|
||
openingBalances: [
|
||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||
{ yearIndex: 0, account: '2440', amount: -150000 },
|
||
{ yearIndex: 0, account: '0099', amount: 100000 }, // System account, not mapped
|
||
],
|
||
})
|
||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||
const result = validateIBBalance(parsed, accountMap)
|
||
|
||
// File-level: 50000 + (-150000) + 100000 = 0, balanced
|
||
expect(result.fileImbalance).toBe(0)
|
||
// Mapped-level: 50000 debit, 150000 credit = -100000 diff
|
||
expect(result.roundingAdjustment).toBe(-100000)
|
||
// The excluded 0099 accounts for the entire difference
|
||
expect(result.excludedAccountsTotal).toBe(100000)
|
||
// Only 2 lines (0099 excluded)
|
||
expect(result.lines).toHaveLength(2)
|
||
})
|
||
|
||
it('ignores non-current-year balances', () => {
|
||
const parsed = makeParsedFile({
|
||
openingBalances: [
|
||
{ yearIndex: 0, account: '1510', amount: 50000 },
|
||
{ yearIndex: 0, account: '2440', amount: -50000 },
|
||
{ yearIndex: -1, account: '1510', amount: 99999 }, // Previous year — ignored
|
||
],
|
||
})
|
||
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
|
||
const result = validateIBBalance(parsed, accountMap)
|
||
|
||
expect(result.roundingAdjustment).toBe(0)
|
||
expect(result.lines).toHaveLength(2)
|
||
})
|
||
})
|
||
|
||
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 an earlier period already exists', async () => {
|
||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||
enqueueMany([
|
||
{ data: null, error: null }, // containing check — no match
|
||
{ data: [], error: null }, // overlapping check — none
|
||
{ data: [{ id: 'earlier' }], error: null }, // earlier period exists
|
||
])
|
||
|
||
await expect(
|
||
ensureFiscalPeriod(
|
||
supabase as unknown as Supabase,
|
||
'company-id',
|
||
'2026-04-16',
|
||
'2026-12-31',
|
||
),
|
||
).rejects.toThrow(/kronologiskt 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: [], error: null }, // no earlier 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: [], error: null }, // no earlier period
|
||
{ 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('allows mid-month start when importing a retroactive earliest period', async () => {
|
||
// Scenario: onboarding created a 2026 fiscal period, user now imports
|
||
// an SIE for their förlängt första räkenskapsår 2017-07-28 – 2018-12-31.
|
||
// The 2017 period is chronologically earliest, so mid-month start is
|
||
// legal under BFL 3 kap.
|
||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||
enqueueMany([
|
||
{ data: null, error: null }, // containing check — no match
|
||
{ data: [], error: null }, // overlapping check — none (2017 vs 2026)
|
||
{ data: [], error: null }, // no earlier period than 2017-07-28
|
||
{ data: { id: 'retro-first-year-id' }, error: null }, // insert
|
||
])
|
||
|
||
const id = await ensureFiscalPeriod(
|
||
supabase as unknown as Supabase,
|
||
'company-id',
|
||
'2017-07-28',
|
||
'2018-12-31',
|
||
)
|
||
|
||
expect(id).toBe('retro-first-year-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)
|
||
expect(isBalanceSheetAccount('1930')).toBe(true)
|
||
})
|
||
|
||
it('returns true for class 2 (liabilities/equity)', () => {
|
||
expect(isBalanceSheetAccount('2099')).toBe(true)
|
||
expect(isBalanceSheetAccount('2440')).toBe(true)
|
||
})
|
||
|
||
it('returns false for class 3 (revenue)', () => {
|
||
expect(isBalanceSheetAccount('3001')).toBe(false)
|
||
expect(isBalanceSheetAccount('3740')).toBe(false)
|
||
})
|
||
|
||
it('returns false for class 4-8 (expenses)', () => {
|
||
expect(isBalanceSheetAccount('4010')).toBe(false)
|
||
expect(isBalanceSheetAccount('5010')).toBe(false)
|
||
expect(isBalanceSheetAccount('6211')).toBe(false)
|
||
expect(isBalanceSheetAccount('7210')).toBe(false)
|
||
expect(isBalanceSheetAccount('8999')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('computeVoucherNumberRanges', () => {
|
||
it('returns empty array for no mapping', () => {
|
||
expect(computeVoucherNumberRanges([])).toEqual([])
|
||
})
|
||
|
||
it('produces one range per series with correct from/to', () => {
|
||
const ranges = computeVoucherNumberRanges([
|
||
{ sourceId: 'B1', series: 'B', targetNumber: 1 },
|
||
{ sourceId: 'B2', series: 'B', targetNumber: 2 },
|
||
{ sourceId: 'B3', series: 'B', targetNumber: 3 },
|
||
{ sourceId: 'C1', series: 'C', targetNumber: 1 },
|
||
{ sourceId: 'C2', series: 'C', targetNumber: 2 },
|
||
{ sourceId: 'V1', series: 'V', targetNumber: 1 },
|
||
])
|
||
|
||
expect(ranges).toEqual([
|
||
{ series: 'B', from: 1, to: 3 },
|
||
{ series: 'C', from: 1, to: 2 },
|
||
{ series: 'V', from: 1, to: 1 },
|
||
])
|
||
})
|
||
|
||
it('handles non-contiguous target numbers per series', () => {
|
||
const ranges = computeVoucherNumberRanges([
|
||
{ sourceId: 'B1', series: 'B', targetNumber: 5 },
|
||
{ sourceId: 'B2', series: 'B', targetNumber: 9 },
|
||
])
|
||
expect(ranges).toEqual([{ series: 'B', from: 5, to: 9 }])
|
||
})
|
||
})
|
||
|
||
describe('importVouchers — per-voucher series preservation', () => {
|
||
// Captures the rows passed to `.insert()` so the test can assert on
|
||
// voucher_series per inserted record. Uses a hand-rolled mock rather than
|
||
// createQueuedMockSupabase because we need to inspect arguments, not just
|
||
// return queued data.
|
||
function buildCapturingSupabase() {
|
||
const journalEntryInserts: Array<Record<string, unknown>> = []
|
||
const journalEntryLineInserts: Array<Record<string, unknown>> = []
|
||
const rpcCalls: Array<{ name: string; args: Record<string, unknown> }> = []
|
||
|
||
// Each `next_voucher_number` RPC call auto-increments per series, matching
|
||
// the DB function's ON CONFLICT behavior.
|
||
const nextNumberBySeries = new Map<string, number>()
|
||
|
||
let syntheticEntryId = 1
|
||
|
||
const supabase = {
|
||
from: vi.fn((table: string) => {
|
||
if (table === 'chart_of_accounts') {
|
||
// Return all accounts used in test vouchers as if already active
|
||
return {
|
||
select: () => ({
|
||
eq: () => ({
|
||
in: (_col: string, accountNumbers: string[]) => ({
|
||
then: (resolve: (v: { data: { id: string; account_number: string }[]; error: null }) => void) =>
|
||
resolve({
|
||
data: accountNumbers.map((num, i) => ({ id: `acc-${i}`, account_number: num })),
|
||
error: null,
|
||
}),
|
||
}),
|
||
}),
|
||
}),
|
||
}
|
||
}
|
||
|
||
if (table === 'journal_entries') {
|
||
return {
|
||
insert: (rows: Array<Record<string, unknown>>) => {
|
||
journalEntryInserts.push(...rows)
|
||
return {
|
||
select: () => ({
|
||
then: (resolve: (v: { data: { id: string }[]; error: null }) => void) =>
|
||
resolve({
|
||
data: rows.map(() => ({ id: `entry-${syntheticEntryId++}` })),
|
||
error: null,
|
||
}),
|
||
}),
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
if (table === 'journal_entry_lines') {
|
||
return {
|
||
insert: (rows: Array<Record<string, unknown>>) => {
|
||
journalEntryLineInserts.push(...rows)
|
||
return Promise.resolve({ error: null })
|
||
},
|
||
}
|
||
}
|
||
|
||
throw new Error(`Unexpected table: ${table}`)
|
||
}),
|
||
|
||
rpc: vi.fn(async (name: string, args: Record<string, unknown>) => {
|
||
rpcCalls.push({ name, args })
|
||
if (name === 'next_voucher_number') {
|
||
const series = args.p_series as string
|
||
const current = nextNumberBySeries.get(series) ?? 0
|
||
const next = current + 1
|
||
nextNumberBySeries.set(series, next)
|
||
return { data: next, error: null }
|
||
}
|
||
if (name === 'reserve_voucher_range') {
|
||
const series = args.p_series as string
|
||
const highest = args.p_highest_used as number
|
||
nextNumberBySeries.set(series, highest)
|
||
return { data: null, error: null }
|
||
}
|
||
if (name === 'release_voucher_range') {
|
||
return { data: null, error: null }
|
||
}
|
||
throw new Error(`Unexpected RPC: ${name}`)
|
||
}),
|
||
}
|
||
|
||
return {
|
||
supabase: supabase as unknown as SupabaseClient,
|
||
journalEntryInserts,
|
||
journalEntryLineInserts,
|
||
rpcCalls,
|
||
}
|
||
}
|
||
|
||
function makeVoucher(
|
||
series: string,
|
||
number: number,
|
||
lines: Array<{ account: string; amount: number }> = [
|
||
{ account: '1510', amount: 1000 },
|
||
{ account: '3001', amount: -1000 },
|
||
],
|
||
) {
|
||
return {
|
||
series,
|
||
number,
|
||
date: new Date(2024, 0, 15),
|
||
description: `Voucher ${series}${number}`,
|
||
lines,
|
||
}
|
||
}
|
||
|
||
const baseMap = new Map([
|
||
['1510', '1510'],
|
||
['3001', '3001'],
|
||
])
|
||
|
||
it('routes each voucher to its source series (B, C, V → B, C, V)', async () => {
|
||
const { supabase, journalEntryInserts, rpcCalls } = buildCapturingSupabase()
|
||
const parsed = makeParsedFile({
|
||
vouchers: [
|
||
makeVoucher('B', 1),
|
||
makeVoucher('B', 2),
|
||
makeVoucher('C', 1),
|
||
makeVoucher('V', 1),
|
||
],
|
||
})
|
||
|
||
const result = await importVouchers(
|
||
supabase,
|
||
'company-1',
|
||
'user-1',
|
||
'period-1',
|
||
parsed,
|
||
baseMap,
|
||
'B', // fallback (should not be used here — all vouchers have series)
|
||
)
|
||
|
||
expect(result.created).toBe(4)
|
||
expect(new Set(result.seriesUsed)).toEqual(new Set(['B', 'C', 'V']))
|
||
|
||
const seriesInInserts = journalEntryInserts.map((r) => r.voucher_series)
|
||
expect(seriesInInserts).toEqual(['B', 'B', 'C', 'V'])
|
||
|
||
// Each series reserves its own voucher-number range independently
|
||
const reserveCalls = rpcCalls.filter((c) => c.name === 'reserve_voucher_range')
|
||
expect(reserveCalls.map((c) => c.args.p_series)).toEqual(['B', 'C', 'V'])
|
||
})
|
||
|
||
it('falls back to defaultSeries when source voucher has empty series (SIE4I)', async () => {
|
||
const { supabase, journalEntryInserts } = buildCapturingSupabase()
|
||
const parsed = makeParsedFile({
|
||
vouchers: [
|
||
{ ...makeVoucher('', 1) },
|
||
{ ...makeVoucher('', 2) },
|
||
],
|
||
})
|
||
|
||
const result = await importVouchers(
|
||
supabase,
|
||
'company-1',
|
||
'user-1',
|
||
'period-1',
|
||
parsed,
|
||
baseMap,
|
||
'V', // fallback used because source series is empty
|
||
)
|
||
|
||
expect(result.created).toBe(2)
|
||
expect(result.seriesUsed).toEqual(['V'])
|
||
expect(journalEntryInserts.every((r) => r.voucher_series === 'V')).toBe(true)
|
||
})
|
||
|
||
it('records source series in voucherNumberMapping for audit trail', async () => {
|
||
const { supabase } = buildCapturingSupabase()
|
||
const parsed = makeParsedFile({
|
||
vouchers: [
|
||
makeVoucher('B', 1),
|
||
makeVoucher('C', 7),
|
||
],
|
||
})
|
||
|
||
const result = await importVouchers(
|
||
supabase,
|
||
'company-1',
|
||
'user-1',
|
||
'period-1',
|
||
parsed,
|
||
baseMap,
|
||
'B',
|
||
)
|
||
|
||
expect(result.voucherNumberMapping).toEqual([
|
||
{ sourceId: 'B1', series: 'B', targetNumber: 1 },
|
||
{ sourceId: 'C7', series: 'C', targetNumber: 1 },
|
||
])
|
||
})
|
||
|
||
it('assigns independent sequential target numbers per series', async () => {
|
||
const { supabase, journalEntryInserts } = buildCapturingSupabase()
|
||
const parsed = makeParsedFile({
|
||
vouchers: [
|
||
makeVoucher('B', 1),
|
||
makeVoucher('B', 2),
|
||
makeVoucher('B', 3),
|
||
makeVoucher('C', 1),
|
||
makeVoucher('C', 2),
|
||
],
|
||
})
|
||
|
||
await importVouchers(
|
||
supabase,
|
||
'company-1',
|
||
'user-1',
|
||
'period-1',
|
||
parsed,
|
||
baseMap,
|
||
'B',
|
||
)
|
||
|
||
const bNumbers = journalEntryInserts
|
||
.filter((r) => r.voucher_series === 'B')
|
||
.map((r) => r.voucher_number)
|
||
const cNumbers = journalEntryInserts
|
||
.filter((r) => r.voucher_series === 'C')
|
||
.map((r) => r.voucher_number)
|
||
|
||
// Each series starts at 1 and increments independently — not globally continuous
|
||
expect(bNumbers).toEqual([1, 2, 3])
|
||
expect(cNumbers).toEqual([1, 2])
|
||
})
|
||
|
||
it('preserves original source series/number on each imported entry, even across skipped vouchers', async () => {
|
||
const { supabase, journalEntryInserts } = buildCapturingSupabase()
|
||
// A2 is an empty voucher (no lines) — will be skipped. A1 and A3 survive.
|
||
// Gnubok assigns target numbers 1 and 2 (contiguous), but source_voucher_number
|
||
// must preserve the SIE originals (1 and 3) so traceability is not lost.
|
||
const parsed = makeParsedFile({
|
||
vouchers: [
|
||
makeVoucher('A', 1),
|
||
{ ...makeVoucher('A', 2), lines: [] },
|
||
makeVoucher('A', 3),
|
||
],
|
||
})
|
||
|
||
const result = await importVouchers(
|
||
supabase,
|
||
'company-1',
|
||
'user-1',
|
||
'period-1',
|
||
parsed,
|
||
baseMap,
|
||
'A',
|
||
)
|
||
|
||
expect(result.created).toBe(2)
|
||
expect(result.skippedEmpty).toBe(1)
|
||
expect(journalEntryInserts.map((r) => r.voucher_number)).toEqual([1, 2])
|
||
expect(journalEntryInserts.map((r) => r.source_voucher_series)).toEqual(['A', 'A'])
|
||
expect(journalEntryInserts.map((r) => r.source_voucher_number)).toEqual([1, 3])
|
||
})
|
||
|
||
it('stores NULL source series/number when the source voucher has no series (SIE4I subsystem import)', async () => {
|
||
const { supabase, journalEntryInserts } = buildCapturingSupabase()
|
||
const parsed = makeParsedFile({
|
||
vouchers: [
|
||
{ ...makeVoucher('', 1) },
|
||
],
|
||
})
|
||
|
||
await importVouchers(
|
||
supabase,
|
||
'company-1',
|
||
'user-1',
|
||
'period-1',
|
||
parsed,
|
||
baseMap,
|
||
'V',
|
||
)
|
||
|
||
expect(journalEntryInserts[0].source_voucher_series).toBeNull()
|
||
expect(journalEntryInserts[0].source_voucher_number).toBe(1)
|
||
})
|
||
})
|