Files
accounted/extensions/general/enable-banking/index.ts
T
Jakob WennbergandClaude Opus 4.7 722b22972d feat(enable-banking): per-account ledger mapping for multicurrency setups (#443) — rebase of #487 (#488)
* feat(enable-banking): per-account ledger mapping for multicurrency setups (#443)

Today every bank account routes to BAS 1930. Multicurrency setups (Wise,
SEB foreign-currency sub-accounts) get pooled into a single SEK ledger
account, making year-end FX revaluation a mess.

This change lets each bank account under a PSD2 consent map to its own BAS
account (SEK→1930, EUR→1932, USD→1933, etc.). The mapping engine already
honors IngestOptions.settlementAccount (lib/bookkeeping/mapping-engine.ts:55-57)
so the wiring is small:

- StoredAccount gains an optional ledger_account field (no migration —
  bank_connections.accounts_data is already JSONB).
- syncAccountTransactions passes account.ledger_account through as
  settlementAccount so the bank-side leg routes to the right BAS account.
- PATCH /accounts accepts account_mappings, validates 4-digit BAS format,
  and verifies each ledger_account exists in chart_of_accounts before
  persisting. Selection edits without account_mappings preserve existing
  values for back-compat.
- AccountPickerDialog shows a per-account "Bokför till konto" combobox
  populated from the company's 19xx accounts, with currency-based
  defaults (SEK→1930, EUR→1932, USD→1933, GBP→1934) and a non-blocking
  warning when two enabled accounts route to the same BAS account with
  different currencies.

Reconciliation still scans 1930 only — foreign-currency accounts skip
auto-matching until follow-up PR 4 (filed in the plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(enable-banking): address review feedback on PR #487

Two real bugs flagged in review:

1. AccountPickerDialog row wrapped a Radix <Checkbox> (which renders as its
   own <button role="checkbox">) inside <button onClick={toggle}>. Browsers
   silently flatten nested interactive elements, the Checkbox lost its
   onCheckedChange handler, and the toggle interaction was effectively
   broken. Reverted to <label> + <Checkbox onCheckedChange> with the <Select>
   as a sibling outside the label so clicking it doesn't also toggle.

2. BAS_ACCOUNT_PATTERN was /^[0-9]{4}$/ — accepted 3001 (revenue), 2640 (input
   VAT), or any 4-digit account that happens to exist in the chart. Direct
   API calls would bypass the UI's 19% picker filter and silently misroute
   every bank-side journal-entry leg into the wrong BAS class, corrupting both
   the ledger and momsdeklaration. Tightened to /^19[0-9]{2}$/ (kassa/bank only).

Tests updated to assert non-19xx rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(enable-banking): validate account_mappings UIDs against accounts_data

A typo'd UID in account_mappings was silently dropped — the entry never
landed in the resulting accounts_data while the response was still 200,
leaving the client to believe the mapping was applied. Mirror the existing
enabled_uids guard: reject unknown UIDs with 400 + unknown_uids in the body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(enable-banking): catch syncPromise rejections to prevent process crash

When the 60s timeout wins the Promise.race, the underlying Promise.all
keeps running. A subsequent bank-API rejection has no registered handler,
which surfaces as an unhandledRejection — Node 22 (the self-hosted Docker
runtime) terminates the process by default on those.

The cron self-heals via initial_sync_completed_at IS NULL, so a no-op
catch is the right policy: drop the late rejection, let the cron retry.

Caught by Greptile review on PR #488.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:59:29 +02:00

899 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import {
startAuthorization,
getASPSPs,
deleteSession,
isSandboxMode,
type ASPSP,
} 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
*
* Provides automatic bank transaction sync via PSD2 open banking.
* This is an opt-in extension — uncomment the import in loader.ts to activate.
*
* Required environment variables:
* - ENABLE_BANKING_APP_ID
* - ENABLE_BANKING_PRIVATE_KEY (base64-encoded PEM)
* - ENABLE_BANKING_SANDBOX (optional, for sandbox mode)
*/
export const enableBankingExtension: Extension = {
id: 'enable-banking',
name: 'Enable Banking (PSD2)',
version: '1.0.0',
settingsPanel: {
label: 'Bankintegration (PSD2)',
path: '/settings/banking',
},
apiRoutes: [
{
method: 'GET',
path: '/banks',
handler: async (_request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
try {
// Detect PSU type from company entity_type
let psuType: 'personal' | 'business' = 'business'
if (ctx?.companyId && ctx?.supabase) {
const { data: company } = await ctx.supabase
.from('companies')
.select('entity_type')
.eq('id', ctx.companyId)
.single()
if (company?.entity_type === 'enskild_firma') {
psuType = 'personal'
}
}
const aspsps = await getASPSPs('SE', psuType)
const banks = aspsps.map((aspsp: ASPSP) => ({
name: aspsp.name,
country: aspsp.country,
logo: aspsp.logo,
bic: aspsp.bic,
}))
return NextResponse.json({ banks, psu_type: psuType, sandbox: isSandboxMode() })
} catch (error) {
log.error('Error fetching banks:', error)
return NextResponse.json({
banks: [
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
],
sandbox: isSandboxMode(),
})
}
},
},
{
method: 'POST',
path: '/connect',
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 })
}
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) {
return NextResponse.json(
{ error: 'aspsp_name and aspsp_country are required' },
{ status: 400 }
)
}
try {
// Detect PSU type: explicit override > company entity_type > default 'business'
let psuType: 'personal' | 'business' = 'business'
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
psuType = explicitPsuType
} else {
const { data: company } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.single()
if (company?.entity_type === 'enskild_firma') {
psuType = 'personal'
}
}
log.info('[enable-banking] Starting bank connection', {
user_id: user.id,
bank: aspsp_name,
country: aspsp_country,
psu_type: psuType,
})
// Reject if there's already a recent pending connection for this user+bank
// to prevent double-click race conditions that confuse the bank's consent flow
const { data: recentPending } = await supabase
.from('bank_connections')
.select('id, created_at')
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (recentPending) {
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds — long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
if (pendingAge < STALE_THRESHOLD_MS) {
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
existing_id: recentPending.id,
age_ms: pendingAge,
})
return NextResponse.json(
{ error: 'En anslutning pågår redan. Vänta och försök igen.' },
{ status: 409 }
)
}
// Clean up stale pending connections (older than threshold)
log.info('[enable-banking] Cleaning up stale pending connections', {
stale_id: recentPending.id,
age_ms: pendingAge,
})
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
}
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
// Generate cryptographic state token for CSRF protection
const oauthState = crypto.randomUUID()
const { url, authorization_id } = await startAuthorization(
aspsp_name,
aspsp_country,
redirectUrl,
oauthState,
psuType
)
const { data: connection, error } = await supabase
.from('bank_connections')
.insert({
company_id: companyId,
user_id: user.id,
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
authorization_id,
oauth_state: oauthState,
status: 'pending',
})
.select()
.single()
if (error) {
log.error('[enable-banking] Database error storing connection', {
errorMessage: error.message,
errorCode: error.code,
errorDetails: error.details,
user_id: user.id,
bank: aspsp_name,
})
throw new Error(`Failed to store connection: ${error.message}`)
}
return NextResponse.json({
connection_id: connection.id,
authorization_url: url,
})
} catch (error) {
log.error('[enable-banking] Connect handler error', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
user_id: user.id,
aspsp_name,
aspsp_country,
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
)
}
},
},
{
method: 'POST',
path: '/sync',
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 })
}
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!
// Default 90 days: most callers (Sync Now button, post-activation gap fill)
// want a deep refresh, not a 30-day blip. Cron uses 7-day incrementals separately.
const { connection_id, days_back: rawDaysBack = 90 } = await request.json()
const days_back = Math.min(Math.max(1, rawDaysBack), 365)
const { data: connection, error: connectionError } = await supabase
.from('bank_connections')
.select('*')
.eq('id', connection_id)
.eq('company_id', companyId)
.single()
if (connectionError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.status !== 'active') {
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
}
try {
// 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)
.toISOString()
.split('T')[0]
const syncStartedAt = new Date().toISOString()
// Use ctx.services.ingestTransactions when available
const ingestFn = ctx?.services.ingestTransactions
// Detect SIE overlap — skip auto-categorization if the sync range
// overlaps with a completed SIE import to prevent double-booking.
// Reconciliation still links bank transactions to existing GL lines.
const { data: sieOverlap } = await supabase
.from('sie_imports')
.select('id')
.eq('company_id', companyId)
.eq('status', 'completed')
.gte('fiscal_year_end', fromDate)
.limit(1)
.maybeSingle()
// Check if user is a viewer — viewers get rawInsertOnly (no categorization)
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
const isViewer = membership?.role === 'viewer'
// Use strategy=longest when the caller asks for >= 30 days of history
// (initial sync, manual backfill). Short windows get the implicit
// default since there's no older data to surface.
const syncOptions = {
...(sieOverlap ? { skipAutoCategorization: true } : {}),
...(isViewer ? { rawInsertOnly: true } : {}),
...(days_back >= 30 ? { strategy: 'longest' as const } : {}),
}
if (sieOverlap) {
log.info('SIE import overlap detected — suppressing auto-categorization', {
sieImportId: sieOverlap.id,
fromDate,
toDate,
})
}
const results = await Promise.all(
accounts.map(account => syncAccountTransactions(
supabase,
companyId,
user.id,
connection.id,
account,
fromDate,
toDate,
ingestFn,
syncOptions
))
)
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
// When SIE overlap is detected, run a batch reconciliation sweep.
// The greedy algorithm considers all candidates globally (highest-
// confidence first) and catches matches the inline per-transaction
// pass may have missed due to processing order.
// Skip for viewers — reconciliation updates transactions which viewers cannot do.
if (sieOverlap && totalImported > 0 && !isViewer) {
try {
const reconResult = await runReconciliation(supabase, companyId, user.id, {
dateFrom: fromDate,
dateTo: toDate,
})
if (reconResult.applied > 0) {
log.info('Post-sync batch reconciliation matched additional transactions', {
applied: reconResult.applied,
total: reconResult.matches.length,
})
}
} catch {
// Non-critical — transactions remain uncategorized for manual review
}
}
const syncedAt = new Date().toISOString()
await supabase
.from('bank_connections')
.update({
accounts_data: allAccounts,
last_synced_at: syncedAt,
})
.eq('id', connection.id)
if (totalImported > 0) {
const { data: syncedTransactions } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.eq('bank_connection_id', connection.id)
.gte('created_at', syncStartedAt)
.order('created_at', { ascending: false })
.limit(totalImported)
if (syncedTransactions && syncedTransactions.length > 0) {
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'transaction.synced',
payload: { transactions: syncedTransactions as Transaction[], userId: user.id, companyId },
})
}
}
return NextResponse.json({
imported: totalImported,
duplicates: totalDuplicates,
last_synced_at: syncedAt,
})
} catch (error) {
log.error('[enable-banking] Sync handler error', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
user_id: user.id,
connection_id,
connectionStatus: connection.status,
bankName: connection.bank_name,
})
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
)
}
},
},
{
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
const rawLookback = body?.initial_lookback_days
const account_mappings = body?.account_mappings
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 }
)
}
// account_mappings is optional. When present, it's an array of
// { uid, ledger_account } pairs that route per-account ingest to a
// specific BAS account (e.g. EUR account → 1932 instead of the default 1930).
// Restrict to BAS class 19 (kassa/bank). Accepting e.g. 3001 (revenue)
// or 2640 (input VAT) here would silently misroute every bank-side
// journal-entry leg into a revenue/VAT account, corrupting both the
// ledger and momsdeklaration. The chart-of-accounts existence check
// below is necessary but not sufficient — those accounts likely do
// exist in the chart, but they're the wrong class.
const BAS_ACCOUNT_PATTERN = /^19[0-9]{2}$/
type AccountMapping = { uid: string; ledger_account?: string | null }
let mappings: AccountMapping[] = []
if (account_mappings !== undefined) {
if (!Array.isArray(account_mappings)) {
return NextResponse.json(
{ error: 'account_mappings måste vara en lista' },
{ status: 400 }
)
}
for (const m of account_mappings) {
if (!m || typeof m !== 'object' || typeof m.uid !== 'string') {
return NextResponse.json(
{ error: 'account_mappings: varje post kräver uid (sträng)' },
{ status: 400 }
)
}
if (m.ledger_account != null && (typeof m.ledger_account !== 'string' || !BAS_ACCOUNT_PATTERN.test(m.ledger_account))) {
return NextResponse.json(
{ error: 'account_mappings: ledger_account måste vara ett BAS-konto i klass 19 (1900–1999)' },
{ status: 400 }
)
}
}
mappings = account_mappings as AccountMapping[]
}
// initial_lookback_days only applies on the pending_selection→active transition.
// Default 90 (PSD2 standard); clamp to [30, 365]. Ignored for selection edits.
const initialLookbackDays = (() => {
const n = typeof rawLookback === 'number' && Number.isFinite(rawLookback) ? rawLookback : 90
return Math.min(365, Math.max(30, Math.round(n)))
})()
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 }
)
}
// Mirror the enabled_uids guard for account_mappings — without this,
// a typo'd UID in the mapping list is silently dropped (the entry
// never lands in the resulting accounts_data) while the response is
// still 200, leaving the client to believe the mapping was applied.
const unknownMappingUids = mappings.map(m => m.uid).filter(uid => !knownUids.has(uid))
if (unknownMappingUids.length > 0) {
return NextResponse.json(
{ error: 'account_mappings innehåller okända konto-uid.', unknown_uids: unknownMappingUids },
{ status: 400 }
)
}
// Verify any provided ledger_account values actually exist in the
// company's chart of accounts. Prevents users from typing arbitrary
// numbers via the API and breaking journal entry creation later.
const requestedLedgerAccounts = mappings
.map(m => m.ledger_account)
.filter((a): a is string => typeof a === 'string')
if (requestedLedgerAccounts.length > 0) {
const { data: chartRows } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.in('account_number', requestedLedgerAccounts)
const validAccountNumbers = new Set((chartRows || []).map(r => r.account_number as string))
const invalid = requestedLedgerAccounts.filter(a => !validAccountNumbers.has(a))
if (invalid.length > 0) {
return NextResponse.json(
{
error: 'Ett eller flera bokföringskonton finns inte i kontoplanen.',
invalid_accounts: invalid,
},
{ status: 400 }
)
}
}
const enabledSet = new Set(enabled_uids)
const mappingsByUid = new Map(mappings.map(m => [m.uid, m]))
const updatedAccounts: StoredAccount[] = existing.map(a => {
const mapping = mappingsByUid.get(a.uid)
return {
...a,
enabled: enabledSet.has(a.uid),
// Apply ledger_account from mapping when present. Explicit null clears it.
// Absent mapping leaves the existing ledger_account untouched (back-compat
// with selection-edit calls that don't include account_mappings).
...(mapping
? { ledger_account: mapping.ledger_account ?? undefined }
: {}),
}
})
// 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,
})
}
// Initial backfill on activation. Run inline so the user has data the
// moment they finish account selection — no 24h cron wait. Failures
// here don't fail the PATCH; the cron will retry on its next run
// (gated on initial_sync_completed_at IS NULL).
let initialSyncSummary: {
imported: number
duplicates: number
requested_from: string
returned_min_date: string | null
returned_max_date: string | null
} | null = null
let initialSyncError: string | null = null
if (connection.status === 'pending_selection') {
const accountsToSync = updatedAccounts.filter(a => a.enabled !== false)
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - initialLookbackDays * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
log.info('[enable-banking] Starting inline initial backfill', {
connectionId: connection.id,
accountCount: accountsToSync.length,
lookbackDays: initialLookbackDays,
fromDate,
toDate,
})
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
try {
const ingestFn = ctx?.services.ingestTransactions
const syncPromise = Promise.all(
accountsToSync.map(account => syncAccountTransactions(
supabase,
companyId,
user.id,
connection.id,
account,
fromDate,
toDate,
ingestFn,
{ strategy: 'longest' }
))
)
// If the timeout wins the race, the underlying Promise.all keeps
// running. Without a registered handler, a late rejection from the
// bank API would surface as an unhandledRejection — Node 22 (the
// self-hosted Docker runtime) terminates the process by default on
// those, taking the whole server down. The cron retries the
// backfill via initial_sync_completed_at IS NULL, so a no-op
// catch is the right policy here.
syncPromise.catch(() => {})
const TIMEOUT_MS = 60_000
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error('initial_sync_timeout')), TIMEOUT_MS)
})
const results = await Promise.race([syncPromise, timeoutPromise])
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
// Min/max booking date across all synced accounts
const minDates = results.map(r => r.returnedMinBookingDate).filter((d): d is string => !!d)
const maxDates = results.map(r => r.returnedMaxBookingDate).filter((d): d is string => !!d)
const returnedMin = minDates.length > 0 ? minDates.reduce((a, b) => (a < b ? a : b)) : null
const returnedMax = maxDates.length > 0 ? maxDates.reduce((a, b) => (a > b ? a : b)) : null
const completedAt = new Date().toISOString()
// Don't re-write accounts_data here — the first update already wrote it.
// Including it again races with any concurrent writer (e.g. cron firing in
// the sub-60s window) and would silently overwrite their changes.
const { error: metaUpdateError } = await supabase
.from('bank_connections')
.update({
last_synced_at: completedAt,
initial_sync_completed_at: completedAt,
initial_sync_requested_from: fromDate,
initial_sync_returned_min_date: returnedMin,
initial_sync_returned_max_date: returnedMax,
initial_sync_lookback_days: initialLookbackDays,
})
.eq('id', connection.id)
if (metaUpdateError) {
// The sync itself succeeded (transactions are ingested) but we
// couldn't persist that. Falsely reporting success would tell the
// client "imported N transactions" while the DB still has
// initial_sync_completed_at = NULL, causing the cron to re-run a
// 90-day backfill next morning. Surface this as initial_sync_error
// so the UI shows a "background sync needs retry" warning, and the
// cron's gate (initial_sync_completed_at IS NULL) will self-heal.
initialSyncError = `metadata_update_failed: ${metaUpdateError.message}`
log.error('[enable-banking] Failed to persist initial_sync metadata after backfill', {
connectionId: connection.id,
error: metaUpdateError.message,
userId: user.id,
companyId,
})
} else {
initialSyncSummary = {
imported: totalImported,
duplicates: totalDuplicates,
requested_from: fromDate,
returned_min_date: returnedMin,
returned_max_date: returnedMax,
}
log.info('[enable-banking] Inline initial backfill complete', {
connectionId: connection.id,
...initialSyncSummary,
})
}
} catch (syncError) {
initialSyncError = syncError instanceof Error ? syncError.message : String(syncError)
log.error('[enable-banking] Inline initial backfill failed — cron will retry', {
connectionId: connection.id,
error: initialSyncError,
userId: user.id,
companyId,
})
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle)
}
}
return NextResponse.json({
success: true,
enabled_count: enabled_uids.length,
total_count: existing.length,
...(initialSyncSummary ? { initial_sync: initialSyncSummary } : {}),
...(initialSyncError ? { initial_sync_error: initialSyncError } : {}),
})
},
},
{
method: 'DELETE',
path: '/disconnect',
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 })
}
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) {
return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
}
const { data: connection, error: findError } = await supabase
.from('bank_connections')
.select('id, session_id, status, bank_name')
.eq('id', connection_id)
.eq('company_id', companyId)
.single()
if (findError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
// Revoke PSD2 consent if session exists
if (connection.session_id) {
try {
await deleteSession(connection.session_id)
} catch (error) {
log.error('[enable-banking] Failed to revoke PSD2 session (may be expired)', {
message: error instanceof Error ? error.message : String(error),
sessionId: connection.session_id,
connectionId: connection_id,
connectionStatus: connection.status,
userId: user.id,
companyId,
})
}
}
const { error: updateError } = await supabase
.from('bank_connections')
.update({ status: 'revoked', session_id: null })
.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 })
},
},
],
eventHandlers: [],
}