Fix/vat parent accounts (#438)
* feat(enable-banking): add support for account selection and syncing - Updated StoredAccount interface to include an 'enabled' flag for account syncing preferences. - Enhanced ensureFiscalPeriod function to handle overlapping fiscal periods with posted entries and opening balances. - Added tests for fiscal period validation and account syncing logic. - Implemented AccountPickerDialog component for user account selection. - Created API routes for PATCH /accounts and POST /sync to manage account syncing. - Introduced 'pending_selection' status for bank connections to allow user account selection before syncing. - Updated database migration to support new connection status and backfill existing accounts with enabled=true. * feat(enable-banking): implement account selection and consent event logging --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d0fbc2b616
commit
a53a119a2e
@@ -422,7 +422,7 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
expect(id).toBe('existing-period-id')
|
||||
})
|
||||
|
||||
it('rejects when an existing period overlaps the range but does not fully contain it', async () => {
|
||||
it('rejects when an existing period overlaps the range but already has posted entries', async () => {
|
||||
// Regression: previously fell through to the overlapping period silently,
|
||||
// which stamped every imported voucher with a fiscal_period_id whose
|
||||
// window did not cover the voucher's own entry_date — breaking the SIE
|
||||
@@ -437,6 +437,80 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
opening_balances_set: false,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ id: 'entry-1' }], error: null }, // journal_entries — has at least one
|
||||
])
|
||||
|
||||
await expect(
|
||||
ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2025-03-01', // Capelix-style broken FY March–Feb
|
||||
'2026-02-28',
|
||||
),
|
||||
).rejects.toThrow(/Inställningar → Företag/)
|
||||
})
|
||||
|
||||
it('replaces an overlapping period when it is empty (onboarding-seeded)', async () => {
|
||||
// Real-world Zerify AB case: onboarding seeded Räkenskapsår 2026 =
|
||||
// 2026-01-01 – 2026-12-31; the user has a förlängt första räkenskapsår
|
||||
// 2025-10-20 – 2026-12-31 (BFL 3 kap.) and imports an SIE for it.
|
||||
// The seeded period carries no data, so we replace it.
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: null, error: null }, // containing check — no match
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'seeded-2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
opening_balances_set: false,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // journal_entries — none
|
||||
{ data: [], error: null }, // earlier-period check — none (mid-month start)
|
||||
{ data: null, error: null }, // delete result
|
||||
{ data: { id: 'replaced-id' }, error: null }, // insert result
|
||||
])
|
||||
|
||||
const id = await ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2025-10-20',
|
||||
'2026-12-31',
|
||||
)
|
||||
|
||||
expect(id).toBe('replaced-id')
|
||||
})
|
||||
|
||||
it('refuses to replace an overlapping period whose opening balances are already set', async () => {
|
||||
// opening_balances_set: true short-circuits the replaceability gate before
|
||||
// we even look at journal_entries — the period clearly carries user data.
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: null, error: null },
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'with-ib-2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
opening_balances_set: true,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
@@ -447,8 +521,38 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2025-03-01', // Capelix-style broken FY March–Feb
|
||||
'2026-02-28',
|
||||
'2025-10-20',
|
||||
'2026-12-31',
|
||||
),
|
||||
).rejects.toThrow(/Inställningar → Företag/)
|
||||
})
|
||||
|
||||
it('refuses to replace an overlapping period that is locked', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: null, error: null },
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'locked-2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
name: 'Räkenskapsår 2026',
|
||||
is_closed: false,
|
||||
locked_at: '2026-03-15T10:00:00Z',
|
||||
opening_balances_set: false,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
])
|
||||
|
||||
await expect(
|
||||
ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2025-10-20',
|
||||
'2026-12-31',
|
||||
),
|
||||
).rejects.toThrow(/överlappar men matchar inte/)
|
||||
})
|
||||
|
||||
+56
-12
@@ -236,28 +236,53 @@ export async function ensureFiscalPeriod(
|
||||
return containing.id
|
||||
}
|
||||
|
||||
// If an existing period overlaps the requested range but does not fully
|
||||
// contain it, we MUST refuse — silently reusing it would stamp every
|
||||
// imported voucher with a fiscal_period_id whose date window doesn't match
|
||||
// the voucher's own date. That breaks the SIE invariant that #VER dates fall
|
||||
// inside #RAR, breaks BFL 5 kap. (verifikationsnummer per räkenskapsår),
|
||||
// and produces wrong-shaped trial balances per period.
|
||||
// An overlapping-but-not-containing period needs to be split into two cases:
|
||||
// - The period has any real content (posted entries, opening balances set,
|
||||
// closed, or locked): refuse. Silently reusing it would stamp imported
|
||||
// vouchers with a fiscal_period_id whose date window doesn't match the
|
||||
// voucher's own date — breaking the SIE invariant that #VER dates fall
|
||||
// inside #RAR and BFL 5 kap. (verifikationsnummer per räkenskapsår).
|
||||
// - The period is empty (onboarding-seeded with the default calendar year
|
||||
// but never used): replace it. The user has a förlängt räkenskapsår per
|
||||
// BFL 3 kap. that doesn't match the seeded period, and the seeded period
|
||||
// carries no data to preserve.
|
||||
const { data: overlapping } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, name')
|
||||
.select('id, period_start, period_end, name, is_closed, locked_at, opening_balances_set')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', endDate)
|
||||
.gte('period_end', startDate)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
let periodToReplaceId: string | null = null
|
||||
|
||||
if (overlapping && overlapping.length > 0) {
|
||||
const existing = overlapping[0]
|
||||
throw new Error(
|
||||
`SIE-filens räkenskapsår (${startDate} – ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i gnubok ` +
|
||||
`(${existing.name}: ${existing.period_start} – ${existing.period_end}). ` +
|
||||
`Justera räkenskapsåret i Inställningar → Räkenskap så att det matchar SIE-filen exakt, eller importera en SIE-fil som täcker exakt samma period.`
|
||||
)
|
||||
|
||||
const replaceableGateOpen =
|
||||
!existing.is_closed && !existing.locked_at && !existing.opening_balances_set
|
||||
|
||||
let hasEntries = true
|
||||
if (replaceableGateOpen) {
|
||||
const { data: existingEntries } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('fiscal_period_id', existing.id)
|
||||
.eq('company_id', companyId)
|
||||
.limit(1)
|
||||
hasEntries = (existingEntries?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
if (!replaceableGateOpen || hasEntries) {
|
||||
throw new Error(
|
||||
`SIE-filens räkenskapsår (${startDate} – ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i gnubok ` +
|
||||
`(${existing.name}: ${existing.period_start} – ${existing.period_end}). ` +
|
||||
`Justera räkenskapsåret i Inställningar → Företag så att det matchar SIE-filen exakt, eller importera en SIE-fil som täcker exakt samma period.`
|
||||
)
|
||||
}
|
||||
|
||||
periodToReplaceId = existing.id
|
||||
}
|
||||
|
||||
// Pre-validate against the DB-side enforce_period_start_day trigger so the
|
||||
@@ -295,6 +320,25 @@ export async function ensureFiscalPeriod(
|
||||
)
|
||||
}
|
||||
|
||||
// All date validation passed. If we identified an empty seeded period above,
|
||||
// delete it now — deferring the destructive step until after every check
|
||||
// keeps the seeded period intact when an SIE has malformed dates.
|
||||
// FK cascades: account_balances, voucher_sequences, voucher_gap_explanations
|
||||
// are ON DELETE CASCADE (all empty for a seeded period); sie_imports is
|
||||
// ON DELETE SET NULL; journal_entries is ON DELETE RESTRICT but we already
|
||||
// verified zero rows above.
|
||||
if (periodToReplaceId) {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.delete()
|
||||
.eq('id', periodToReplaceId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (deleteError) {
|
||||
throw new Error(`Kunde inte ersätta automatiskt skapat räkenskapsår: ${deleteError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new fiscal period
|
||||
const startYear = startParts.year
|
||||
const endYear = endParts.year
|
||||
|
||||
Reference in New Issue
Block a user