diff --git a/.compliance/authorization-policy.md b/.compliance/authorization-policy.md new file mode 100644 index 00000000..334de316 --- /dev/null +++ b/.compliance/authorization-policy.md @@ -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. diff --git a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts index 7f8d1d95..ae48e6db 100644 --- a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts @@ -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[] = [] 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 = {} + chain.update = vi.fn((payload: Record) => { + 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 () => { diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index fbff9890..ad984e3a 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -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) { diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index afaf904b..64f62748 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -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 } : {}), }) diff --git a/extensions/general/enable-banking/__tests__/accounts-route.test.ts b/extensions/general/enable-banking/__tests__/accounts-route.test.ts new file mode 100644 index 00000000..a37ae556 --- /dev/null +++ b/extensions/general/enable-banking/__tests__/accounts-route.test.ts @@ -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 +} + +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) => { + stub.capturedUpdate = payload + return { + eq: vi.fn().mockResolvedValue({ error: stub.updateError ?? null }), + } + }), + })), + } +} + +function makeContext(supabase: ReturnType): 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', + }), + }) + ) + }) +}) diff --git a/extensions/general/enable-banking/__tests__/sync-filter.test.ts b/extensions/general/enable-banking/__tests__/sync-filter.test.ts new file mode 100644 index 00000000..791bdf20 --- /dev/null +++ b/extensions/general/enable-banking/__tests__/sync-filter.test.ts @@ -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) + }) +}) diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx new file mode 100644 index 00000000..44c85ea2 --- /dev/null +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -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>(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( + 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 ( + + + + Välj konton att synka — {bankName} + + {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.'} + + + +
+ + {selected.size} av {accounts.length} valda + +
+ + · + +
+
+ +
+ {sortedAccounts.map(account => { + const isChecked = selected.has(account.uid) + return ( + + ) + })} +
+ + + + + +
+
+ ) +} diff --git a/extensions/general/enable-banking/components/BankConnectionStatus.tsx b/extensions/general/enable-banking/components/BankConnectionStatus.tsx index 52e6bef4..d325f291 100644 --- a/extensions/general/enable-banking/components/BankConnectionStatus.tsx +++ b/extensions/general/enable-banking/components/BankConnectionStatus.tsx @@ -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({ )} )} + {onManageAccounts && ( + + )} + + + + ) + })} + + + )} + {/* Action required — expired/error connections */} {actionRequiredConnections.length > 0 && ( @@ -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} /> ))} diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index d050e0eb..63d64fd7 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -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 }) }, }, diff --git a/extensions/general/enable-banking/types.ts b/extensions/general/enable-banking/types.ts index f26e941f..c3b5e855 100644 --- a/extensions/general/enable-banking/types.ts +++ b/extensions/general/enable-banking/types.ts @@ -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 diff --git a/lib/auth/rate-limit-http.ts b/lib/auth/rate-limit-http.ts new file mode 100644 index 00000000..e85fda41 --- /dev/null +++ b/lib/auth/rate-limit-http.ts @@ -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() + +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 { + 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 } +} diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts index b7cfb834..a62a2c1c 100644 --- a/lib/events/handlers/event-log-handler.ts +++ b/lib/events/handlers/event-log-handler.ts @@ -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 | 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 diff --git a/lib/events/types.ts b/lib/events/types.ts index f94e8d11..c85c6838 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -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 } } diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 4b2a4a0a..c9710290 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -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/) }) diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 7a2139cd..d13a193f 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -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 diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index f3a40edf..fb3b4560 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -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) + }) +}) diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index d8cef667..12a90a1e 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -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 = { // 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) diff --git a/supabase/migrations/20260511120000_bank_connections_pending_selection.sql b/supabase/migrations/20260511120000_bank_connections_pending_selection.sql new file mode 100644 index 00000000..91714d63 --- /dev/null +++ b/supabase/migrations/20260511120000_bank_connections_pending_selection.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index 2384f0cb..9318f4fe 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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'