* 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
210 lines
7.3 KiB
TypeScript
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')}`
|
|
)
|
|
}
|
|
}
|