* feat: event log, pending operations, and MCP staging - Event log system: persist bus events to event_log table for external automation platforms. Batch insert for transaction.synced. Daily cleanup cron at 02:00 UTC. - Pending operations: MCP write tools (categorize, create customer, create invoice) now stage to pending_operations instead of executing directly. Users review and commit/reject from /pending in the web UI. - Granskning page: card-based review UI with expandable previews, commit/reject dialogs. Only shown in nav when pending ops exist. - Commit route re-executes using core lib functions (no extension imports). Guards against stale state (double-commit, deleted entities). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stage new MCP write tools after main merge Add staging for 4 new write tools from #133: - mark_invoice_paid, send_invoice, mark_invoice_sent, match_transaction_invoice - Expand pending_operations CHECK constraint - Add commit executors with full execution logic - Add UI labels and generic preview component - Remove confirm parameter from categorize (single-call staging) - Fix UUID in pending op title (fetch transaction description) - Hide Granskning nav when no pending ops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback - Fix TS build error: use `select('*, customer:customers(*)')` for match_transaction_invoice to avoid array type inference - Add status guard to commitSendInvoice (prevents duplicate sends) - Replace auth.admin.getUserById with user email from session auth - Restore optimistic lock check in commitMatchTransactionInvoice - Fix tool description typo: expense_software → expense_office Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add support contact links and improve SIE import UX Add a SupportLink component with a contact dialog throughout the app (nav, help page, settings, MFA, error pages, empty states). Improve SIE import flow with phased loading states, structured skip breakdowns, and an elapsed-time counter. Fix MFA enroll stale factor cleanup and URL encoding for settings return path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — open redirect, XSS, test cleanup, fallback email - Validate returnTo is a relative path in MFA enroll (prevents open redirect) - Add afterEach import to event-log-handler tests (fixes handler leak) - HTML-escape user-supplied subject and message in support email body - Replace hardcoded personal email with support@gnubok.se fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: move bank details from onboarding to first invoice creation Bank details (account, bankgiro, invoice prefix) are now collected contextually when the user creates their first invoice, rather than during onboarding where most users skip them. This ensures invoices always have payment information on the PDF. - Remove onboarding step 5 (bank details), simplify to 4 steps - Delete Step6ConnectBank component - Add BankDetailsSetupDialog with bank account, bankgiro (Luhn), IBAN/BIC (collapsible), and invoice prefix fields - Intercept at "Granska & skapa" for invoice document type only (proforma and delivery notes pass through without bank details) - Show soft info banner on invoice form when bank details are missing - Add controlled mode (value/onChange) to BankNameCombobox for reuse Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — null-check race, escape key, starting number - Fix P1: use `hasBankDetails === false` instead of `!hasBankDetails` to avoid treating null (loading) state as missing bank details - Fix P2: remove onEscapeKeyDown override so keyboard users can dismiss the dialog (WCAG AA compliance) - Add starting invoice number field alongside prefix, so users can choose e.g. starting at 14 instead of 1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: skip auto-categorization during bank sync when SIE overlap detected Prevents double-booking when bank transactions are synced for a period that already has journal entries from a SIE import. Reconciliation still links transactions to existing GL lines; only new journal entry creation is suppressed. A batch reconciliation sweep runs post-sync to catch additional matches. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
394 lines
14 KiB
TypeScript
394 lines
14 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 type { StoredAccount } from './types'
|
|
import type { Transaction } from '@/types'
|
|
|
|
/**
|
|
* 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?tab=banking',
|
|
},
|
|
|
|
apiRoutes: [
|
|
{
|
|
method: 'GET',
|
|
path: '/banks',
|
|
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
|
const log = ctx?.log ?? console
|
|
try {
|
|
const aspsps = await getASPSPs('SE')
|
|
const banks = aspsps.map((aspsp: ASPSP) => ({
|
|
name: aspsp.name,
|
|
country: aspsp.country,
|
|
logo: aspsp.logo,
|
|
bic: aspsp.bic,
|
|
}))
|
|
return NextResponse.json({ banks, 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 })
|
|
}
|
|
|
|
const { aspsp_name, aspsp_country } = await request.json()
|
|
|
|
if (!aspsp_name || !aspsp_country) {
|
|
return NextResponse.json(
|
|
{ error: 'aspsp_name and aspsp_country are required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
try {
|
|
// Determine PSU type from entity type
|
|
const { data: companySettings } = await supabase
|
|
.from('company_settings')
|
|
.select('entity_type')
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
const psuType = companySettings?.entity_type === 'aktiebolag' ? 'business' : 'personal'
|
|
|
|
log.info('[enable-banking] Starting bank connection', {
|
|
user_id: user.id,
|
|
bank: aspsp_name,
|
|
country: aspsp_country,
|
|
entity_type: companySettings?.entity_type,
|
|
psu_type: psuType,
|
|
})
|
|
|
|
// Clean up any stale pending connections for this user+bank
|
|
// to avoid conflicts with the new authorization
|
|
const { data: staleConnections } = await supabase
|
|
.from('bank_connections')
|
|
.select('id')
|
|
.eq('user_id', user.id)
|
|
.eq('bank_name', aspsp_name)
|
|
.eq('status', 'pending')
|
|
|
|
if (staleConnections && staleConnections.length > 0) {
|
|
log.info('[enable-banking] Cleaning up stale pending connections', {
|
|
count: staleConnections.length,
|
|
})
|
|
await supabase
|
|
.from('bank_connections')
|
|
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
|
|
.eq('user_id', user.id)
|
|
.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({
|
|
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 })
|
|
}
|
|
|
|
const { connection_id, days_back: rawDaysBack = 30 } = 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('user_id', user.id)
|
|
.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 {
|
|
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
|
|
|
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('user_id', user.id)
|
|
.eq('status', 'completed')
|
|
.gte('fiscal_year_end', fromDate)
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
const syncOptions = sieOverlap
|
|
? { skipAutoCategorization: true }
|
|
: undefined
|
|
|
|
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,
|
|
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.
|
|
if (sieOverlap && totalImported > 0) {
|
|
try {
|
|
const reconResult = await runReconciliation(supabase, 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: accounts,
|
|
last_synced_at: syncedAt,
|
|
})
|
|
.eq('id', connection.id)
|
|
|
|
if (totalImported > 0) {
|
|
const { data: syncedTransactions } = await supabase
|
|
.from('transactions')
|
|
.select('*')
|
|
.eq('user_id', user.id)
|
|
.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 },
|
|
})
|
|
}
|
|
}
|
|
|
|
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: '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 })
|
|
}
|
|
|
|
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')
|
|
.eq('id', connection_id)
|
|
.eq('user_id', user.id)
|
|
.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,
|
|
})
|
|
}
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from('bank_connections')
|
|
.update({ status: 'revoked', session_id: null })
|
|
.eq('id', connection.id)
|
|
|
|
if (updateError) {
|
|
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
},
|
|
},
|
|
],
|
|
|
|
eventHandlers: [],
|
|
}
|