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