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:
co-authored by
Claude Opus 4.6
parent
4d4a9a40c9
commit
c1630068b3
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user