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
parent d0fbc2b616
commit a53a119a2e
20 changed files with 1761 additions and 112 deletions
+157
View File
@@ -0,0 +1,157 @@
# Authorization Policy
Status: **Approved Documented Security Decision**
Owner: Emil Mattsson (emil.mattsson@arcim.io)
Last reviewed: 2026-05-11
This document records authorization decisions for gnubok that go beyond the
default "the resource creator is the only person who can act on it" model.
It is the canonical reference for compliance reviewers (OWASP ASVS V8, ISO
27001:2022 A.5.1 / A.8.3 / A.8.5, SOC 2 CC6.1) when they encounter an
authorization check that uses `company_id` rather than `user_id`.
---
## Multi-tenant model
gnubok is a multi-tenant SaaS where the unit of business ownership is the
**company** (a row in `public.companies`). Users access companies through
the `company_members` table, which links a user to one or more companies
with a role (`owner` / `admin` / `member` / `viewer`).
The active company is resolved on every request by
`lib/supabase/middleware.ts`. Application code reads it via
`ctx.companyId` (extensions) or `companyId` resolved from the cookie. Row
Level Security policies on every business table use the `user_company_ids()`
DB helper to enforce membership.
The user is **never** the authoritative tenant identifier on its own. Any
business data ownership check that compares `user_id` to the actor's
`user.id` instead of the resource's `company_id` is a bug.
---
## Shared-resource model (default)
Business records inside a company are **shared resources**: any member of
the company can read and write them, subject to their role. This includes:
- Customer invoices and supplier invoices
- Journal entries and bank transactions
- Customers and suppliers
- Receipts and documents
- **Bank connections (Enable Banking PSD2)**
- Mapping rules, booking templates, counterparty templates
- Salary runs and AGI declarations
- Company settings
The role of the actor (owner, admin, member, viewer) restricts what
operations they can perform via `lib/auth/require-write.ts`, but does not
restrict *which records* they can act on. A viewer cannot post any journal
entry; a member can post any journal entry their company owns, regardless
of who originally drafted it.
### Why this is intentional
gnubok's users are small businesses and the bookkeepers / consultants they
share access with. Compliance scenarios that drive this model:
1. **Bookkeeper handover.** A consultant who connected a bank during
onboarding might not be the same person who later configures account
selection. Forcing `user_id` ownership would lock the second person
out of fixing the first person's setup.
2. **Vacation cover.** A second admin must be able to disconnect a bank,
approve a supplier invoice, or send a customer invoice when the
primary user is unreachable.
3. **Audit trail under BFL 7 kap.** Swedish bookkeeping law requires a
continuous audit trail per *company*, not per user. Locking entries
to a single user would interrupt that trail at every personnel
change.
4. **Role-based, not identity-based, separation of duties.** Where SoD
matters (e.g. AGI submission, year-end close, salary approval) we
enforce it through the `role` column on `company_members`, not by
recording which specific user created the underlying record.
### Compensating controls
Although authorization is by `company_id`, the audit trail is by `user_id`:
- `journal_entries.user_id`, `transactions.user_id`, etc. record who
*created* a record. These columns are never used for authorization,
but they are preserved for the audit log and the immutable
`audit_log` table.
- `event_log` rows include `user_id` so every privileged action
(consent grants, invoice sends, period locks, document uploads) is
attributable to a specific user even when authorization is shared.
- The `audit_log` table is immutable (DB trigger `audit_log_immutable`)
and retained for 7 years per BFL.
---
## Specific decisions
### bank_connections — managed at company scope
**Decision.** Any active `company_members` row for a company can manage
any `bank_connection` belonging to that company. This covers `POST /connect`,
`PATCH /accounts`, `POST /sync`, and `DELETE /disconnect` in the
`enable-banking` extension.
**Why.** A bank connection is a company-level resource (it represents the
company's relationship to its bank under PSD2 consent obtained on behalf of
the company, not a personal banking relationship). Restricting management
to the user who initiated the OAuth flow would create a lockout failure
mode that exceeds the cross-tenant access risk of the broader model.
**Compensating audit.** Every state transition on a bank connection emits
a structured event persisted to `event_log` with both `user_id` and
`company_id`:
- `bank_connection.consent_granted` — PSD2 callback completed, account
metadata stored, status `pending_selection`. Emitted from
`app/api/extensions/enable-banking/callback/route.ts`.
- `bank_connection.account_selection_changed` — user chose which accounts
to sync; status may transition `pending_selection → active`. Emitted
from `PATCH /accounts` in `extensions/general/enable-banking/index.ts`.
- `bank_connection.revoked` — user disconnected the bank; PSD2 session is
revoked at Enable Banking; status set to `revoked`. Emitted from
`DELETE /disconnect`.
The `event_log` row carries: `connectionId`, `bankName`, `previousStatus`,
`newStatus`, `accountCount` / `enabledCount` / `totalCount`, `userId`,
`companyId`, `consentExpiresAt`. This is sufficient to attribute every
PSD2 consent decision to a specific user under that company.
**Cross-references.**
- ASVS V8.2.1 — authorization checks at trust boundary
- ASVS V16 — audit logging of security-relevant events
- ISO 27001:2022 A.5.1, A.8.3, A.8.5 — access control and information
access restriction
- SOC 2 CC6.1, CC7.2 — logical access controls and detection of
unauthorized changes
- GDPR Art.30 — records of processing activities (PSD2 consent
decisions)
- BFL 7 kap. — 7-year retention of audit trail
---
## Reviewers' checklist
When reviewing a PR that touches authorization:
1. The check filters by **`company_id`** resolved from the verified
request context (`ctx.companyId` in extensions; `companyId` in API
routes). Never `user.id` as a substitute.
2. If `companyId` is absent, the handler returns `400`. Never falls
back to a different identifier.
3. The actor's company membership is enforced by either RLS
(`user_company_ids()` policy) or an explicit application-side check
against `company_members`. Both is best.
4. Any state change is emitted as a structured event with `userId` and
`companyId` so the audit trail remains attributable.
5. Where role-level restrictions apply, `requireWrite()` /
`requireRole()` from `lib/auth/require-write.ts` enforces them.
Deviations from the shared-resource model (e.g. resources that *should*
be locked to a single user) must be added to this document with the same
"Decision / Why / Compensating audit" structure before merging.
@@ -61,36 +61,67 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(location).toContain('bank_error=invalid_state')
})
it('activates connection and clears oauth_state on success', async () => {
it('writes pending_selection and redirects to picker on success', async () => {
const capturedUpdates: Record<string, unknown>[] = []
let callIndex = 0
mockFrom.mockImplementation(() => {
callIndex++
if (callIndex === 1) {
// Find pending connection by oauth_state
return mockChain({ data: { id: 'conn-1', user_id: 'user-1' }, error: null })
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
}
if (callIndex === 2) {
// Update connection
return mockChain({ data: null, error: null })
}
// Company settings lookup
return mockChain({ data: { onboarding_complete: true }, error: null })
// Update connection — capture the payload, then chain returns the
// updated row via .select().single() for the audit event emission.
const chain: Record<string, unknown> = {}
chain.update = vi.fn((payload: Record<string, unknown>) => {
capturedUpdates.push(payload)
return chain
})
chain.eq = vi.fn().mockReturnValue(chain)
chain.select = vi.fn().mockReturnValue(chain)
chain.single = vi.fn().mockResolvedValue({
data: {
id: 'conn-1',
bank_name: 'TestBank',
company_id: 'company-1',
user_id: 'user-1',
},
error: null,
})
// Back-compat fallthrough for chains that aren't terminated by .single()
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
})
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [],
accounts: [
{ uid: 'acc-1', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
{ uid: 'acc-2', account_id: { iban: 'SE5678' }, name: 'Privatkonto', currency: 'SEK' },
],
access: { valid_until: '2024-12-31T00:00:00Z' },
aspsp: { name: 'TestBank', country: 'SE' },
})
mockGetAccountBalance.mockRejectedValue(new Error('skip balance fetch'))
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
const location = response.headers.get('location') || ''
expect(location).toContain('/settings/banking?')
expect(location).toContain('bank_connected=true')
expect(location).toContain('connection_id=conn-1')
expect(location).toContain('select_accounts=conn-1')
expect(location).not.toContain('bank_connected=true')
// Verify the update payload: status=pending_selection, no last_synced_at,
// and every account defaults to enabled=true so the picker can simply
// mirror current state without back-filling.
expect(capturedUpdates).toHaveLength(1)
const payload = capturedUpdates[0]
expect(payload.status).toBe('pending_selection')
expect(payload).not.toHaveProperty('last_synced_at')
const accountsData = payload.accounts_data as Array<{ uid: string; enabled: boolean }>
expect(accountsData).toHaveLength(2)
expect(accountsData.every(a => a.enabled === true)).toBe(true)
})
it('redirects with error when bank returns error param (no state)', async () => {
@@ -1,7 +1,8 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
import { eventBus } from '@/lib/events/bus'
/**
* GET /api/extensions/enable-banking/callback
@@ -106,7 +107,6 @@ export async function GET(request: Request) {
}
const userId = pendingConnection.user_id
const companyId = pendingConnection.company_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
@@ -125,44 +125,41 @@ export async function GET(request: Request) {
consentExpiresAt,
})
const accountsWithBalances: StoredAccount[] = await Promise.all(
accounts.map(async (account: AccountInfo) => {
try {
const balance = await getAccountBalance(account.uid)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: balance.amount,
}
} catch (balanceError) {
console.error(`Failed to get balance for account ${account.uid}:`, balanceError)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: undefined,
}
}
})
)
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
// currency). Balances are bank account financial data — we don't fetch
// them here. The first sync (after the user enables specific accounts)
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
// user deselects never have their balance pulled.
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
// Default to enabled. The user is presented with a picker
// immediately after this callback to uncheck unwanted accounts
// before any transactions are fetched.
enabled: true,
}))
// Do not set last_synced_at here. The session is created but no transactions
// have been fetched yet; setting it now causes the cron's first-sync 90-day
// backfill path to be skipped if the manual sync triggered by the redirect
// never lands. The first successful sync (manual or cron) will set it.
const { error: updateError } = await supabase
// Stay in 'pending_selection' until the user confirms which accounts to sync.
// The cron and manual sync routes both skip this status, so no transactions
// can be pulled before the user has had a chance to deselect accounts.
// Do not set last_synced_at here either: no transactions have been fetched
// yet, and setting it would cause the cron's first-sync 90-day backfill
// path to be skipped. The first successful sync sets it.
const { data: updatedConnection, error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'active',
accounts_data: accountsWithBalances,
status: 'pending_selection',
accounts_data: accountsMetadata,
consent_expires: consentExpiresAt,
oauth_state: null, // Clear to prevent replay
})
.eq('id', pendingConnection.id)
.select('id, bank_name, company_id, user_id')
.single()
if (updateError) {
console.error('[enable-banking] Failed to update connection after session creation', {
@@ -173,15 +170,33 @@ export async function GET(request: Request) {
throw new Error(`Failed to update connection: ${updateError.message}`)
}
const connectionId = pendingConnection.id
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
// ASVS V16 requires this transition to be logged as a security event; emit
// here so the event_log handler persists it (30-day TTL).
try {
await eventBus.emit({
type: 'bank_connection.consent_granted',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountCount: accounts.length,
consentExpiresAt: consentExpiresAt ?? null,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
// Non-fatal: redirect the user even if the audit event fails. Sentry
// surfaces the error; the underlying DB write (the source of truth for
// the connection state) has already succeeded.
console.error('[enable-banking] Failed to emit consent_granted event', {
connectionId: updatedConnection.id,
error: emitError instanceof Error ? emitError.message : String(emitError),
})
}
const { data: userSettings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('company_id', companyId)
.single()
const redirectTarget = `/settings/banking?bank_connected=true&connection_id=${connectionId}`
const connectionId = updatedConnection.id
const redirectTarget = `/settings/banking?select_accounts=${connectionId}`
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
@@ -143,7 +143,29 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
.toISOString()
.split('T')[0]
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
// Keep the full list for the DB write-back so we don't drop accounts
// the user has opted out of. Sync only the enabled subset (treating
// undefined as enabled for back-compat with older rows).
const allAccounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const accounts = allAccounts.filter(a => a.enabled !== false)
if (accounts.length === 0) {
ctx.log.info('all accounts disabled — skipping sync', {
connectionId: connection.id,
totalAccounts: allAccounts.length,
})
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: 0,
duplicates: 0,
errors: 0,
status: 'synced',
daysUntilExpiry: daysLeft,
})
continue
}
// Detect SIE overlap — skip auto-categorization if the sync range
// overlaps with a completed SIE import to prevent double-booking
@@ -194,11 +216,12 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
}
}
// Successful sync: update connection and clear any previous error state
// Successful sync: update connection and clear any previous error state.
// Write allAccounts (not accounts) so disabled accounts stay in the row.
await supabase
.from('bank_connections')
.update({
accounts_data: accounts,
accounts_data: allAccounts,
last_synced_at: new Date().toISOString(),
...(connection.error_message ? { error_message: null } : {}),
})
@@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { enableBankingExtension } from '../index'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
// Locate the PATCH /accounts handler once — schema doesn't change at runtime.
const accountsRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'PATCH' && r.path === '/accounts'
)
if (!accountsRoute) {
throw new Error('PATCH /accounts route not registered on enable-banking extension')
}
interface SupabaseStub {
authUser: { id: string } | null
connectionRow: {
id: string
status: string
accounts_data: StoredAccount[]
} | null
connectionError?: { message: string } | null
updateError?: { message: string } | null
capturedUpdate?: Record<string, unknown>
}
function buildSupabase(stub: SupabaseStub) {
return {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: stub.authUser }, error: null }),
},
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: stub.connectionRow,
error: stub.connectionError ?? null,
}),
update: vi.fn((payload: Record<string, unknown>) => {
stub.capturedUpdate = payload
return {
eq: vi.fn().mockResolvedValue({ error: stub.updateError ?? null }),
}
}),
})),
}
}
function makeContext(supabase: ReturnType<typeof buildSupabase>): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: supabase as any,
emit: vi.fn().mockResolvedValue(undefined),
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
storage: {} as never,
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as never,
services: {} as never,
}
}
function makeRequest(body: unknown): Request {
return new Request('http://localhost/api/extensions/ext/enable-banking/accounts', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('PATCH /accounts (enable-banking)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when unauthenticated', async () => {
const supabase = buildSupabase({ authUser: null, connectionRow: null })
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(401)
})
it('returns 400 when connection_id missing', async () => {
const supabase = buildSupabase({ authUser: { id: 'user-1' }, connectionRow: null })
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(400)
})
it('returns 400 when enabled_uids is empty', async () => {
const supabase = buildSupabase({ authUser: { id: 'user-1' }, connectionRow: null })
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: [] }),
ctx
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Välj minst ett konto/i)
})
it('returns 400 when enabled_uids contains unknown uid', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: true },
],
},
})
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1', 'acc-bogus'] }),
ctx
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.unknown_uids).toEqual(['acc-bogus'])
})
it('returns 404 when connection not found', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: null,
connectionError: { message: 'not found' },
})
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(404)
})
it('returns 400 when connection is in an invalid status (e.g. expired)', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'expired',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
},
})
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(400)
})
it('flips status to active and writes per-account enabled flags', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true, name: 'Företag' },
{ uid: 'acc-2', currency: 'SEK', enabled: true, name: 'Privat' },
{ uid: 'acc-3', currency: 'SEK', enabled: true, name: 'Spar' },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1', 'acc-3'] }),
ctx
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toMatchObject({ success: true, enabled_count: 2, total_count: 3 })
expect(stub.capturedUpdate).toBeDefined()
expect(stub.capturedUpdate?.status).toBe('active')
const written = stub.capturedUpdate?.accounts_data as StoredAccount[]
expect(written).toHaveLength(3)
expect(written.find(a => a.uid === 'acc-1')?.enabled).toBe(true)
expect(written.find(a => a.uid === 'acc-2')?.enabled).toBe(false)
expect(written.find(a => a.uid === 'acc-3')?.enabled).toBe(true)
// Disabled accounts are kept in the row so the user can re-enable later.
expect(written.find(a => a.uid === 'acc-2')?.name).toBe('Privat')
})
it('allows re-selection on an already-active connection', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: false },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-2'] }),
ctx
)
expect(res.status).toBe(200)
const written = stub.capturedUpdate?.accounts_data as StoredAccount[]
expect(written.find(a => a.uid === 'acc-1')?.enabled).toBe(false)
expect(written.find(a => a.uid === 'acc-2')?.enabled).toBe(true)
})
it('omits status from update payload when connection is already active (state machine)', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: false },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-2'] }),
ctx
)
expect(res.status).toBe(200)
// Status field is NOT present in the update — already-active connections
// don't re-assert the transition, which keeps the state machine explicit.
expect(stub.capturedUpdate).toBeDefined()
expect('status' in (stub.capturedUpdate ?? {})).toBe(false)
})
it('returns 400 when ctx.companyId is absent (no user.id fallback)', async () => {
const supabase = buildSupabase({ authUser: { id: 'user-1' }, connectionRow: null })
const ctx = makeContext(supabase)
// Simulate a missing company context — should not fall back to user.id.
const ctxWithoutCompany = { ...ctx, companyId: undefined as unknown as string }
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctxWithoutCompany
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Company context required/i)
})
it('returns 400 when enabled_uids exceeds the per-connection cap', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [],
},
})
const ctx = makeContext(supabase)
const tooMany = Array.from({ length: 51 }, (_, i) => `acc-${i}`)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: tooMany }),
ctx
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Max 50 konton/i)
})
it('emits bank_connection.account_selection_changed after a successful update', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: true },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(200)
expect(ctx.emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'bank_connection.account_selection_changed',
payload: expect.objectContaining({
connectionId: 'conn-1',
previousStatus: 'pending_selection',
newStatus: 'active',
enabledCount: 1,
totalCount: 2,
userId: 'user-1',
companyId: 'company-1',
}),
})
)
})
})
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { enableBankingExtension } from '../index'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
const syncRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'POST' && r.path === '/sync'
)
if (!syncRoute) {
throw new Error('POST /sync route not registered on enable-banking extension')
}
function makeContext(connectionRow: {
status: string
accounts_data: StoredAccount[]
}): ExtensionContext {
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
},
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { id: 'conn-1', company_id: 'company-1', ...connectionRow },
error: null,
}),
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
})),
}
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: supabase as any,
emit: vi.fn().mockResolvedValue(undefined),
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
storage: {} as never,
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as never,
services: {} as never,
}
}
function makeRequest(): Request {
return new Request('http://localhost/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: 'conn-1' }),
})
}
describe('POST /sync (enable-banking) — account filtering', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 400 when status is pending_selection', async () => {
const ctx = makeContext({
status: 'pending_selection',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
})
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/not active/i)
})
it('returns 400 when every account is disabled', async () => {
const ctx = makeContext({
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: false },
{ uid: 'acc-2', currency: 'SEK', enabled: false },
],
})
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Inga konton är valda/i)
})
})
@@ -0,0 +1,220 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import type { StoredAccount } from '../types'
interface AccountPickerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
connectionId: string
bankName: string
accounts: StoredAccount[]
// True when the connection is still in pending_selection — closing without
// saving is allowed but the user is reminded that no sync runs until they
// confirm.
isInitialSelection: boolean
onSaved: () => void
}
export function AccountPickerDialog({
open,
onOpenChange,
connectionId,
bankName,
accounts,
isInitialSelection,
onSaved,
}: AccountPickerDialogProps) {
const { toast } = useToast()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (open) {
// Start the dialog reflecting the current state. Accounts without an
// explicit enabled flag are treated as enabled (back-compat).
const initial = new Set<string>(
accounts.filter(a => a.enabled !== false).map(a => a.uid)
)
setSelected(initial)
}
}, [open, accounts])
const allSelected = accounts.length > 0 && selected.size === accounts.length
const noneSelected = selected.size === 0
const sortedAccounts = useMemo(
() => [...accounts].sort((a, b) => (a.name || a.iban || '').localeCompare(b.name || b.iban || '')),
[accounts]
)
function toggle(uid: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(uid)) next.delete(uid)
else next.add(uid)
return next
})
}
function selectAll() {
setSelected(new Set(accounts.map(a => a.uid)))
}
function selectNone() {
setSelected(new Set())
}
async function handleSave() {
if (noneSelected) {
toast({
title: 'Välj minst ett konto',
description: 'Avmarkera alla konton och koppla bort banken istället om inga konton ska synkas.',
variant: 'destructive',
})
return
}
setIsSaving(true)
try {
const response = await fetch('/api/extensions/ext/enable-banking/accounts', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connection_id: connectionId,
enabled_uids: Array.from(selected),
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte spara kontoval')
}
toast({
title: 'Kontoval sparat',
description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`,
})
onOpenChange(false)
onSaved()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte spara kontoval',
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Välj konton att synka {bankName}</DialogTitle>
<DialogDescription>
{isInitialSelection
? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från. Inga transaktioner hämtas innan du sparar.'
: 'Justera vilka konton som ska synkas. Konton du avmarkerar slutar synkas från nästa körning; redan importerade transaktioner ligger kvar.'}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{selected.size} av {accounts.length} valda
</span>
<div className="flex gap-2">
<button
type="button"
onClick={selectAll}
disabled={allSelected || isSaving}
className="underline-offset-2 hover:underline disabled:opacity-50 disabled:no-underline"
>
Markera alla
</button>
<span aria-hidden>·</span>
<button
type="button"
onClick={selectNone}
disabled={noneSelected || isSaving}
className="underline-offset-2 hover:underline disabled:opacity-50 disabled:no-underline"
>
Avmarkera alla
</button>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border divide-y divide-border">
{sortedAccounts.map(account => {
const isChecked = selected.has(account.uid)
return (
<label
key={account.uid}
className="flex cursor-pointer items-center gap-3 p-3 hover:bg-muted/50"
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggle(account.uid)}
disabled={isSaving}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{account.name || account.iban || 'Okänt konto'}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground tabular-nums">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
</div>
{account.balance !== undefined && (
<p className="text-sm font-medium tabular-nums shrink-0">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
)}
</label>
)
})}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Avbryt
</Button>
<Button type="button" onClick={handleSave} disabled={isSaving || noneSelected}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar
</>
) : (
'Spara val'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -9,6 +9,7 @@ import {
CreditCard,
AlertTriangle,
RefreshCw,
Settings,
Trash2,
Loader2,
CheckCircle,
@@ -22,6 +23,7 @@ interface BankConnectionStatusProps {
onSync: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
onReconnect?: (bank: { name: string; country: string }) => void
onManageAccounts?: (connectionId: string) => void
isSyncing?: boolean
}
@@ -30,6 +32,7 @@ export function BankConnectionStatus({
onSync,
onDisconnect,
onReconnect,
onManageAccounts,
isSyncing = false,
}: BankConnectionStatusProps) {
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
@@ -86,8 +89,11 @@ export function BankConnectionStatus({
currency: string
balance?: number
balance_updated_at?: string
enabled?: boolean
}>) || []
const enabledCount = accounts.filter((a) => a.enabled !== false).length
const [now] = useState(() => Date.now())
function formatBalanceAge(updatedAt: string): string {
@@ -168,6 +174,16 @@ export function BankConnectionStatus({
)}
</Button>
)}
{onManageAccounts && (
<Button
variant="ghost"
size="sm"
onClick={() => onManageAccounts(connection.id)}
title="Hantera konton"
>
<Settings className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -228,40 +244,55 @@ export function BankConnectionStatus({
{/* Accounts list */}
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Konton</p>
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">Konton</p>
<p className="text-xs text-muted-foreground tabular-nums">
{enabledCount} av {accounts.length} synkas
</p>
</div>
<div className="space-y-2">
{accounts.map((account) => (
<div
key={account.uid}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div>
<p className="text-sm font-medium">
{account.name || account.iban || 'Okänt konto'}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
</div>
{account.balance !== undefined && (
<div className="text-right">
<p className="text-sm font-medium">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
{account.balance_updated_at && (
<p className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
{accounts.map((account) => {
const isDisabled = account.enabled === false
return (
<div
key={account.uid}
className={`flex items-center justify-between p-3 rounded-lg ${isDisabled ? 'bg-muted/20 opacity-60' : 'bg-muted/50'}`}
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{account.name || account.iban || 'Okänt konto'}
</p>
{isDisabled && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1.5 py-0.5">
Synkas ej
</span>
)}
</div>
{account.iban && (
<p className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
</div>
)}
</div>
))}
{account.balance !== undefined && (
<div className="text-right">
<p className="text-sm font-medium tabular-nums">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
{account.balance_updated_at && (
<p className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</p>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
@@ -6,13 +6,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Upload } from 'lucide-react'
import { AlertTriangle, Loader2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus'
import { AccountPickerDialog } from './AccountPickerDialog'
import type { BankConnection } from '@/types'
import type { StoredAccount } from '../types'
/**
* Self-contained banking settings panel for the enable-banking extension.
@@ -34,6 +36,7 @@ export default function BankingSettingsPanel() {
const [isLoading, setIsLoading] = useState(true)
const [showCsvFallback, setShowCsvFallback] = useState(false)
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
const [pickerConnectionId, setPickerConnectionId] = useState<string | null>(null)
// Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts
const PENDING_LOCK_MS = 30 * 1000
@@ -45,6 +48,28 @@ export default function BankingSettingsPanel() {
}
}, [])
// Auto-open the picker when the user lands here from the OAuth callback
// (URL: /settings/banking?select_accounts=<id>). The query param is stripped
// afterwards so a refresh doesn't keep reopening it.
useEffect(() => {
if (isLoading) return
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
const targetId = params.get('select_accounts')
if (!targetId) return
const match = bankConnections.find(c => c.id === targetId)
if (match) {
setPickerConnectionId(targetId)
}
params.delete('select_accounts')
const newQuery = params.toString()
const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}`
window.history.replaceState({}, '', newUrl)
}, [isLoading, bankConnections])
function releaseConnectingLock() {
connectingRef.current = false
setIsConnecting(false)
@@ -252,12 +277,34 @@ export default function BankingSettingsPanel() {
}
const activeConnections = bankConnections.filter((c) => c.status === 'active')
const pendingSelectionConnections = bankConnections.filter((c) => c.status === 'pending_selection')
const actionRequiredConnections = bankConnections.filter((c) => ['expired', 'error'].includes(c.status))
const pickerConnection = pickerConnectionId
? bankConnections.find(c => c.id === pickerConnectionId)
: null
const pickerAccounts = pickerConnection
? ((pickerConnection.accounts_data as StoredAccount[] | null) || [])
: []
return (
<div className="space-y-6">
<DestructiveConfirmDialog {...dialogProps} />
{pickerConnection && (
<AccountPickerDialog
open={!!pickerConnection}
onOpenChange={(open) => {
if (!open) setPickerConnectionId(null)
}}
connectionId={pickerConnection.id}
bankName={pickerConnection.bank_name}
accounts={pickerAccounts}
isInitialSelection={pickerConnection.status === 'pending_selection'}
onSaved={() => fetchConnections()}
/>
)}
{/* Persistent CSV fallback after connection/sync failure */}
{showCsvFallback && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
@@ -271,6 +318,54 @@ export default function BankingSettingsPanel() {
</div>
)}
{/* Pending account selection — new connections waiting for the user to pick accounts */}
{pendingSelectionConnections.length > 0 && (
<Card className="border-warning/30">
<CardHeader>
<CardTitle>Välj konton att synka</CardTitle>
<CardDescription>
Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pendingSelectionConnections.map((connection) => {
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
return (
<div
key={connection.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 shrink-0 text-warning" />
<div>
<p className="font-medium">{connection.bank_name}</p>
<p className="text-sm text-muted-foreground">
{accountsList.length} konton tillgängliga inga transaktioner synkas ännu
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setPickerConnectionId(connection.id)}
>
Välj konton
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnectBank(connection.id)}
>
Avbryt
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Action required — expired/error connections */}
{actionRequiredConnections.length > 0 && (
<Card className="border-warning/30">
@@ -288,6 +383,7 @@ export default function BankingSettingsPanel() {
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onReconnect={handleConnectBank}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
@@ -308,6 +404,7 @@ export default function BankingSettingsPanel() {
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
+238 -10
View File
@@ -9,9 +9,19 @@ import {
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import type { StoredAccount } from './types'
import type { Transaction } from '@/types'
// Per-user limits keep one tenant from spamming any single bank handler.
// Sliding 60s windows — generous enough for legitimate retry, tight enough
// to prevent UUID probing or status-machine abuse.
const RATE_LIMIT_ACCOUNTS = { maxRequests: 20, windowMs: 60_000 }
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
const MAX_ENABLED_UIDS = 50
/**
* Enable Banking (PSD2) extension
*
@@ -87,6 +97,11 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const { aspsp_name, aspsp_country, psu_type: explicitPsuType } = await request.json()
if (!aspsp_name || !aspsp_country) {
@@ -102,7 +117,6 @@ export const enableBankingExtension: Extension = {
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
psuType = explicitPsuType
} else {
const companyId = ctx?.companyId ?? user.id
const { data: company } = await supabase
.from('companies')
.select('entity_type')
@@ -125,7 +139,7 @@ export const enableBankingExtension: Extension = {
const { data: recentPending } = await supabase
.from('bank_connections')
.select('id, created_at')
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
.order('created_at', { ascending: false })
@@ -155,7 +169,7 @@ export const enableBankingExtension: Extension = {
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
}
@@ -176,7 +190,7 @@ export const enableBankingExtension: Extension = {
const { data: connection, error } = await supabase
.from('bank_connections')
.insert({
company_id: ctx?.companyId ?? user.id,
company_id: companyId,
user_id: user.id,
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
@@ -230,6 +244,18 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const rl = await checkRateLimit({
prefix: 'enable-banking:sync',
identifier: user.id,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
const { connection_id, days_back: rawDaysBack = 30 } = await request.json()
const days_back = Math.min(Math.max(1, rawDaysBack), 365)
@@ -237,7 +263,7 @@ export const enableBankingExtension: Extension = {
.from('bank_connections')
.select('*')
.eq('id', connection_id)
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.single()
if (connectionError || !connection) {
@@ -249,7 +275,18 @@ export const enableBankingExtension: Extension = {
}
try {
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
// Keep the full list for write-back; sync only the enabled subset.
// undefined enabled === true for back-compat with rows that predate
// the per-account toggle.
const allAccounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const accounts = allAccounts.filter(a => a.enabled !== false)
if (accounts.length === 0) {
return NextResponse.json(
{ error: 'Inga konton är valda för synkning. Öppna "Hantera konton" för att aktivera minst ett.' },
{ status: 400 }
)
}
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
@@ -259,7 +296,6 @@ export const enableBankingExtension: Extension = {
// Use ctx.services.ingestTransactions when available
const ingestFn = ctx?.services.ingestTransactions
const companyId = ctx?.companyId ?? user.id
// Detect SIE overlap — skip auto-categorization if the sync range
// overlaps with a completed SIE import to prevent double-booking.
@@ -341,7 +377,7 @@ export const enableBankingExtension: Extension = {
await supabase
.from('bank_connections')
.update({
accounts_data: accounts,
accounts_data: allAccounts,
last_synced_at: syncedAt,
})
.eq('id', connection.id)
@@ -387,6 +423,158 @@ export const enableBankingExtension: Extension = {
}
},
},
{
method: 'PATCH',
path: '/accounts',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// company_id must come from the verified extension context, never fall
// back to user.id (which is a different identifier dimension and would
// silently mis-scope queries in multi-tenant deployments).
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const rl = await checkRateLimit({
prefix: 'enable-banking:accounts',
identifier: user.id,
...RATE_LIMIT_ACCOUNTS,
})
if (!rl.ok) return rl.response!
const body = await request.json().catch(() => null)
const connection_id = body?.connection_id
const enabled_uids = body?.enabled_uids
if (typeof connection_id !== 'string' || !connection_id) {
return NextResponse.json({ error: 'connection_id krävs' }, { status: 400 })
}
if (!Array.isArray(enabled_uids) || !enabled_uids.every(u => typeof u === 'string')) {
return NextResponse.json({ error: 'enabled_uids måste vara en lista av strängar' }, { status: 400 })
}
if (enabled_uids.length === 0) {
return NextResponse.json(
{ error: 'Välj minst ett konto, eller koppla bort banken om inga konton ska synkas.' },
{ status: 400 }
)
}
if (enabled_uids.length > MAX_ENABLED_UIDS) {
return NextResponse.json(
{ error: `Max ${MAX_ENABLED_UIDS} konton per anslutning.` },
{ status: 400 }
)
}
const { data: connection, error: connectionError } = await supabase
.from('bank_connections')
.select('id, status, accounts_data, bank_name')
.eq('id', connection_id)
.eq('company_id', companyId)
.single()
if (connectionError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.status !== 'pending_selection' && connection.status !== 'active') {
return NextResponse.json(
{ error: 'Anslutningen kan inte konfigureras i nuvarande status.' },
{ status: 400 }
)
}
const existing = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const knownUids = new Set(existing.map(a => a.uid))
const unknownUids = enabled_uids.filter(uid => !knownUids.has(uid))
if (unknownUids.length > 0) {
return NextResponse.json(
{ error: 'Ett eller flera konton kunde inte hittas.', unknown_uids: unknownUids },
{ status: 400 }
)
}
const enabledSet = new Set(enabled_uids)
const updatedAccounts: StoredAccount[] = existing.map(a => ({
...a,
enabled: enabledSet.has(a.uid),
}))
// State machine: only transition pending_selection → active. Once
// active, the status field is omitted from the update so the same
// endpoint can be reused to change account selection without
// re-asserting a transition that has already happened.
const updatePayload: { accounts_data: StoredAccount[]; status?: 'active' } = {
accounts_data: updatedAccounts,
}
if (connection.status === 'pending_selection') {
updatePayload.status = 'active'
}
const { error: updateError } = await supabase
.from('bank_connections')
.update(updatePayload)
.eq('id', connection.id)
if (updateError) {
log.error('[enable-banking] Failed to update account selection', {
errorMessage: updateError.message,
connectionId: connection.id,
userId: user.id,
companyId,
})
return NextResponse.json({ error: 'Kunde inte spara kontoval' }, { status: 500 })
}
const newStatus = updatePayload.status ?? connection.status
log.info('[enable-banking] Account selection saved', {
connectionId: connection.id,
enabledCount: enabled_uids.length,
totalCount: existing.length,
previousStatus: connection.status,
newStatus,
userId: user.id,
companyId,
})
try {
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'bank_connection.account_selection_changed',
payload: {
connectionId: connection.id,
bankName: (connection as { bank_name?: string | null }).bank_name ?? null,
previousStatus: connection.status,
newStatus,
enabledCount: enabled_uids.length,
totalCount: existing.length,
userId: user.id,
companyId,
},
})
} catch (emitError) {
log.error('[enable-banking] Failed to emit account selection event', {
errorMessage: emitError instanceof Error ? emitError.message : String(emitError),
connectionId: connection.id,
userId: user.id,
companyId,
})
}
return NextResponse.json({
success: true,
enabled_count: enabled_uids.length,
total_count: existing.length,
})
},
},
{
method: 'DELETE',
path: '/disconnect',
@@ -399,6 +587,18 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const rl = await checkRateLimit({
prefix: 'enable-banking:disconnect',
identifier: user.id,
...RATE_LIMIT_DISCONNECT,
})
if (!rl.ok) return rl.response!
const { connection_id } = await request.json()
if (!connection_id) {
@@ -407,9 +607,9 @@ export const enableBankingExtension: Extension = {
const { data: connection, error: findError } = await supabase
.from('bank_connections')
.select('id, session_id, status')
.select('id, session_id, status, bank_name')
.eq('id', connection_id)
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.single()
if (findError || !connection) {
@@ -426,6 +626,8 @@ export const enableBankingExtension: Extension = {
sessionId: connection.session_id,
connectionId: connection_id,
connectionStatus: connection.status,
userId: user.id,
companyId,
})
}
}
@@ -436,9 +638,35 @@ export const enableBankingExtension: Extension = {
.eq('id', connection.id)
if (updateError) {
log.error('[enable-banking] Failed to mark connection revoked', {
errorMessage: updateError.message,
connectionId: connection.id,
userId: user.id,
companyId,
})
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
}
try {
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'bank_connection.revoked',
payload: {
connectionId: connection.id,
bankName: (connection as { bank_name?: string | null }).bank_name ?? null,
userId: user.id,
companyId,
},
})
} catch (emitError) {
log.error('[enable-banking] Failed to emit revoke event', {
errorMessage: emitError instanceof Error ? emitError.message : String(emitError),
connectionId: connection.id,
userId: user.id,
companyId,
})
}
return NextResponse.json({ success: true })
},
},
@@ -7,6 +7,10 @@ export interface StoredAccount {
currency: string
balance?: number
balance_updated_at?: string
// When false, the account is part of the PSD2 consent but the user has
// chosen not to sync transactions from it. Treated as true if missing
// (back-compat with rows that predate the per-account toggle).
enabled?: boolean
}
// Re-export API types from the client
+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 MarchFeb
'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 MarchFeb
'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)
@@ -0,0 +1,31 @@
-- Add 'pending_selection' to bank_connections.status CHECK constraint.
--
-- New PSD2 connections start in 'pending_selection' so the user can pick
-- which accounts to actually sync before any transactions are pulled.
-- Once the user confirms their selection the connection flips to 'active'.
ALTER TABLE public.bank_connections
DROP CONSTRAINT IF EXISTS bank_connections_status_check;
ALTER TABLE public.bank_connections
ADD CONSTRAINT bank_connections_status_check
CHECK (status IN ('pending', 'pending_selection', 'active', 'expired', 'error', 'revoked'));
-- Backfill existing rows: every account gets enabled=true so current
-- connections keep syncing exactly the accounts they were syncing before.
-- Users can later prune accounts via the picker dialog.
UPDATE public.bank_connections
SET accounts_data = (
SELECT jsonb_agg(
CASE
WHEN elem ? 'enabled' THEN elem
ELSE elem || jsonb_build_object('enabled', true)
END
)
FROM jsonb_array_elements(accounts_data) AS elem
)
WHERE accounts_data IS NOT NULL
AND jsonb_typeof(accounts_data) = 'array'
AND jsonb_array_length(accounts_data) > 0;
NOTIFY pgrst, 'reload schema';
+3 -1
View File
@@ -151,7 +151,9 @@ export interface ProcessingHistoryEvent {
}
// Bank connection status
export type BankConnectionStatus = 'pending' | 'active' | 'expired' | 'revoked' | 'error'
// 'pending_selection' = PSD2 consent granted, awaiting user to pick which
// accounts to actually sync. No transactions are pulled in this state.
export type BankConnectionStatus = 'pending' | 'pending_selection' | 'active' | 'expired' | 'revoked' | 'error'
// Currency types
export type Currency = 'SEK' | 'EUR' | 'USD' | 'GBP' | 'NOK' | 'DKK'