* 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>
899 lines
36 KiB
TypeScript
899 lines
36 KiB
TypeScript
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: [],
|
||
}
|