Files
accounted/app/api/extensions/enable-banking/callback/route.ts
T
Mattsson d484c341a4 Psu type configuration (#248)
* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking

- Added personnummer encryption and decryption functions for secure storage.
- Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions.
- Implemented tax table lookup functionality for calculating tax amounts based on monthly income.
- Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations.
- Established row-level security policies for all new tables to ensure company-scoped access.

* feat: add salary calculation modules for 2026

- Implemented engångsskatt calculation for one-time payments with tax brackets.
- Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings.
- Created pain.001 generator for salary batch payments in compliance with Swedish banking standards.
- Developed PDF template for payslips, including detailed breakdowns and employer costs.
- Generated seed data for Swedish tax tables for 2026, including SQL insert statements.
- Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations.
- Added seed script for populating tax tables in the database.

* feat: Update meal reduction percentages in traktamente calculation

fix: Remove obsolete seed script for 2026 tax tables

feat: Extend SalaryRunStatus type to include 'corrected' status

feat: Implement KU10 XML generation endpoint for annual employee income statements

feat: Add endpoint for creating corrections to booked salary runs

feat: Implement endpoint for sending payslip PDFs to employees

feat: Create KU10 XML generator for annual reporting

feat: Add salary transaction matcher for auto-linking bank transactions to salary entries

chore: Add database migration for salary correction support

* feat: replace select elements with custom Select component for employment and salary types

* feat: enhance salary calculations with pension entry and avgifter category support

* feat: enhance banking settings with PSU type detection and error handling

* fix: improve error handling for bank connection and update access denial message
2026-04-15 13:48:08 +02:00

210 lines
7.3 KiB
TypeScript

import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
/**
* GET /api/extensions/enable-banking/callback
*
* OAuth callback for Enable Banking PSD2 authorization.
* Must be a real Next.js route (not extension handler) because
* banks redirect to this URL directly.
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state') // Cryptographic oauth_state token
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
if (error) {
const errorMessage = errorDescription || error
console.error('[enable-banking] Bank authorization denied', {
error,
error_description: errorDescription,
has_state: !!state,
})
// Clean up the pending bank_connections row so it doesn't accumulate
if (state) {
try {
const supabase = await createServiceClient()
// Fetch connection details for logging before updating
const { data: pendingConn } = await supabase
.from('bank_connections')
.select('id, user_id, bank_name')
.eq('oauth_state', state)
.eq('status', 'pending')
.single()
if (pendingConn) {
console.error('[enable-banking] Authorization denied details', {
connection_id: pendingConn.id,
user_id: pendingConn.user_id,
bank_name: pendingConn.bank_name,
error_code: error,
error_description: errorDescription,
})
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: errorMessage, oauth_state: null })
.eq('id', pendingConn.id)
// Include bank name and error code in redirect so the UI can offer PSU type retry
const params = new URLSearchParams({
bank_error: errorMessage,
...(pendingConn.bank_name ? { bank_name: pendingConn.bank_name } : {}),
...(error === 'access_denied' ? { bank_error_code: error } : {}),
})
return NextResponse.redirect(`${baseUrl}/settings/banking?${params.toString()}`)
}
} catch (cleanupError) {
console.error('[enable-banking] Failed to clean up pending bank connection:', cleanupError)
}
}
return NextResponse.redirect(
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent(errorMessage)}`
)
}
if (!code || !state) {
return NextResponse.redirect(`${baseUrl}/settings/banking?bank_error=missing_parameters`)
}
// Validate authorization code format
const codePattern = /^[a-zA-Z0-9._~+\/-]{8,2048}$/
if (!codePattern.test(code)) {
return NextResponse.redirect(`${baseUrl}/settings/banking?bank_error=invalid_code_format`)
}
const supabase = await createServiceClient()
try {
// Look up pending connection by oauth_state (CSRF-safe)
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id, company_id')
.eq('oauth_state', state)
.eq('status', 'pending')
.single()
if (findError || !pendingConnection) {
console.error('[enable-banking] No pending connection for oauth_state', {
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
state,
hasCode: !!code,
})
return NextResponse.redirect(
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
)
}
const userId = pendingConnection.user_id
const companyId = pendingConnection.company_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
userId,
codeLength: code.length,
})
const sessionData = await createSession(code)
const { session_id, accounts, access } = sessionData
const consentExpiresAt = access.valid_until
console.log('[enable-banking] Session created successfully', {
connectionId: pendingConnection.id,
sessionId: '[REDACTED]',
accountCount: accounts.length,
consentExpiresAt,
})
const accountsWithBalances: StoredAccount[] = await Promise.all(
accounts.map(async (account: AccountInfo) => {
try {
const balance = await getAccountBalance(account.uid)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: balance.amount,
}
} catch (balanceError) {
console.error(`Failed to get balance for account ${account.uid}:`, balanceError)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: undefined,
}
}
})
)
const { error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'active',
accounts_data: accountsWithBalances,
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
oauth_state: null, // Clear to prevent replay
})
.eq('id', pendingConnection.id)
if (updateError) {
console.error('[enable-banking] Failed to update connection after session creation', {
connectionId: pendingConnection.id,
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
sessionId: '[REDACTED]',
})
throw new Error(`Failed to update connection: ${updateError.message}`)
}
const connectionId = pendingConnection.id
const { data: userSettings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('company_id', companyId)
.single()
const redirectTarget = `/settings/banking?bank_connected=true&connection_id=${connectionId}`
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
console.error('[enable-banking] Callback error', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
state,
hasCode: !!code,
})
try {
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: error instanceof Error ? error.message : 'Connection failed', oauth_state: null })
.eq('oauth_state', state)
.eq('status', 'pending')
} catch (cleanupError) {
console.error('[enable-banking] Callback cleanup failed', {
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
return NextResponse.redirect(
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('Connection failed')}`
)
}
}