Feature/quick review scroll (#110)

* fix: allow scrolling in QuickReviewDialog when content overflows viewport

The dialog had no max-height or overflow handling, so tall content (e.g.
extern representation with warnings + journal preview) was clipped with
no way to scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add detailed error logging to Enable Banking integration

Adds structured console logging with full context (status codes,
response bodies, connection IDs, account UIDs, stack traces) to all
Enable Banking operations: connect, callback, sync, disconnect,
and the API client retry/error paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add detailed error logging to login and register flows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: implement KPI settings management with preferences and dynamic KPI display

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-03-23 20:04:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4d4a9a40c9
commit c1630068b3
15 changed files with 1097 additions and 130 deletions
@@ -81,7 +81,11 @@ export async function GET(request: Request) {
.single()
if (findError || !pendingConnection) {
console.error('No pending connection for oauth_state:', findError)
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?bank_error=${encodeURIComponent('invalid_state')}`
)
@@ -89,10 +93,23 @@ export async function GET(request: Request) {
const userId = pendingConnection.user_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: session_id,
accountCount: accounts.length,
consentExpiresAt,
})
const accountsWithBalances: StoredAccount[] = await Promise.all(
accounts.map(async (account: AccountInfo) => {
try {
@@ -130,7 +147,12 @@ export async function GET(request: Request) {
.eq('id', pendingConnection.id)
if (updateError) {
throw new Error('Failed to update connection')
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: session_id,
})
throw new Error(`Failed to update connection: ${updateError.message}`)
}
const connectionId = pendingConnection.id
@@ -147,16 +169,24 @@ export async function GET(request: Request) {
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
console.error('Bank callback error:', 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', oauth_state: null })
.update({ status: 'error', error_message: error instanceof Error ? error.message : 'Connection failed', oauth_state: null })
.eq('oauth_state', state)
.eq('status', 'pending')
} catch {
// Ignore cleanup errors
} catch (cleanupError) {
console.error('[enable-banking] Callback cleanup failed', {
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
return NextResponse.redirect(
@@ -64,7 +64,11 @@ export async function GET(request: Request) {
.limit(50)
if (connError) {
console.error('Failed to fetch bank connections:', connError)
console.error('[bank-sync-cron] Failed to fetch bank connections', {
message: connError.message,
code: connError.code,
details: connError.details,
})
return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 })
}
@@ -174,7 +178,17 @@ export async function GET(request: Request) {
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error(`Sync failed for connection ${connection.id}:`, error)
console.error('[bank-sync-cron] Sync failed for connection', {
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
sessionId: connection.session_id,
consentExpires: connection.consent_expires,
lastSyncedAt: connection.last_synced_at,
message,
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
})
// Persist error status on sync failure
await supabase
+81
View File
@@ -0,0 +1,81 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import type { KPIPreferences } from '@/types'
const EXTENSION_ID = 'core/kpi'
const KEY = 'preferences'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('extension_id', EXTENSION_ID)
.eq('key', KEY)
.single()
const preferences = mergeWithDefaults((data?.value as Partial<KPIPreferences>) ?? {})
return NextResponse.json({ data: preferences })
}
export async function PUT(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const prefs = body as Partial<KPIPreferences>
// Validate account overrides — must be 4-digit numeric strings
if (prefs.accountOverrides) {
for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) {
if (!Array.isArray(accounts)) {
return NextResponse.json(
{ error: `accountOverrides.${kpiId} must be an array` },
{ status: 400 }
)
}
for (const acc of accounts) {
if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) {
return NextResponse.json(
{ error: `Invalid account number "${acc}" in ${kpiId} — must be 4 digits` },
{ status: 400 }
)
}
}
}
}
const merged = mergeWithDefaults(prefs)
const { data, error } = await supabase
.from('extension_data')
.upsert(
{
user_id: user.id,
extension_id: EXTENSION_ID,
key: KEY,
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: data.value })
}
+75 -14
View File
@@ -4,8 +4,14 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { calculateCashPosition } from '@/lib/reports/kpi'
import type { KPIReport } from '@/types'
import {
calculateCashPosition,
calculateGrossMargin,
calculateExpenseRatio,
calculateAvgPaymentDays,
} from '@/lib/reports/kpi'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import type { KPIReport, KPIPreferences } from '@/types'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -29,32 +35,87 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown] =
// Load user preferences for account overrides
const { data: prefsData } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', user.id)
.eq('extension_id', 'core/kpi')
.eq('key', 'preferences')
.single()
const preferences = mergeWithDefaults(
(prefsData?.value as Partial<KPIPreferences>) ?? {}
)
const [incomeStatement, trialBalanceResult, arLedger, monthlyBreakdown, paidInvoicesResult] =
await Promise.all([
generateIncomeStatement(supabase, user.id, periodId),
generateTrialBalance(supabase, user.id, periodId),
generateARLedger(supabase, user.id),
generateMonthlyBreakdown(supabase, user.id, periodId),
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('user_id', user.id)
.eq('status', 'paid')
.not('paid_at', 'is', null),
])
// VAT liability from trial balance (output VAT - input VAT)
const vatOutputAccounts = ['2611', '2621', '2631']
const vatInputAccounts = ['2641', '2645']
const outputVat = trialBalanceResult.rows
.filter((r) => vatOutputAccounts.includes(r.account_number))
.reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0)
const inputVat = trialBalanceResult.rows
.filter((r) => vatInputAccounts.includes(r.account_number))
.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0)
// Cash position — use account overrides if set
const cashOverrides = preferences.accountOverrides['cashPosition']
let cashPosition: number
if (cashOverrides && cashOverrides.length > 0) {
const cashRows = trialBalanceResult.rows.filter((r) =>
cashOverrides.includes(r.account_number)
)
cashPosition = Math.round(
cashRows.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) * 100
) / 100
} else {
cashPosition = calculateCashPosition(trialBalanceResult.rows)
}
// VAT liability — use account overrides if set
const vatOverrides = preferences.accountOverrides['vatLiability']
let vatLiability: number
if (vatOverrides && vatOverrides.length > 0) {
const outputVat = trialBalanceResult.rows
.filter((r) => vatOverrides.includes(r.account_number) && r.account_number.startsWith('26') && !r.account_number.startsWith('264'))
.reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0)
const inputVat = trialBalanceResult.rows
.filter((r) => vatOverrides.includes(r.account_number) && r.account_number.startsWith('264'))
.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0)
vatLiability = Math.round((outputVat - inputVat) * 100) / 100
} else {
const vatOutputAccounts = ['2611', '2621', '2631']
const vatInputAccounts = ['2641', '2645']
const outputVat = trialBalanceResult.rows
.filter((r) => vatOutputAccounts.includes(r.account_number))
.reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0)
const inputVat = trialBalanceResult.rows
.filter((r) => vatInputAccounts.includes(r.account_number))
.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0)
vatLiability = Math.round((outputVat - inputVat) * 100) / 100
}
// Avg payment days from paid invoices
const paidInvoices = (paidInvoicesResult.data ?? []).map((inv) => ({
invoice_date: inv.invoice_date as string,
paid_at: inv.paid_at as string,
}))
const report: KPIReport = {
netResult: incomeStatement.net_result,
cashPosition: calculateCashPosition(trialBalanceResult.rows),
cashPosition,
outstandingReceivables: arLedger.total_outstanding,
overdueReceivables: arLedger.total_overdue,
vatLiability: Math.round((outputVat - inputVat) * 100) / 100,
vatLiability,
totalRevenue: incomeStatement.total_revenue,
totalExpenses: incomeStatement.total_expenses,
grossMargin: calculateGrossMargin(incomeStatement),
expenseRatio: calculateExpenseRatio(incomeStatement),
avgPaymentDays: calculateAvgPaymentDays(paidInvoices),
periodComplete: period.is_closed,
months: monthlyBreakdown.months,
period: { start: period.period_start, end: period.period_end },