Files
accounted/extensions/general/enable-banking/index.ts
T
MattssonandClaude Fable 5.1 d670fe6663 feat(invoices): named payee accounts and per-invoice choice of bank account (#2233)
* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account

Enable Banking has no top-level `bban` key on AccountIdentification: a
Swedish BBAN (clearing + account number) arrives as `other.identification`
with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed
`bban?: string` and read `.bban`, so the value was always undefined: no
connected account ever carried its clearing + account number, and domestic
counterparty accounts on transactions were dropped.

Type the identifiers per the OpenAPI spec, add extractBban() and
pickAccountIdentifier(), read counterparty identifiers through the scheme
list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on
StoredAccount from the OAuth callback. The external_id dedup scope stays
IBAN-then-uid and is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* feat(invoices): named payee accounts on cash_accounts with a default per currency

A company had exactly one set of payment instructions per invoice currency
(company_settings.invoice_payment_accounts), picked by currency alone. A
second SEK bank account, or a second bankgiro number, had nowhere to live.

cash_accounts is already the per-company bank-account entity. Migration
20260903150000 adds the payee fields (bankgiro, plusgiro, clearing +
account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a
small invoice_payee_defaults table (one default account per currency; one
account may be the default for several currencies, a SEK account with an
IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites
the legacy map and the SEK bank columns from the default accounts. Every
existing reader (PDF, email, reminders, v1, MCP) keeps working; the three
writers that only touched legacy columns (PUT /api/settings, v1 settings,
MCP update_company_settings) now write through to the default account, so
what an agent sets is what the PDF prints. Peppol PaymentMeans is built
from the resolver instead of the raw legacy column. bg_pg is dropped
(never read or written; NULL on every prod and staging row).

Backfill lands only on existing cash accounts (primary, IBAN match, or the
only enabled account in the currency). Entries with no target stay in the
map as the resolver fallback and get an attach action in settings.

New: POST /api/cash-accounts (manual bank account on the next free 19xx),
PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT
/api/cash-accounts/payee-defaults. Settings page rewritten as an account
list with per-currency defaults. Behandlingshistorik and the full archive
cover the new table and columns.

Verified on staging: migration applied (11 defaults landed), mirror
trigger observed rewriting company_settings from a payee edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* feat(invoices): choose which bank account an invoice is paid to, frozen at issue

Migration 20260903160000 adds invoices.payment_cash_account_id (FK to
cash_accounts, SET NULL) and invoices.payment_details, the payee fields
frozen when the account is chosen and refreshed at issue.

Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount /
assertInvoicePaymentAccountForRender take an optional override, and
hasRequiredInvoicePaymentAccount reads it from the invoice row, so every
surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol,
recurring, staged MCP send) prints the frozen payee when one exists and the
company default per currency otherwise. Invoices that never chose an
account behave exactly as before.

Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send,
recurring, MCP send and mark-sent) refresh the snapshot from the account as
it is at issue; a chosen account that is disabled, un-flagged or unusable
for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID.

Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice
accept payment_cash_account_id and validate it against the company's payee
accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the
original's payee; copies carry the choice; preview-pdf renders the chosen
account. The editor shows "Betalas till" under the currency when the
company has two or more usable payee accounts for that currency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* feat(invoices): book manual payments on the invoice's chosen bank account

Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the
booking dialog's proposed lines debited 1930 regardless of which bank
account the invoice asked to be paid to. They now resolve the chosen
payee account's ledger account (resolveInvoiceSettlementAccount) and fall
back to 1930 only when no account was chosen or the row is gone.

Bank-transaction matching keeps debiting the account the money landed on
and does not filter by the chosen account; between equal-confidence
candidates it prefers the invoice that asked to be paid to the landing
account. Scores are untouched, so nothing new auto-matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* chore(invoices): keep the payload-size and phantom-column ceilings after the payee work

Shorten the new gnubok_create_invoice argument description (tools/list
payload was 29 bytes over the 60 kB budget), inline the cash-account payee
UPDATE/INSERT payloads and the settings select strings as literals so the
phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE
instead of a hand-rolled copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK)

Review findings from CodeRabbit, Superagent, the Swedish accounting review
and three skeptic passes, resolved in one batch:

Schema (both migrations are unshipped and edited in place):
- cash_accounts.payee_iban: the printed IBAN is its own column. iban stays
  the bank identity written by every sync and used to re-pair on reconnect,
  so a sync can no longer rewrite an invoice instruction or resurrect a
  cleared IBAN. The backfill copies each currency entry verbatim onto the
  target account (IBAN match first, then primary), so every invoice keeps
  printing exactly what it printed before; the bank IBAN is never pushed
  onto invoices that did not carry one.
- Payee columns are owner/admin-only at the database (BEFORE trigger,
  service role exempt): cash_accounts is member-writable for bank sync, and
  the SECURITY DEFINER mirror would otherwise have let a member rewrite
  where customers pay.
- Revoking an account as payee or disabling it drops its defaults; deleting
  a default drops that currency from the map and clears the legacy SEK
  columns (an admin saying "nothing to print" must not keep printing a
  closed account). The mirror leaves the legacy SEK columns alone when the
  map has no SEK entry, so legacy-only companies are never wiped by a
  mirror run for another currency.
- Audit and mirror triggers fire on the same column set; anon and
  authenticated can no longer execute the trigger-only definer functions.
- invoices.payment_cash_account_id is a composite same-company FK with
  SET NULL scoped to the account column.

Code:
- Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now
  also requires enabled, payee-flagged and usable for the currency),
  resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which
  also refuses disabled rows and logs every fallback to 1930).
- createManualBankAccount excludes every ledger slot any row already holds
  (findFreeLedgerAccount treats a manual holder as free; this path inserts).
- The legacy settings writers (PUT /api/settings, v1, MCP) write through to
  the account BEFORE updating company_settings and fail the request on
  error; the account is written before it is adopted as default so the
  mirror never sees an empty payee.
- snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write
  blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid
  projections carry the payee columns; v1 create validates the payee
  before the dry-run return and echoes it in the preview.
- pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and
  non-account schemes (card PANs) are never persisted.
- Editor shows the payee select for a single usable account with no
  default; the booking dialog waits for cash accounts before proposing
  lines; a failed default write no longer hides a created account.
- Behandlingshistorik names the account on created/deleted defaults.
- Regenerated skills/accounted-api; MCP argument description trimmed under
  the tools/list payload ceiling.

Declined: clearing legacy columns via a forward migration (the mirror now
does it on delete); Swedish review's "show the debit account in the
mark-paid UI" (the booking dialog already proposes and lets the user edit
the debit line); manual ledger collision (UNIQUE exists, and the create
path now rejects it with a clear error); Peppol aligning to the PDF value
for companies whose legacy column had drifted from the map (the PDF is the
customer-facing document; both now agree).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger

trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves
record fields per expression, so the combined condition failed with
"record new has no field invoice_payee" whenever a default row changed,
which took down every pg-real case on the payee tables. The revoke/disable
check now sits inside its own TG_TABLE_NAME branch. The MCP settings
executor test mocks the payee write-through like the settings route test
already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload

Cycle 3 of /resolve-pr on #2233.

Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's
invoice_payee_defaults rows whenever cash_accounts.enabled flipped to
false, and enabled is member-writable (the bank picker's "Synkas ej"), so
a member could undo an admin's payee decision. The trigger now drops
defaults only on the admin-only invoice_payee true -> false revoke; the
mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out
of the pick lists and the send gate already refuses an invoice that chose
one. Applied to staging as the same function + trigger definition and
probed inside a rolled-back block: disable keeps the default and the
mirrored bankgiro, revoke clears both.

pg-real: the admin-guard test ran three expectations inside one
withUserContext transaction; the first raise aborted it and the next
statement failed with "current transaction is aborted". One transaction
per expectation now, and the member case also flips enabled to prove the
column stays member-level.

Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa
and the 1911-1919 tills. A customer pays to a giro or bank account, so
isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH
route now require BAS 1920-1999; tests cover 1910 and 1919.

Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014
tokens after main merged #2166 and #2163 alongside this branch. The
ceiling is not bumped and no read on this surface is a demotion
candidate, so gnubok_create_invoice drops payment_cash_account_id;
agent-created invoices print the per-currency default and v1 REST plus
the editor keep the field. Recorded in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration

origin/main merged 20260903160000_kpi_monthly_include_reversed_originals
while this branch held the same version; identical versions abort the
Supabase apply. Staging's schema_migrations row was moved to the new
version with the file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet

Cycle 4 of /resolve-pr on #2233, on Emil's go.

Swedish review: the 1920-1999 payee rule lived only in the routes. The
cash_accounts_payee_admin_only trigger now also refuses invoice_payee on
any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes
it, and the backfill only targets giro/bank rows, so a company whose
single enabled cash_accounts row is a Stripe clearing account keeps its
legacy bankgiro in company_settings instead of landing it on 1686. pg
test covers insert and update on 1686 and 1910; the function was applied
to staging and probed.

Typecheck ratchet: main is red from two merges that landed with failing
Checks, and every branch that syncs it inherits the errors.
  - #2242 added POST(req) calls to the fiscal-periods route test without
    the route params argument withRouteContext handlers take (25 errors
    in the file, baseline 23). All 25 calls now pass
    createMockRouteParams({}).
  - #2247 made SyncResult.requestedFromDate and historyNarrowed required;
    the 13 mockedSync results in the enable-banking accounts-route test
    lacked them. They now carry a fixed date and historyNarrowed: false.
Both files' tests pass unchanged in behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion

origin/main merged 20260903183000_party_promotion while this branch held
the same version. Staging's schema_migrations row must follow (pending:
the Supabase MCP was disconnected at the time of this commit).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:06:45 +02:00

1916 lines
85 KiB
TypeScript

import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import {
startAuthorization,
getASPSPs,
getPreferredAuthMethodDetails,
deleteSession,
isSandboxMode,
SessionExpiredError,
AspspUnavailableError,
REAUTH_REQUIRED_MESSAGE,
SYNC_FAILED_MESSAGE,
BANK_UNAVAILABLE_MESSAGE,
type ASPSP,
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
import { triggerConnectionSync } from './lib/trigger-sync'
import { findReusableSessions, countLiveSiblings } from './lib/session-sharing'
import {
runUnattendedReconciliationSweep,
toSweepSummary,
} from '@/lib/reconciliation/unattended-sweep'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
} from '@/lib/reconciliation/bank-reconciliation'
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
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 RATE_LIMIT_ATTACH = { 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)
*
* Optional:
* - ENABLE_BANKING_API_URL (default https://api.enablebanking.com; api.tilisy.com = sandbox)
* - ENABLE_BANKING_PSU_TYPE (default business)
*/
export const enableBankingExtension: Extension = {
id: 'enable-banking',
name: 'Enable Banking (PSD2)',
version: '1.0.0',
settingsPanel: {
label: 'Bankintegration (PSD2)',
path: '/settings/banking',
},
// Registry-resolved services for core callers (core cannot import
// @/extensions). Contract: lib/bank-sync/trigger-sync-contract.ts.
services: {
// Agent-triggered sync behind POST /api/v1/.../bank-connections/{id}/sync.
triggerConnectionSync,
},
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(),
})
}
},
},
{
// Live PSD2 sessions the user already holds in their OTHER companies that
// still have unclaimed accounts. Drives the "reuse this connection" offer:
// several ASPSPs allow one active AIS session per PSU, so authorizing the
// same bank again for a second company kills the first company's feed.
// An empty list is the normal case and renders no offer at all.
method: 'GET',
path: '/reusable-sessions',
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 })
}
try {
const sessions = await findReusableSessions(supabase, user.id, ctx.companyId)
// Never ship session_id to the browser: it is the bearer of the PSD2
// consent. The client only needs to name the offer and post back the
// source connection id, which is re-validated server-side on attach.
return NextResponse.json({
sessions: sessions.map(s => ({
connection_id: s.connectionId,
company_id: s.companyId,
company_name: s.companyName,
bank_name: s.bankName,
consent_expires: s.consentExpires,
available_account_count: s.availableAccounts.length,
})),
})
} catch (error) {
log.error('[enable-banking] Failed to list reusable sessions', error)
return NextResponse.json({ sessions: [] })
}
},
},
{
// Attach the ACTIVE company to a session authorized for another of the
// user's companies. No BankID, no new /auth call, nothing revoked: the
// new row shares session_id + consent_expires and carries only the
// accounts no company has claimed. Lands in 'pending_selection' so the
// existing AccountPickerDialog does the ledger mapping, IBAN-aware.
method: 'POST',
path: '/attach',
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 blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync)
if (blocked) return blocked
const rl = await checkRateLimit({
prefix: 'enable-banking:attach',
identifier: user.id,
...RATE_LIMIT_ATTACH,
})
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 })
}
try {
// Re-derive the offer server-side rather than trusting the posted id.
// findReusableSessions re-checks ownership (same user), that the
// source is a DIFFERENT company, that its session is active with a
// live consent, and which accounts are genuinely unclaimed.
const sessions = await findReusableSessions(supabase, user.id, companyId)
const source = sessions.find(s => s.connectionId === connection_id)
if (!source) {
return NextResponse.json(
{ error: 'No reusable session available for this connection' },
{ status: 404 }
)
}
// A company already syncing this bank must go through reconnect, not
// attach: a second live row for the same provider would sync the same
// accounts twice into one set of books.
const { data: existingForCompany } = await supabase
.from('bank_connections')
.select('id')
.eq('company_id', companyId)
.eq('provider', source.provider)
.in('status', ['active', 'pending_selection'])
.limit(1)
.maybeSingle()
if (existingForCompany) {
return NextResponse.json(
{ error: 'This company is already connected to that bank' },
{ status: 409 }
)
}
const { data: created, error: insertError } = await supabase
.from('bank_connections')
.insert({
user_id: user.id,
company_id: companyId,
provider: source.provider,
bank_name: source.bankName,
session_id: source.sessionId,
psu_type: source.psuType,
consent_expires: source.consentExpires,
accounts_data: source.availableAccounts,
status: 'pending_selection',
})
.select('id')
.single()
if (insertError || !created) {
log.error('[enable-banking] Failed to attach shared session', {
message: insertError?.message,
sourceConnectionId: source.connectionId,
companyId,
})
return NextResponse.json({ error: 'Failed to reuse connection' }, { status: 500 })
}
log.info('[enable-banking] Attached company to an existing PSD2 session', {
connectionId: created.id,
sourceConnectionId: source.connectionId,
companyId,
bankName: source.bankName,
accountCount: source.availableAccounts.length,
})
// This company gains access to bank data, so it is a consent grant
// from an audit standpoint even though no new consent was signed
// (ASVS V16 / GDPR Art.30), same event the callback emits.
try {
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'bank_connection.consent_granted',
payload: {
connectionId: created.id,
bankName: source.bankName ?? null,
accountCount: source.availableAccounts.length,
consentExpiresAt: source.consentExpires ?? null,
userId: user.id,
companyId,
},
})
} catch (emitError) {
log.error('[enable-banking] Failed to emit consent_granted on attach', emitError)
}
return NextResponse.json({
connection_id: created.id,
account_count: source.availableAccounts.length,
})
} catch (error) {
log.error('[enable-banking] Attach failed', error)
return NextResponse.json({ error: 'Failed to reuse connection' }, { status: 500 })
}
},
},
{
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 blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync)
if (blocked) return blocked
const { aspsp_name, aspsp_country, psu_type: explicitPsuType, connection_id: reconnectId, force_new: forceNew } = await request.json()
// Reconnect mode: re-authorize an EXISTING connection in place (no
// disconnect required). The aspsp identity falls back to the stored row
// when the client omits it, so a closed/expired session can be renewed
// with one click. A fresh connect still needs the bank name + country.
const isReconnect = !!reconnectId
if (!isReconnect && (!aspsp_name || !aspsp_country)) {
return NextResponse.json(
{ error: 'aspsp_name and aspsp_country are required' },
{ status: 400 }
)
}
try {
// For reconnect, load the existing connection up front (company-scoped)
// so we can revoke its dead session and reuse its bank identity.
let existing:
| { id: string; bank_name: string; provider: string; session_id: string | null; psu_type: string | null }
| null = null
if (isReconnect) {
const { data, error: findErr } = await supabase
.from('bank_connections')
.select('id, bank_name, provider, session_id, psu_type')
.eq('id', reconnectId)
.eq('company_id', companyId)
.single()
if (findErr || !data) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
existing = data
}
// Resolve the aspsp identity. For a reconnect the bank is already
// known, so derive it authoritatively from the stored row and IGNORE
// any client-supplied aspsp_name/aspsp_country: the client
// (BankSyncNowButton) derives the country by string-splitting the
// provider slug, and trusting that back is needless attack surface
// (compliance: ASVS V8.2.1/V4.5). A fresh connect has no stored row,
// so it uses the client values (already required+validated above).
// The provider slug ends with the country code, e.g. "nordea-se".
const resolvedAspspName = isReconnect ? existing?.bank_name : aspsp_name
const resolvedAspspCountry = isReconnect
? existing?.provider?.split('-').pop()?.toUpperCase() || 'SE'
: aspsp_country
if (!resolvedAspspName) {
return NextResponse.json(
{ error: 'aspsp_name and aspsp_country are required' },
{ status: 400 }
)
}
// Detect PSU type: explicit override > stored type (reconnect) >
// company entity_type > default 'business'. Reusing the stored type on
// reconnect is the key fix: re-deriving from entity_type would flip a
// working 'personal' connection (e.g. an AB owner who signs with a
// personal Mobile BankID) back to 'business' on every consent renewal,
// failing at the bank's signing step. The client can still pass an
// explicit psu_type to switch account type in place.
let psuType: 'personal' | 'business' = 'business'
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
psuType = explicitPsuType
} else if (isReconnect && (existing?.psu_type === 'personal' || existing?.psu_type === 'business')) {
psuType = existing.psu_type
} else {
const { data: company } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.single()
if (company?.entity_type === 'enskild_firma') {
psuType = 'personal'
}
}
// Resolve the bank's preferred auth method. Handelsbanken (and some
// other Swedish banks) expose Mobile BankID only as a hidden DECOUPLED
// method; without pinning it, Enable Banking defaults to the REDIRECT
// method, which for Handelsbanken *corporate* PSUs cannot complete
// with Mobile BankID: the user approves in the app and then hits an
// error. Only hidden methods applicable to this psu_type are pinned;
// banks whose decoupled method is visible (e.g. Lunar) get undefined
// so their own working default flow runs untouched.
const preferredMethod = await getPreferredAuthMethodDetails(
resolvedAspspName,
resolvedAspspCountry,
psuType
)
const authMethod = preferredMethod?.name
log.info('[enable-banking] Starting bank connection', {
user_id: user.id,
bank: resolvedAspspName,
country: resolvedAspspCountry,
psu_type: psuType,
auth_method: authMethod ?? '(aspsp default)',
// Chosen method's metadata, so prod logs can verify per-bank pinning
// behavior after deploy (hidden-only + psu_types selection). A
// pinned method with no psu_types (the documented Handelsbanken
// shape) applies to all PSU types and logs '(all)': the
// '(aspsp default)' sentinel is reserved for the unpinned case,
// where it would otherwise contradict auth_method on the same line.
auth_method_approach: preferredMethod?.approach ?? '(aspsp default)',
auth_method_hidden: preferredMethod?.hidden_method ?? '(aspsp default)',
auth_method_psu_types: preferredMethod
? (preferredMethod.psu_types ?? '(all)')
: '(aspsp default)',
reconnect: isReconnect,
})
// 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. Skipped for reconnect: that deliberately re-authorizes a known row.
if (!isReconnect) {
const { data: recentPending } = await supabase
.from('bank_connections')
.select('id, created_at')
.eq('company_id', companyId)
.eq('bank_name', resolvedAspspName)
.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 }
)
}
}
// Sweep failed attempts that never became a live connection:
// stale 'pending' rows (abandoned redirects past the threshold)
// and 'error' rows left by earlier denied/failed connects.
// DELETE instead of marking 'error': a parked 'error' row renders
// forever as an "Åtgärd krävs" card, so a failed attempt followed
// by a successful retry showed up as two connections to the same
// bank. The session_id/accounts_data guards protect established
// connections (anything that ever completed the callback has
// accounts_data); never-activated rows have no dependents, and
// the transactions/cash_accounts FKs are ON DELETE SET NULL.
const { data: sweptRows } = await supabase
.from('bank_connections')
.delete()
.eq('company_id', companyId)
.eq('bank_name', resolvedAspspName)
.in('status', ['pending', 'error'])
.is('session_id', null)
.is('accounts_data', null)
.select('id')
if (sweptRows?.length) {
log.info('[enable-banking] Swept never-activated connection attempts', {
count: sweptRows.length,
bank: resolvedAspspName,
})
}
// A fresh connect while a DEAD-BUT-ESTABLISHED connection to the
// same bank exists (expired/error/pending_selection) is almost
// always a renewal that should go through the reconnect path: a
// second row duplicates the connection and used to strand the old
// one in "Åtgärd krävs" forever. 409 with the existing id lets
// the client offer "förnya i stället". An ACTIVE row never
// triggers the guard: two legitimate logins at the same bank
// (disjoint account sets, e.g. privat + företag) must remain
// creatable through the UI, and the callback's supersede leaves
// non-overlapping account sets alone. force_new stays as the
// deliberate escape hatch. Runs AFTER the sweep so a
// never-activated zombie cannot block a legitimate fresh connect.
if (forceNew !== true) {
const { data: establishedRow } = await supabase
.from('bank_connections')
.select('id, status')
.eq('company_id', companyId)
.eq('bank_name', resolvedAspspName)
.in('status', ['expired', 'error', 'pending_selection'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (establishedRow) {
log.info('[enable-banking] Rejecting fresh connect: dead connection needs renewal', {
existing_id: establishedRow.id,
existing_status: establishedRow.status,
bank: resolvedAspspName,
})
return NextResponse.json(
{
error: `Du har redan en koppling till ${resolvedAspspName} som behöver förnyas. Använd Förnya samtycke på kopplingen i stället.`,
code: 'EXISTING_CONNECTION',
existing_connection_id: establishedRow.id,
},
{ status: 409 }
)
}
}
}
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
// Generate cryptographic state token for CSRF protection
const oauthState = crypto.randomUUID()
if (isReconnect && existing) {
// Persist the CSRF state to the existing row BEFORE asking the bank
// to start an authorization. The OAuth callback locates this row only
// by oauth_state, so writing it first guarantees that once the bank
// holds a session bound to this state a matching row already exists.
// If startAuthorization ran first and this UPDATE then failed, the
// bank session would be orphaned with no row to complete it.
//
// Reuse the SAME row: the callback drives it back to
// pending_selection → active, so existing transactions and the
// cash_accounts mirror stay linked. Deliberately keep status
// 'expired' (NOT 'pending') during the round-trip: this row's
// created_at is old and the cron deletes stale 'pending' rows after
// 1h: a reconnect must not be eligible for that. Staying 'expired'
// also keeps it visible in "Åtgärd krävs" so an abandoned reconnect
// is recoverable. The callback's oauth_state lookup accepts 'expired'.
const { error: stateError } = await supabase
.from('bank_connections')
.update({
oauth_state: oauthState,
status: 'expired',
// session_id is deliberately KEPT here. The callback needs the
// session being replaced to carry the renewed consent across to
// sibling companies sharing it (lib/session-sharing.ts); nulling
// it made the renewal invisible and left the siblings pointing
// at a session the bank had just superseded. 'expired' already
// marks the row dead, and the probe pass skips expired rows.
error_message: null,
psu_type: psuType,
})
.eq('id', existing.id)
.eq('company_id', companyId)
if (stateError) {
log.error('[enable-banking] Database error staging reconnect state', {
errorMessage: stateError.message,
errorCode: stateError.code,
connection_id: existing.id,
user_id: user.id,
})
throw new Error(`Failed to update connection: ${stateError.message}`)
}
// Best-effort revoke the dead consent at Enable Banking. A
// closed/expired session is often already gone, so a failure here is
// expected and non-fatal: the new authorization supersedes it.
// Logged at WARN so a systematic revoke failure is visible to
// monitoring (compliance: ASVS V16 / ISO 27001 A.8.15).
// Never revoke a session other companies still hold. On a shared
// consent this revoke would kill their feeds instantly, before the
// replacement session exists, and permanently if the user abandons
// the bank flow. The callback moves the siblings onto the new
// session once it lands; the superseded one lapses on its own.
let oldSessionShared = false
if (existing.session_id) {
const { createServiceClient } = await import('@/lib/supabase/server')
const serviceSupabase = await createServiceClient()
oldSessionShared =
(await countLiveSiblings(serviceSupabase, existing.session_id, existing.id)) > 0
if (oldSessionShared) {
log.info('[enable-banking] Old session shared with other companies: not revoking', {
connection_id: existing.id,
})
}
}
if (existing.session_id && !oldSessionShared) {
try {
await deleteSession(existing.session_id)
} catch (revokeError) {
log.warn('[enable-banking] Old session revoke skipped (likely already expired)', {
message: revokeError instanceof Error ? revokeError.message : String(revokeError),
connection_id: existing.id,
})
}
}
const { url, authorization_id } = await startAuthorization(
resolvedAspspName,
resolvedAspspCountry,
redirectUrl,
oauthState,
psuType,
authMethod,
companyId
)
// Record the bank's authorization_id for audit/traceability. The
// callback matches on oauth_state alone (already persisted above), so
// a failure here cannot orphan the flow: log and continue.
const { error: authIdError } = await supabase
.from('bank_connections')
.update({ authorization_id })
.eq('id', existing.id)
.eq('company_id', companyId)
if (authIdError) {
log.warn('[enable-banking] Could not persist authorization_id on reconnect (non-fatal)', {
errorMessage: authIdError.message,
connection_id: existing.id,
})
}
return NextResponse.json({
connection_id: existing.id,
authorization_url: url,
})
}
// Fresh connect: create the bank authorization, then persist the new
// row carrying its oauth_state so the callback can find it.
const { url, authorization_id } = await startAuthorization(
resolvedAspspName,
resolvedAspspCountry,
redirectUrl,
oauthState,
psuType,
authMethod,
companyId
)
const { data: connection, error } = await supabase
.from('bank_connections')
.insert({
company_id: companyId,
user_id: user.id,
provider: `${resolvedAspspName.toLowerCase().replace(/\s+/g, '-')}-${resolvedAspspCountry.toLowerCase()}`,
bank_name: resolvedAspspName,
authorization_id,
oauth_state: oauthState,
status: 'pending',
psu_type: psuType,
})
.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: resolvedAspspName,
})
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 blocked = await requireCapability(supabase, companyId, CAPABILITY.bank_sync)
if (blocked) return blocked
const rl = await checkRateLimit({
prefix: 'enable-banking:sync',
identifier: user.id,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
// Default 120 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.
// Bank may still cap at ~90 days per PSD2 without fresh SCA: asking for more
// is harmless and surfaces whatever the ASPSP is willing to return.
const { connection_id, days_back: rawDaysBack = 120 } = 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 })
}
// 'error' is retryable: a transient upstream failure (e.g. ASPSP_ERROR)
// parks the connection in 'error' while the PSD2 session is still
// alive, so the UI's "Försök igen" must be allowed through; a
// successful sync below restores 'active'. 'expired' stays rejected:
// a dead consent needs re-authorization via /connect, not a retry.
if (connection.status !== 'active' && connection.status !== 'error') {
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. One scoped run per
// enabled cash account (issue #1298): the pooled run could persist a
// cross-account journal_entry_id.
// Skip for viewers: reconciliation updates transactions which viewers cannot do.
if (sieOverlap && totalImported > 0 && !isViewer) {
try {
const reconResult = await runUnattendedReconciliationSweep(
supabase,
companyId,
user.id,
{ dateFrom: fromDate, dateTo: toDate },
)
// Stamp the outcome so the UI can render "Vi matchade X av Y" and
// the review surface knows there is something to granska.
await supabase
.from('bank_connections')
.update({
last_sie_sweep: toSweepSummary(reconResult, {
dateFrom: fromDate,
dateTo: toDate,
}),
})
.eq('id', connection.id)
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
log.info('Post-sync batch reconciliation matched additional transactions', {
applied: reconResult.applied,
skippedBelowThreshold: reconResult.skippedBelowThreshold,
accounts: reconResult.accounts.map((a) => ({
accountNumber: a.accountNumber,
applied: a.applied,
skippedBelowThreshold: a.skippedBelowThreshold,
})),
})
}
} catch {
// Non-critical: transactions remain uncategorized for manual review
}
}
const syncedAt = new Date().toISOString()
// Mirror refreshed balances into cash_accounts: the Bank-page source
// picker and the reconciliation status read that table, and without
// this the balance there froze at connect time.
{
const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service')
await updateBalancesFromSync(
supabase,
companyId,
connection.id,
allAccounts.map((a) => ({
external_uid: a.uid,
balance: a.balance,
available_balance: a.available_balance,
balance_updated_at: a.balance_updated_at,
})),
)
}
await supabase
.from('bank_connections')
.update({
accounts_data: allAccounts,
last_synced_at: syncedAt,
// A successful sync proves the session works again: recover an
// 'error' connection to 'active' (so the cron picks it up again)
// and clear any stale failure message from the settings panel.
...(connection.status === 'error' ? { status: 'active' } : {}),
...(connection.status === 'error' || connection.error_message
? { error_message: null }
: {}),
})
.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 },
})
}
}
// A bank that refused the requested window answered a narrower one;
// say so instead of reporting a truncated sync as complete (#2202).
// history_from is the LATEST effective date across the accounts:
// the date from which every account is complete.
const narrowedFrom = results
.filter((r) => r.historyNarrowed && r.effectiveFromDate)
.map((r) => r.effectiveFromDate as string)
const historyFrom = narrowedFrom.length > 0
? narrowedFrom.reduce((a, b) => (a > b ? a : b))
: null
return NextResponse.json({
imported: totalImported,
duplicates: totalDuplicates,
last_synced_at: syncedAt,
requested_from: fromDate,
history_narrowed: historyFrom !== null,
history_from: historyFrom,
})
} catch (error) {
// The bank refused a window it has answered before, or every
// narrower one: not a dead session and not a broken connection, so
// the row is left alone (no 'error', no renewal advice) and the
// client is told to try again later (#2202).
if (error instanceof AspspUnavailableError) {
log.warn('[enable-banking] Sync: bank unavailable, narrowing cannot help', {
reason: error.reason,
dateFrom: error.dateFrom,
status: error.status,
body: error.body,
user_id: user.id,
connection_id,
bankName: connection.bank_name,
})
return NextResponse.json(
{
error: BANK_UNAVAILABLE_MESSAGE,
code: 'BANK_UNAVAILABLE',
retryable: true,
connection_id: connection.id,
},
{ status: 503 }
)
}
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,
})
// A dead PSD2 session (closed/expired/invalid consent) can't be fixed
// by retrying: the user must re-authorize. Flip the connection to
// 'expired' so the UI surfaces the reconnect affordance, and tell the
// client re-auth is required (reauth_required) so it can offer a
// one-click "Förnya anslutning" instead of a dead-end error. No
// disconnect needed: /connect reconnects this same connection in place.
if (error instanceof SessionExpiredError) {
await supabase
.from('bank_connections')
.update({ status: 'expired', error_message: REAUTH_REQUIRED_MESSAGE })
.eq('id', connection.id)
.eq('company_id', companyId)
return NextResponse.json(
{
error: REAUTH_REQUIRED_MESSAGE,
code: 'SESSION_EXPIRED',
reauth_required: true,
connection_id: connection.id,
},
{ status: 409 }
)
}
// Non-session failure: the settings panel toasts this error verbatim
// and renders error_message on the connection card, so both must be
// the short Swedish message: the raw Enable Banking body (an English
// JSON envelope) is already in the server log above. Refresh the
// stored error_message on rows already in 'error' so a failed retry
// replaces any stale raw body persisted by older code.
if (connection.status === 'error') {
await supabase
.from('bank_connections')
.update({ error_message: SYNC_FAILED_MESSAGE })
.eq('id', connection.id)
.eq('company_id', companyId)
}
return NextResponse.json({ error: SYNC_FAILED_MESSAGE }, { 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 rawLookbackFromDate = body?.initial_lookback_from_date
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 120; clamp to [30, 365]. Ignored for selection edits.
// PSD2 obliges ASPSPs to ~90 days without fresh SCA, but many Swedish banks
// return more if asked: request 120 and accept whatever the bank gives back.
//
// If the client sent initial_lookback_from_date (preferred for fiscal-year-anchored
// backfills), derive days from that and reject future dates outright. Otherwise
// fall back to initial_lookback_days (or the 120-day default).
if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) {
const from = new Date(rawLookbackFromDate + 'T00:00:00Z')
if (!Number.isFinite(from.getTime())) {
return NextResponse.json(
{ error: 'initial_lookback_from_date är inte ett giltigt datum.' },
{ status: 400 }
)
}
const diffMs = Date.now() - from.getTime()
const daysFromDate = Math.ceil(diffMs / (24 * 60 * 60 * 1000))
if (daysFromDate <= 0) {
return NextResponse.json(
{ error: 'initial_lookback_from_date måste ligga i det förflutna.' },
{ status: 400 }
)
}
}
const initialLookbackDays = (() => {
if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) {
const from = new Date(rawLookbackFromDate + 'T00:00:00Z')
// Future/invalid dates already rejected above; days > 0 is guaranteed.
const diffMs = Date.now() - from.getTime()
const days = Math.ceil(diffMs / (24 * 60 * 60 * 1000))
return Math.min(365, Math.max(1, days))
}
const n = typeof rawLookback === 'number' && Number.isFinite(rawLookback) ? rawLookback : 120
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)
const next: StoredAccount = {
...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 }
: {}),
}
// Enabling an account is the deliberate takeover the callback's
// guard flags exist to force: once made, the flags are stale (the
// account syncs HERE now) and would keep rendering a false
// "synkas i annat bolag" note in every later picker.
if (next.enabled) {
delete next.claimed_by_company_id
delete next.claimed_by_company_name
delete next.deselected_elsewhere
}
return next
})
// Accounts the callback guard left disabled AND unmirrored (no ledger
// anywhere) stay that way through a save that does not enable them:
// allocating a 19xx slot and upserting a cash_accounts row for a
// still-disabled claimed account would recreate exactly the state the
// guard exists to prevent (another company's IBAN and name in this
// company's chart and routing table), one screen after the callback
// avoided it. Disabled accounts that already have a ledger or a
// mirrored row keep the existing behavior: their row's enabled flag
// must still flip off.
const neverMirroredDisabledUids = new Set<string>()
// Resolve the effective mirror ledger for every account up front and
// reject collisions with a 400 — the mirror pass below writes into
// cash_accounts, whose UNIQUE (company_id, ledger_account) constraint
// would otherwise fail per-account and get swallowed, leaving accounts
// silently unmirrored.
const { resolvePsd2LedgerAccount, upsertFromPsd2, getRevokedConnectionIds, normalizeIban } =
await import('@/lib/cash-accounts/service')
const { data: companyCashRows } = await supabase
.from('cash_accounts')
.select('id, external_uid, bank_connection_id, ledger_account, iban')
.eq('company_id', companyId)
const cashRows = (companyCashRows ?? []) as Array<{
id: string
external_uid: string | null
bank_connection_id: string | null
ledger_account: string
iban: string | null
}>
// Rows that already represent one of THIS connection's accounts, matched
// on IBAN rather than on the provider uid. After a re-authorization (new
// uids) or a fresh connect to an already-connected bank (new connection
// row), these are the user's own mappings wearing a stale owner: they
// must not be treated as another bank's territory, and the mirror pass
// below promotes them in place instead of inserting a second row.
const rowByIban = new Map<string, { id: string; ledger_account: string }>()
for (const r of cashRows) {
const normalized = normalizeIban(r.iban)
if (normalized && !rowByIban.has(normalized)) {
rowByIban.set(normalized, { id: r.id, ledger_account: r.ledger_account })
}
}
const reuseRowByUid = new Map<string, { id: string; ledger_account: string }>()
for (const a of updatedAccounts) {
const normalized = normalizeIban(a.iban)
const row = normalized ? rowByIban.get(normalized) : undefined
if (row) reuseRowByUid.set(a.uid, row)
}
const ownIbanRowIds = new Set([...reuseRowByUid.values()].map(r => r.id))
const existingLedgerByUid = new Map(
cashRows
.filter(r => r.bank_connection_id === connection.id && r.external_uid)
.map(r => [r.external_uid as string, r.ledger_account])
)
// Slots held by OTHER connections' PSD2 accounts — an explicit mapping
// onto one of those would violate the unique constraint. Manual rows
// are not foreign: upsertFromPsd2 promotes them in place. Rows held by
// a REVOKED connection are not foreign either: those are orphaned
// leftovers (disconnect predating the claim release, or a lost demote)
// and upsertFromPsd2 promotes them in place too. Excluding them here
// is the self-heal path for companies whose bank was disconnected
// before disconnect started releasing ledger claims.
const foreignConnectionIds = [
...new Set(
cashRows
.filter(r => r.bank_connection_id !== null && r.bank_connection_id !== connection.id)
.map(r => r.bank_connection_id as string)
),
]
const revokedConnectionIds = await getRevokedConnectionIds(
supabase,
companyId,
foreignConnectionIds
)
const foreignConnectedLedgers = new Set(
cashRows
.filter(
r =>
r.bank_connection_id !== null &&
r.bank_connection_id !== connection.id &&
!revokedConnectionIds.has(r.bank_connection_id) &&
// Same IBAN as one of this connection's accounts: the same
// physical account under a stale owner, not a foreign claim.
!ownIbanRowIds.has(r.id)
)
.map(r => r.ledger_account)
)
const effectiveLedgerByUid = new Map<string, string>()
const usedLedgers = new Set<string>()
const duplicateLedgers = new Set<string>()
const conflictingLedgers = new Set<string>()
// First pass: accounts with an explicit or previously mirrored ledger.
for (const a of updatedAccounts) {
const ledger = a.ledger_account ?? existingLedgerByUid.get(a.uid)
if (!ledger) continue
if (usedLedgers.has(ledger)) duplicateLedgers.add(ledger)
if (foreignConnectedLedgers.has(ledger) && existingLedgerByUid.get(a.uid) !== ledger) {
conflictingLedgers.add(ledger)
}
usedLedgers.add(ledger)
effectiveLedgerByUid.set(a.uid, ledger)
}
if (duplicateLedgers.size > 0) {
return NextResponse.json(
{
error: 'Flera bankkonton kan inte bokföras på samma konto. Välj olika bokföringskonton.',
duplicate_accounts: [...duplicateLedgers],
},
{ status: 400 }
)
}
if (conflictingLedgers.size > 0) {
return NextResponse.json(
{
error: 'Bokföringskontot används redan av ett bankkonto från en annan bankanslutning.',
conflicting_accounts: [...conflictingLedgers],
},
{ status: 400 }
)
}
// Second pass: allocate a free slot for accounts with no ledger at all
// (legacy connections mirrored before allocation existed, or mappings
// explicitly cleared). Allocation failure must never block selection
// save — fall back to the pre-allocator behavior (1930) and let the
// mirror pass surface any collision per-account, as before.
for (const a of updatedAccounts) {
if (effectiveLedgerByUid.has(a.uid)) continue
// See neverMirroredDisabledUids above: a disabled account that has
// never held a ledger or a mirrored row gets neither allocated nor
// mirrored by this save.
if (!enabledSet.has(a.uid) && !reuseRowByUid.has(a.uid)) {
neverMirroredDisabledUids.add(a.uid)
continue
}
let allocated: string | null = null
try {
const resolved = await resolvePsd2LedgerAccount(supabase, companyId, user.id, {
iban: a.iban,
currency: a.currency,
accountName: a.name,
exclude: usedLedgers,
})
allocated = resolved?.ledgerAccount ?? null
} catch (allocErr) {
log.warn('[enable-banking] ledger allocation failed on selection save', {
connectionId: connection.id,
uid: a.uid,
error: allocErr instanceof Error ? allocErr.message : String(allocErr),
})
}
const ledger = allocated ?? '1930'
usedLedgers.add(ledger)
effectiveLedgerByUid.set(a.uid, ledger)
}
// accounts_data mirrors the resolved assignment so the picker
// pre-fills reality on the next open. Skipped disabled accounts keep
// no assignment: their slot is only claimed if they are ever enabled.
for (const a of updatedAccounts) {
if (neverMirroredDisabledUids.has(a.uid)) continue
a.ledger_account = effectiveLedgerByUid.get(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 })
}
// Mirror the user's selection into cash_accounts so routing decisions
// and reconciliation pick up the new enabled state + ledger mapping
// without reading the JSONB column.
{
for (const a of updatedAccounts) {
// Never-mirrored disabled accounts (callback-guard leftovers the
// user did not enable) get no cash_accounts row: see above.
if (neverMirroredDisabledUids.has(a.uid)) continue
const ledgerAccount = a.ledger_account ?? '1930'
// Only reuse the IBAN-matched row when it already sits on the
// ledger we are about to write. If the user deliberately remapped
// the account to a different BAS number, promoting the old row
// would move a row out from under the ledger it still holds.
const reuseRow = reuseRowByUid.get(a.uid)
const reuseCashAccountId =
reuseRow && reuseRow.ledger_account === ledgerAccount ? reuseRow.id : null
try {
await upsertFromPsd2(supabase, companyId, {
bank_connection_id: connection.id,
external_uid: a.uid,
currency: a.currency,
ledger_account: ledgerAccount,
iban: a.iban ?? null,
bban: a.bban ?? null,
name: a.name ?? null,
balance: a.balance ?? null,
available_balance: a.available_balance ?? null,
balance_updated_at: a.balance_updated_at ?? null,
enabled: a.enabled ?? true,
reuse_cash_account_id: reuseCashAccountId,
})
} catch (cashErr) {
log.error('[enable-banking] Failed to mirror cash_account on selection save', {
connectionId: connection.id,
uid: a.uid,
error: cashErr instanceof Error ? cashErr.message : String(cashErr),
})
}
}
}
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
auto_matched: 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]
// Same guard the manual /sync route and the cron apply. This path also
// runs on RENEWAL (reconnect resets status to pending_selection), and a
// fresh consent often makes the bank release history the first connect
// never delivered, straight over an already-bookkept period. Without
// the guard those rows would be auto-categorized into brand-new
// verifikat (double-booking) instead of being linked to the ones that
// already describe them.
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()
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'
log.info('[enable-banking] Starting inline initial backfill', {
connectionId: connection.id,
accountCount: accountsToSync.length,
lookbackDays: initialLookbackDays,
fromDate,
toDate,
sieOverlap: Boolean(sieOverlap),
})
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',
...(sieOverlap ? { skipAutoCategorization: true } : {}),
...(isViewer ? { rawInsertOnly: true } : {}),
}
))
)
// 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
// Post-backfill reconciliation sweep, mirroring the manual /sync
// route: link just-imported rows to the verifikat that already
// describe them so a re-released period does not resurface as
// hundreds of "ohanterade" transactions. Unlike the /sync and cron
// sweeps this runs once per enabled ledger account with a resolved
// cash-account scope: the pooled unscoped form can cross-link
// accounts (#1290/#1298). Both a thrown scope resolution AND an
// unresolved cash-account row (found: false) skip that account's
// sweep instead of widening to the pooled form.
//
// The window opens at the OLDEST booking date the bank actually
// returned when that is older than the requested fromDate: some
// ASPSPs over-return history, and rows outside the requested window
// would otherwise be ingested but never swept.
const sweepDateFrom = returnedMin && returnedMin < fromDate ? returnedMin : fromDate
// The sweep writes bank-feed metadata only (transactions.journal_entry_id,
// reconciliation_method, is_business): journal tables are never touched,
// so BFL immutability and period locks (which guard journal entries) are
// not in play. Links to opening-balance verifikat are blocked by the
// check_transaction_link_not_opening_balance trigger, and every link is
// reversible via unlinkReconciliation without any ledger write.
let totalAutoMatched = 0
if (sieOverlap && totalImported > 0 && !isViewer) {
// Filter, not `?? undefined`: an undefined accountNumber makes
// resolveCashAccountScope fall back to the primary account with
// includeUnassigned=true, which is the pooled form this block must
// never widen to. The allocator gives every enabled account a
// concrete ledger_account, so nothing is skipped in practice.
const ledgerAccounts = Array.from(
new Set(
accountsToSync
.map(a => a.ledger_account)
.filter((l): l is string => typeof l === 'string' && l.length > 0)
)
)
for (const ledgerAccount of ledgerAccounts) {
try {
const scope = await resolveCashAccountScope(supabase, companyId, ledgerAccount)
if (!scope.found) {
log.warn('[enable-banking] No cash_accounts row for ledger account; skipping its reconciliation sweep', {
connectionId: connection.id,
ledgerAccount,
})
continue
}
const reconResult = await runReconciliation(supabase, companyId, user.id, {
dateFrom: sweepDateFrom,
dateTo: toDate,
accountNumber: scope.accountNumber,
currency: scope.currency,
cashAccountId: scope.cashAccountId,
includeUnassigned: scope.includeUnassigned,
// Unattended run: nobody reviews a dry-run first, so never
// commit low-confidence (fuzzy / date-range) matches.
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
// ...but don't DROP them either: the 0.75-0.89 band feeds
// the "Granska förslag" review surface.
persistSuggestions: true,
})
totalAutoMatched += reconResult.applied
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
log.info('[enable-banking] Post-backfill reconciliation linked imported rows to existing verifikat', {
connectionId: connection.id,
accountNumber: scope.accountNumber,
applied: reconResult.applied,
skippedBelowThreshold: reconResult.skippedBelowThreshold,
total: reconResult.matches.length,
})
}
} catch {
// Non-critical: rows stay unmatched for manual review.
}
}
}
// Mirror the balances the backfill just fetched into cash_accounts.
// accounts_data is deliberately NOT re-written here (see below), so
// without this the balances fetched during the initial sync would
// reach neither store until the next scheduled sync.
try {
const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service')
await updateBalancesFromSync(
supabase,
companyId,
connection.id,
updatedAccounts.map((a) => ({
external_uid: a.uid,
balance: a.balance,
available_balance: a.available_balance,
balance_updated_at: a.balance_updated_at,
})),
)
} catch (mirrorErr) {
log.error('[enable-banking] Balance mirror after initial backfill failed', {
connectionId: connection.id,
error: mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr),
})
}
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,
auto_matched: totalAutoMatched,
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 the PSD2 consent only when no other company still depends on
// it. Sessions are shared across a user's companies (see
// lib/session-sharing.ts), so a blind revoke here would silently take
// down a sibling company's bank feed: exactly the failure this feature
// exists to remove. countLiveSiblings needs the service client because
// RLS hides a sibling living in a company the user has since left, and
// an unseen sibling would read as "safe to revoke".
let sharedWithSiblings = false
if (connection.session_id) {
const { createServiceClient } = await import('@/lib/supabase/server')
const serviceSupabase = await createServiceClient()
const siblingCount = await countLiveSiblings(
serviceSupabase,
connection.session_id,
connection.id,
)
sharedWithSiblings = siblingCount > 0
if (sharedWithSiblings) {
log.info('[enable-banking] Session still in use by other companies: skipping revoke', {
connectionId: connection.id,
siblingCount,
userId: user.id,
companyId,
})
}
}
if (connection.session_id && !sharedWithSiblings) {
try {
await deleteSession(connection.session_id)
} catch (error) {
// The revoke is best-effort: an expired or already-closed session
// is the normal case here, and the disconnect continues either
// way, so this is a warning and not an error.
log.warn('[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 })
}
// Release the connection's ledger claims by demoting its cash_accounts
// rows to manual (bank_connection_id = null). The rows themselves stay:
// transactions.cash_account_id and the ledger history reference them,
// and upsertFromPsd2 promotes a manual holder in place on reconnect so
// the same bank lands back on its original BAS account (e.g. 1930)
// instead of overflowing to the next free slot.
const { error: releaseError } = await supabase
.from('cash_accounts')
.update({ bank_connection_id: null })
.eq('company_id', companyId)
.eq('bank_connection_id', connection.id)
if (releaseError) {
// Don't fail the disconnect: the connection is already revoked, and
// the allocator / collision guard also skip revoked connections, so
// the orphaned rows self-heal on the next picker save.
log.error('[enable-banking] Failed to release cash_accounts ledger claims on disconnect', {
errorMessage: releaseError.message,
connectionId: connection.id,
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.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: [],
}