Added extension functionality
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||
import {
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import type { BankConnection } from '@/types'
|
||||
|
||||
interface BankConnectionStatusProps {
|
||||
connection: BankConnection
|
||||
onSync: (connectionId: string) => void
|
||||
onDisconnect: (connectionId: string) => void
|
||||
isSyncing?: boolean
|
||||
}
|
||||
|
||||
export function BankConnectionStatus({
|
||||
connection,
|
||||
onSync,
|
||||
onDisconnect,
|
||||
isSyncing = false,
|
||||
}: BankConnectionStatusProps) {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600',
|
||||
label: 'Aktiv',
|
||||
variant: 'success' as const,
|
||||
},
|
||||
pending: {
|
||||
icon: Loader2,
|
||||
color: 'text-yellow-600',
|
||||
label: 'Väntar',
|
||||
variant: 'warning' as const,
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600',
|
||||
label: 'Fel',
|
||||
variant: 'destructive' as const,
|
||||
},
|
||||
revoked: {
|
||||
icon: XCircle,
|
||||
color: 'text-gray-600',
|
||||
label: 'Bortkopplad',
|
||||
variant: 'secondary' as const,
|
||||
},
|
||||
}
|
||||
|
||||
const status = statusConfig[connection.status as keyof typeof statusConfig] || statusConfig.error
|
||||
const StatusIcon = status.icon
|
||||
|
||||
// Parse accounts from connection
|
||||
const accounts = (connection.accounts_data as Array<{
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}>) || []
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StatusIcon className={`h-3 w-3 ${status.color}`} />
|
||||
<span>{status.label}</span>
|
||||
{connection.last_synced_at && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>Synkad {formatDate(connection.last_synced_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSync(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDisconnect(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consent expiry warning */}
|
||||
{isExpiring && daysUntilExpiry !== null && (
|
||||
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<span className="text-sm">
|
||||
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
|
||||
Förnya genom att ansluta igen.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts list */}
|
||||
{accounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Konton</p>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.uid}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.balance !== undefined && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Script from 'next/script'
|
||||
|
||||
export interface Bank {
|
||||
name: string
|
||||
country: string
|
||||
logo?: string
|
||||
bic?: string
|
||||
}
|
||||
|
||||
interface BankSelectorProps {
|
||||
onSelect: (bank: Bank) => void
|
||||
selectedBank?: Bank | null
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
country?: string
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
export function BankSelector({
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
className,
|
||||
country = 'SE',
|
||||
sandbox = true, // Default to sandbox for development
|
||||
}: BankSelectorProps) {
|
||||
const widgetRef = useRef<HTMLElement | null>(null)
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!scriptLoaded) return
|
||||
|
||||
const widget = widgetRef.current
|
||||
if (!widget) return
|
||||
|
||||
const handleSelected = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const detail = customEvent.detail as {
|
||||
name: string
|
||||
country: string
|
||||
psuType: string
|
||||
sandbox: boolean
|
||||
}
|
||||
|
||||
onSelect({
|
||||
name: detail.name,
|
||||
country: detail.country,
|
||||
})
|
||||
}
|
||||
|
||||
widget.addEventListener('selected', handleSelected)
|
||||
|
||||
return () => {
|
||||
widget.removeEventListener('selected', handleSelected)
|
||||
}
|
||||
}, [onSelect, scriptLoaded])
|
||||
|
||||
const handleScriptLoad = () => {
|
||||
setScriptLoaded(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center p-8', className)}>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Ansluter...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<Script
|
||||
src="https://tilisy.enablebanking.com/lib/widgets.umd.min.js"
|
||||
onLoad={handleScriptLoad}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<link
|
||||
href="https://tilisy.enablebanking.com/lib/widgets.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
{/* @ts-expect-error - Enable Banking custom element */}
|
||||
<enablebanking-aspsp-list
|
||||
ref={widgetRef}
|
||||
country={country}
|
||||
psu-type="business"
|
||||
service="AIS"
|
||||
{...(sandbox ? { sandbox: true } : {})}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{sandbox ? 'Sandbox-läge: Använd testbanker för utveckling' : 'Välj din bank för att fortsätta'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import {
|
||||
startAuthorization,
|
||||
getASPSPs,
|
||||
createSession,
|
||||
getAccountBalance,
|
||||
isConsentExpiringSoon,
|
||||
getDaysUntilExpiry,
|
||||
type ASPSP,
|
||||
type AccountInfo,
|
||||
} from './lib/api-client'
|
||||
import { syncAccountTransactions } from './lib/sync'
|
||||
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 () => {
|
||||
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 })
|
||||
} catch (error) {
|
||||
console.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' },
|
||||
]
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await 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 {
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
||||
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
aspsp_name,
|
||||
aspsp_country,
|
||||
redirectUrl,
|
||||
user.id,
|
||||
'personal'
|
||||
)
|
||||
|
||||
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,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
throw new Error('Failed to store connection')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: connection.id,
|
||||
authorization_url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { connection_id, days_back = 30 } = await request.json()
|
||||
|
||||
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]
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
}
|
||||
|
||||
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', fromDate)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(totalImported)
|
||||
|
||||
if (syncedTransactions && syncedTransactions.length > 0) {
|
||||
await eventBus.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) {
|
||||
console.error('Sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [],
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Enable Banking API integration for PSD2 bank connections
|
||||
*
|
||||
* Documentation: https://enablebanking.com/docs/api/reference/
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /auth → { url, authorization_id }
|
||||
* 2. User redirects to URL, authenticates with bank
|
||||
* 3. Callback receives ?code=XXX&state=YYY
|
||||
* 4. POST /sessions { code } → { session_id, accounts }
|
||||
* 5. GET /accounts/{uid}/balances
|
||||
* 6. GET /accounts/{uid}/transactions
|
||||
*/
|
||||
|
||||
import { getAuthorizationHeader } from './jwt'
|
||||
|
||||
const ENABLE_BANKING_API_URL = 'https://api.enablebanking.com'
|
||||
|
||||
// Types
|
||||
|
||||
export interface ASPSP {
|
||||
name: string
|
||||
country: string
|
||||
logo?: string
|
||||
bic?: string
|
||||
beta?: boolean
|
||||
max_consent_validity?: number
|
||||
available_auth_methods?: AuthMethod[]
|
||||
}
|
||||
|
||||
export interface AuthMethod {
|
||||
name: string
|
||||
title?: string
|
||||
psu_types?: ('personal' | 'business')[]
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
url: string
|
||||
authorization_id: string
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
session_id: string
|
||||
access: {
|
||||
valid_until: string
|
||||
}
|
||||
accounts: AccountInfo[]
|
||||
aspsp: {
|
||||
name: string
|
||||
country: string
|
||||
}
|
||||
psu_type: string
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
uid: string
|
||||
account_id?: {
|
||||
iban?: string
|
||||
bban?: string
|
||||
other?: string
|
||||
}
|
||||
name?: string
|
||||
product?: string
|
||||
currency: string
|
||||
identification_hash?: string
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
balance_amount: {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
balance_type: string
|
||||
reference_date?: string
|
||||
last_change_date_time?: string
|
||||
}
|
||||
|
||||
export interface BalanceResponse {
|
||||
balances: Balance[]
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
entry_reference?: string
|
||||
transaction_id?: string
|
||||
booking_date?: string
|
||||
value_date?: string
|
||||
transaction_amount: {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
credit_debit_indicator?: 'CRDT' | 'DBIT' // CRDT = credit (income), DBIT = debit (expense)
|
||||
creditor_name?: string
|
||||
creditor_account?: {
|
||||
iban?: string
|
||||
bban?: string
|
||||
}
|
||||
creditor?: {
|
||||
name?: string
|
||||
}
|
||||
debtor_name?: string
|
||||
debtor_account?: {
|
||||
iban?: string
|
||||
bban?: string
|
||||
}
|
||||
debtor?: {
|
||||
name?: string
|
||||
}
|
||||
remittance_information?: string[]
|
||||
merchant_category_code?: string
|
||||
bank_transaction_code?: string
|
||||
proprietary_bank_transaction_code?: string
|
||||
}
|
||||
|
||||
export interface TransactionsResponse {
|
||||
transactions: Transaction[]
|
||||
continuation_key?: string
|
||||
}
|
||||
|
||||
// Legacy types for backward compatibility
|
||||
export interface Bank {
|
||||
id: string
|
||||
name: string
|
||||
bic?: string
|
||||
countries: string[]
|
||||
logo_url?: string
|
||||
}
|
||||
|
||||
export interface BankTransaction {
|
||||
id: string
|
||||
date: string
|
||||
booking_date: string
|
||||
amount: number
|
||||
currency: string
|
||||
description: string
|
||||
counterparty_name?: string
|
||||
counterparty_account?: string
|
||||
reference?: string
|
||||
merchant_category_code?: string
|
||||
}
|
||||
|
||||
// API Helper
|
||||
|
||||
async function authenticatedFetch(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const url = `${ENABLE_BANKING_API_URL}${endpoint}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': getAuthorizationHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
/**
|
||||
* Get list of supported banks (ASPSPs) for a country
|
||||
*/
|
||||
export async function getASPSPs(country: string = 'SE'): Promise<ASPSP[]> {
|
||||
const response = await authenticatedFetch(`/aspsps?country=${country}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error('Failed to fetch ASPSPs:', error)
|
||||
throw new Error('Failed to fetch banks')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.aspsps || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported banks (legacy format for backward compatibility)
|
||||
*/
|
||||
export async function getSupportedBanks(): Promise<Bank[]> {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
|
||||
return aspsps.map((aspsp) => ({
|
||||
id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-se`,
|
||||
name: aspsp.name,
|
||||
bic: aspsp.bic,
|
||||
countries: [aspsp.country],
|
||||
logo_url: aspsp.logo,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
// Return fallback list
|
||||
return [
|
||||
{ id: 'nordea-se', name: 'Nordea', bic: 'NDEASESS', countries: ['SE'] },
|
||||
{ id: 'seb-se', name: 'SEB', bic: 'ESSESESS', countries: ['SE'] },
|
||||
{ id: 'swedbank-se', name: 'Swedbank', bic: 'SWEDSESS', countries: ['SE'] },
|
||||
{ id: 'handelsbanken-se', name: 'Handelsbanken', bic: 'HANDSESS', countries: ['SE'] },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start bank authorization flow
|
||||
*
|
||||
* @param aspspName - The name of the ASPSP (bank) exactly as returned from /aspsps
|
||||
* @param aspspCountry - The country code (e.g., 'SE')
|
||||
* @param redirectUrl - URL to redirect user after bank authorization
|
||||
* @param state - State parameter returned in callback (e.g., user ID)
|
||||
* @param psuType - Type of user: 'personal' or 'business'
|
||||
*/
|
||||
export async function startAuthorization(
|
||||
aspspName: string,
|
||||
aspspCountry: string,
|
||||
redirectUrl: string,
|
||||
state: string,
|
||||
psuType: 'personal' | 'business' = 'personal'
|
||||
): Promise<AuthResponse> {
|
||||
// Calculate consent validity (90 days)
|
||||
const validUntil = new Date()
|
||||
validUntil.setDate(validUntil.getDate() + 90)
|
||||
|
||||
const requestBody = {
|
||||
access: {
|
||||
valid_until: validUntil.toISOString()
|
||||
},
|
||||
aspsp: {
|
||||
name: aspspName,
|
||||
country: aspspCountry
|
||||
},
|
||||
state,
|
||||
redirect_url: redirectUrl,
|
||||
psu_type: psuType
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch('/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error('Failed to start authorization:', error)
|
||||
throw new Error('Failed to start bank connection')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session after user completes bank authorization
|
||||
*
|
||||
* @param code - The authorization code from callback
|
||||
*/
|
||||
export async function createSession(code: string): Promise<SessionResponse> {
|
||||
const response = await authenticatedFetch('/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error('Failed to create session:', error)
|
||||
throw new Error('Failed to create bank session')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session details
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
*/
|
||||
export async function getSession(sessionId: string): Promise<SessionResponse> {
|
||||
const response = await authenticatedFetch(`/sessions/${sessionId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get session')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete/revoke a session
|
||||
*
|
||||
* @param sessionId - The session ID to revoke
|
||||
*/
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const response = await authenticatedFetch(`/sessions/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to revoke session')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account balances
|
||||
*
|
||||
* @param accountUid - The account UID (from session.accounts[].uid)
|
||||
*/
|
||||
export async function getAccountBalances(accountUid: string): Promise<Balance[]> {
|
||||
const response = await authenticatedFetch(`/accounts/${accountUid}/balances`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get account balances')
|
||||
}
|
||||
|
||||
const data: BalanceResponse = await response.json()
|
||||
return data.balances || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account balance (returns booked balance amount)
|
||||
*/
|
||||
export async function getAccountBalance(
|
||||
accountUid: string
|
||||
): Promise<{ amount: number; date: string }> {
|
||||
const balances = await getAccountBalances(accountUid)
|
||||
|
||||
// Prefer closingBooked, then expected, then first available
|
||||
const balance =
|
||||
balances.find(b => b.balance_type === 'closingBooked') ||
|
||||
balances.find(b => b.balance_type === 'expected') ||
|
||||
balances[0]
|
||||
|
||||
if (!balance) {
|
||||
return { amount: 0, date: new Date().toISOString().split('T')[0] }
|
||||
}
|
||||
|
||||
return {
|
||||
amount: parseFloat(balance.balance_amount.amount),
|
||||
date: balance.reference_date || new Date().toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account transactions
|
||||
*
|
||||
* @param accountUid - The account UID
|
||||
* @param dateFrom - Start date (YYYY-MM-DD)
|
||||
* @param dateTo - End date (YYYY-MM-DD)
|
||||
* @param continuationKey - Pagination key from previous response
|
||||
*/
|
||||
export async function getAccountTransactions(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
continuationKey?: string
|
||||
): Promise<TransactionsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (continuationKey) params.set('continuation_key', continuationKey)
|
||||
|
||||
const queryString = params.toString()
|
||||
const endpoint = `/accounts/${accountUid}/transactions${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await authenticatedFetch(endpoint)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get transactions')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions with pagination
|
||||
*/
|
||||
export async function getAllTransactions(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
): Promise<Transaction[]> {
|
||||
const allTransactions: Transaction[] = []
|
||||
let continuationKey: string | undefined
|
||||
|
||||
do {
|
||||
const response = await getAccountTransactions(
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
continuationKey
|
||||
)
|
||||
|
||||
allTransactions.push(...response.transactions)
|
||||
continuationKey = response.continuation_key
|
||||
} while (continuationKey)
|
||||
|
||||
return allTransactions
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Enable Banking transaction to legacy format
|
||||
*/
|
||||
export function convertTransaction(tx: Transaction, accountCurrency: string): BankTransaction {
|
||||
const rawAmount = parseFloat(tx.transaction_amount.amount)
|
||||
|
||||
// Use credit_debit_indicator to determine sign
|
||||
// CRDT = credit (money in) = positive
|
||||
// DBIT = debit (money out) = negative
|
||||
const isCredit = tx.credit_debit_indicator === 'CRDT'
|
||||
const amount = isCredit ? Math.abs(rawAmount) : -Math.abs(rawAmount)
|
||||
|
||||
// Get counterparty name from creditor/debtor objects or direct fields
|
||||
const creditorName = tx.creditor?.name || tx.creditor_name
|
||||
const debtorName = tx.debtor?.name || tx.debtor_name
|
||||
|
||||
return {
|
||||
id: tx.entry_reference || tx.transaction_id || `${tx.booking_date}_${rawAmount}`,
|
||||
date: tx.value_date || tx.booking_date || new Date().toISOString().split('T')[0],
|
||||
booking_date: tx.booking_date || tx.value_date || new Date().toISOString().split('T')[0],
|
||||
amount,
|
||||
currency: tx.transaction_amount.currency || accountCurrency,
|
||||
description: tx.remittance_information?.filter(r => r.trim()).join(' ') ||
|
||||
(isCredit ? debtorName : creditorName) ||
|
||||
'Unknown',
|
||||
counterparty_name: isCredit ? debtorName : creditorName,
|
||||
counterparty_account: isCredit
|
||||
? tx.debtor_account?.iban || tx.debtor_account?.bban
|
||||
: tx.creditor_account?.iban || tx.creditor_account?.bban,
|
||||
merchant_category_code: tx.merchant_category_code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transactions in legacy format
|
||||
*/
|
||||
export async function getTransactions(
|
||||
accountUid: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
accountCurrency: string = 'SEK'
|
||||
): Promise<BankTransaction[]> {
|
||||
const transactions = await getAllTransactions(accountUid, fromDate, toDate)
|
||||
return transactions.map(tx => convertTransaction(tx, accountCurrency))
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
/**
|
||||
* Check if consent is expiring soon (within 7 days)
|
||||
*/
|
||||
export function isConsentExpiringSoon(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false
|
||||
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const warningDate = new Date()
|
||||
warningDate.setDate(warningDate.getDate() + 7)
|
||||
|
||||
return expiryDate <= warningDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days until consent expires
|
||||
*/
|
||||
export function getDaysUntilExpiry(expiresAt: string | null): number | null {
|
||||
if (!expiresAt) return null
|
||||
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffTime = expiryDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return Math.max(0, diffDays)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* JWT generation for Enable Banking API authentication
|
||||
*
|
||||
* Enable Banking requires JWT tokens signed with RS256 using your private key.
|
||||
* The JWT is included in the Authorization header for all API calls.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
const APP_ID = process.env.ENABLE_BANKING_APP_ID
|
||||
const PRIVATE_KEY_BASE64 = process.env.ENABLE_BANKING_PRIVATE_KEY
|
||||
|
||||
interface JWTHeader {
|
||||
typ: string
|
||||
alg: string
|
||||
kid: string
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
iss: string
|
||||
aud: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Buffer | string): string {
|
||||
const str = typeof data === 'string' ? data : data.toString('base64')
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function getPrivateKey(): string {
|
||||
if (!PRIVATE_KEY_BASE64) {
|
||||
throw new Error('ENABLE_BANKING_PRIVATE_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
// Decode base64 to get PEM format private key
|
||||
const privateKeyPem = Buffer.from(PRIVATE_KEY_BASE64, 'base64').toString('utf-8')
|
||||
return privateKeyPem
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for Enable Banking API authentication
|
||||
*
|
||||
* @param expiresInSeconds - Token validity in seconds (default: 3600 = 1 hour)
|
||||
* @returns Signed JWT token
|
||||
*/
|
||||
export function generateJWT(expiresInSeconds: number = 3600): string {
|
||||
if (!APP_ID) {
|
||||
throw new Error('ENABLE_BANKING_APP_ID environment variable is not set')
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const header: JWTHeader = {
|
||||
typ: 'JWT',
|
||||
alg: 'RS256',
|
||||
kid: APP_ID
|
||||
}
|
||||
|
||||
const payload: JWTPayload = {
|
||||
iss: 'enablebanking.com',
|
||||
aud: 'api.enablebanking.com',
|
||||
iat: now,
|
||||
exp: now + expiresInSeconds
|
||||
}
|
||||
|
||||
// Encode header and payload
|
||||
const headerBase64 = base64UrlEncode(Buffer.from(JSON.stringify(header)))
|
||||
const payloadBase64 = base64UrlEncode(Buffer.from(JSON.stringify(payload)))
|
||||
|
||||
// Create signature
|
||||
const signatureInput = `${headerBase64}.${payloadBase64}`
|
||||
const privateKey = getPrivateKey()
|
||||
|
||||
const sign = crypto.createSign('RSA-SHA256')
|
||||
sign.update(signatureInput)
|
||||
sign.end()
|
||||
|
||||
const signature = sign.sign(privateKey)
|
||||
const signatureBase64 = base64UrlEncode(signature)
|
||||
|
||||
return `${headerBase64}.${payloadBase64}.${signatureBase64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Authorization header value for Enable Banking API
|
||||
*/
|
||||
export function getAuthorizationHeader(): string {
|
||||
const token = generateJWT()
|
||||
return `Bearer ${token}`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getTransactions, getAccountBalance } from './api-client'
|
||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
export interface SyncResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync transactions for a single bank account via Enable Banking PSD2.
|
||||
*
|
||||
* Fetches transactions from the Enable Banking API, converts to RawTransaction
|
||||
* format, and delegates to the shared ingestion pipeline.
|
||||
*/
|
||||
export async function syncAccountTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
account: StoredAccount,
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
): Promise<SyncResult> {
|
||||
const bankTransactions = await getTransactions(
|
||||
account.uid,
|
||||
fromDate,
|
||||
toDate,
|
||||
account.currency
|
||||
)
|
||||
|
||||
// Convert Enable Banking format to generic RawTransaction
|
||||
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => ({
|
||||
date: tx.booking_date || tx.date,
|
||||
description: tx.description || tx.counterparty_name || 'Unknown',
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || account.currency,
|
||||
external_id: `${connectionId}_${tx.id}`,
|
||||
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
|
||||
merchant_name: tx.counterparty_name || null,
|
||||
reference: tx.reference || null,
|
||||
bank_connection_id: connectionId,
|
||||
import_source: 'enable_banking',
|
||||
}))
|
||||
|
||||
const ingestResult = await ingestTransactions(supabase, userId, rawTransactions)
|
||||
|
||||
// Update account balance
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
account.balance = balance.amount
|
||||
} catch {
|
||||
// Ignore balance fetch errors
|
||||
}
|
||||
|
||||
return {
|
||||
imported: ingestResult.imported,
|
||||
duplicates: ingestResult.duplicates,
|
||||
errors: ingestResult.errors,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Enable Banking extension types
|
||||
|
||||
export interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
// Re-export API types from the client
|
||||
export type {
|
||||
ASPSP,
|
||||
AuthMethod,
|
||||
AuthResponse,
|
||||
SessionResponse,
|
||||
AccountInfo,
|
||||
Balance,
|
||||
BalanceResponse,
|
||||
Transaction as EnableBankingTransaction,
|
||||
TransactionsResponse,
|
||||
Bank,
|
||||
BankTransaction,
|
||||
} from './lib/api-client'
|
||||
Reference in New Issue
Block a user