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:
Mattsson
2026-05-11 17:12:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d0fbc2b616
commit a53a119a2e
20 changed files with 1761 additions and 112 deletions
+76
View File
@@ -0,0 +1,76 @@
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { NextResponse } from 'next/server'
let redis: Redis | null = null
function getRedis(): Redis | null {
if (redis) return redis
const url = process.env.UPSTASH_REDIS_REST_URL
const token = process.env.UPSTASH_REDIS_REST_TOKEN
if (!url || !token) return null
redis = new Redis({ url, token })
return redis
}
const limiters = new Map<string, Ratelimit>()
function getLimiter(prefix: string, maxRequests: number, windowMs: number): Ratelimit | null {
const key = `${prefix}:${maxRequests}:${windowMs}`
const cached = limiters.get(key)
if (cached) return cached
const client = getRedis()
if (!client) return null
const limiter = new Ratelimit({
redis: client,
limiter: Ratelimit.slidingWindow(maxRequests, `${windowMs} ms`),
prefix,
analytics: false,
})
limiters.set(key, limiter)
return limiter
}
export interface RateLimitOptions {
prefix: string
identifier: string
maxRequests: number
windowMs: number
}
export interface RateLimitResult {
ok: boolean
response?: NextResponse
}
/**
* HTTP rate limit check using Upstash Ratelimit (sliding window).
*
* Returns `{ ok: true }` when the request is allowed.
* Returns `{ ok: false, response }` with a 429 NextResponse when blocked.
*
* No-ops (allows the request) when Upstash env vars are not configured —
* intentional so local dev and self-hosted deployments without Redis still work.
* Production hosted deployments must set UPSTASH_REDIS_REST_URL/TOKEN for the
* limit to be enforced; absence is logged once at startup by other call sites.
*/
export async function checkRateLimit(opts: RateLimitOptions): Promise<RateLimitResult> {
const limiter = getLimiter(opts.prefix, opts.maxRequests, opts.windowMs)
if (!limiter) return { ok: true }
const { success, reset, limit, remaining } = await limiter.limit(opts.identifier)
if (success) return { ok: true }
const retryAfterSec = Math.max(1, Math.ceil((reset - Date.now()) / 1000))
const response = NextResponse.json(
{ error: 'För många förfrågningar. Försök igen om en stund.' },
{ status: 429 }
)
response.headers.set('Retry-After', String(retryAfterSec))
response.headers.set('X-RateLimit-Limit', String(limit))
response.headers.set('X-RateLimit-Remaining', String(remaining))
response.headers.set('X-RateLimit-Reset', String(Math.ceil(reset / 1000)))
return { ok: false, response }
}
+12
View File
@@ -39,6 +39,11 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
'mcp.tool_called',
'mcp.tools_list_called',
'mcp.resource_read',
// Bank connection consent lifecycle — required audit trail per ASVS V16
// and GDPR Art.30 (records of processing) for PSD2 consent decisions.
'bank_connection.consent_granted',
'bank_connection.account_selection_changed',
'bank_connection.revoked',
]
// Excluded (with reasoning):
@@ -65,6 +70,13 @@ function extractEntityId(payload: Record<string, unknown>): string | null {
}
}
// Flat-string ID fields on events that don't carry a full entity object.
// Bank connection events fall into this category — the connection lives in
// an extension table, so we record its id directly.
if (typeof payload.connectionId === 'string') {
return payload.connectionId
}
// For journal_entry.corrected: use the corrected entry's ID
if ('corrected' in payload) {
const corrected = payload.corrected
+5
View File
@@ -35,6 +35,11 @@ export type CoreEvent =
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string; companyId: string } }
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string; companyId: string } }
// Bank connection lifecycle — consent + account selection are the
// GDPR/PSD2 audit points; emitted to event_log for compliance trail.
| { type: 'bank_connection.consent_granted'; payload: { connectionId: string; bankName: string | null; accountCount: number; consentExpiresAt: string | null; userId: string; companyId: string } }
| { type: 'bank_connection.account_selection_changed'; payload: { connectionId: string; bankName: string | null; previousStatus: string; newStatus: string; enabledCount: number; totalCount: number; userId: string; companyId: string } }
| { type: 'bank_connection.revoked'; payload: { connectionId: string; bankName: string | null; userId: string; companyId: string } }
// Periods
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
+107 -3
View File
@@ -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
View File
@@ -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
@@ -918,3 +918,135 @@ describe('SKV §4.1.1.4 cross-field contracts', () => {
expect(r.ruta49).toBe(expected)
})
})
// ============================================================
// Parent/summary BAS accounts — 2610/2620/2630 (output),
// 2618/2628/2638 (vilande), 2640 (input parent).
//
// Users who post directly to the group account (manual entries, SIE imports,
// alternate templates) had their balances silently dropped before this fix
// because only the leaf accounts were mapped.
// ============================================================
describe('calculateVatDeclaration — parent/summary accounts', () => {
it('maps 2610 (parent) to ruta10 when posted directly', async () => {
results = [
{
data: [
{ account_number: '1910', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2610', debit_amount: 0, credit_amount: 2500 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(10000)
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta49).toBe(2500) // owed, not refund
})
it('maps 2620 (parent) to ruta11 and 2630 (parent) to ruta12', async () => {
results = [
{
data: [
{ account_number: '2620', debit_amount: 0, credit_amount: 600 },
{ account_number: '2630', debit_amount: 0, credit_amount: 180 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
})
it('maps vilande output VAT (2618/2628/2638) to ruta10/11/12', async () => {
// Vilande accounts hold output VAT for invoices that have been sent but not
// yet paid, used by cash-method bookkeepers per BFNAR 2006:1.
results = [
{
data: [
{ account_number: '2618', debit_amount: 0, credit_amount: 500 },
{ account_number: '2628', debit_amount: 0, credit_amount: 120 },
{ account_number: '2638', debit_amount: 0, credit_amount: 60 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(500)
expect(result.rutor.ruta11).toBe(120)
expect(result.rutor.ruta12).toBe(60)
})
it('sums parent and sub-account balances on the same ruta', async () => {
// If a ledger has activity on both the parent and the sub-accounts (mixed
// bookkeeping practice, SIE imports, etc.), the ruta reflects the literal
// ledger total — accounting truth wins.
results = [
{
data: [
{ account_number: '2610', debit_amount: 0, credit_amount: 1000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 500 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(1500)
})
it('maps 2640 (input VAT parent) to ruta48', async () => {
results = [
{
data: [
{ account_number: '2640', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(200)
expect(result.rutor.ruta49).toBe(-200) // refund
})
it('reproduces the user-reported bug: 2610 balance now reaches ruta10', async () => {
// Customer screenshot scenario (simplified): 3001 + 2610 booked with the
// correct VAT amount on the parent account. Before the fix, ruta10 read 0
// and ruta49 incorrectly showed a refund.
results = [
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 21600 },
{ account_number: '2610', debit_amount: 0, credit_amount: 9768 },
{ account_number: '2641', debit_amount: 7048.45, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'yearly', 2025, 1)
expect(result.rutor.ruta05).toBe(21600)
expect(result.rutor.ruta10).toBe(9768)
expect(result.rutor.ruta48).toBe(7048.45)
expect(result.rutor.ruta49).toBe(2719.55) // 9768 − 7048.45, owed (was −7048.45 pre-fix)
})
})
+11 -1
View File
@@ -28,9 +28,12 @@ import type {
* (`.claude/skills/swedish-vat/references/vat-compliance-reference.md` §7).
*
* Output VAT (261x/262x/263x) → ruta 10/11/12 per rate (credit balance)
* Includes parent/summary accounts (2610/2620/2630) for users who post
* directly to the group account, and vilande accounts (2618/2628/2638)
* used by cash-method bookkeepers for invoices not yet paid.
* Reverse charge output (2614/2624/2634) → ruta 30/31/32 (credit)
* Import VAT (2615/2625/2635) → ruta 60/61/62 (credit)
* Input VAT (2641-2649) → ruta 48 (debit)
* Input VAT (2640-2649) → ruta 48 (debit), incl. parent 2640
* Domestic taxable sales (3001-3003) → ruta 05 (credit)
* Uttag (3401-3403) → ruta 06 (credit)
* EU goods (3108) → ruta 35; EU services (3308) → ruta 39 (credit)
@@ -46,25 +49,32 @@ import type {
*/
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
// Output VAT 25% → ruta 10
'2610': { box: 'ruta10', side: 'credit' }, // Utgående moms 25% (summary/parent)
'2611': { box: 'ruta10', side: 'credit' }, // Försäljning inom Sverige
'2612': { box: 'ruta10', side: 'credit' }, // Egna uttag
'2613': { box: 'ruta10', side: 'credit' }, // Uthyrning (frivillig skattskyldighet)
'2616': { box: 'ruta10', side: 'credit' }, // Vinstmarginalbeskattning
'2618': { box: 'ruta10', side: 'credit' }, // Vilande utgående moms 25%
// Output VAT 12% → ruta 11
'2620': { box: 'ruta11', side: 'credit' }, // Utgående moms 12% (summary/parent)
'2621': { box: 'ruta11', side: 'credit' },
'2622': { box: 'ruta11', side: 'credit' }, // Egna uttag
'2623': { box: 'ruta11', side: 'credit' }, // Uthyrning
'2626': { box: 'ruta11', side: 'credit' }, // VMB
'2628': { box: 'ruta11', side: 'credit' }, // Vilande utgående moms 12%
// Output VAT 6% → ruta 12
'2630': { box: 'ruta12', side: 'credit' }, // Utgående moms 6% (summary/parent)
'2631': { box: 'ruta12', side: 'credit' },
'2632': { box: 'ruta12', side: 'credit' }, // Egna uttag
'2633': { box: 'ruta12', side: 'credit' }, // Uthyrning
'2636': { box: 'ruta12', side: 'credit' }, // VMB
'2638': { box: 'ruta12', side: 'credit' }, // Vilande utgående moms 6%
// Reverse charge output VAT → ruta 30/31/32
'2614': { box: 'ruta30', side: 'credit' },
'2624': { box: 'ruta31', side: 'credit' },
'2634': { box: 'ruta32', side: 'credit' },
// Input VAT → ruta 48
'2640': { box: 'ruta48', side: 'debit' }, // Ingående moms (summary/parent)
'2641': { box: 'ruta48', side: 'debit' }, // Debiterad ingående moms
'2642': { box: 'ruta48', side: 'debit' }, // Frivillig skattskyldighet
'2645': { box: 'ruta48', side: 'debit' }, // Förvärv utlandet (EU/non-EU RC)