diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 34a46fde..6edddbc6 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -51,6 +51,8 @@ export default async function DashboardPage() { { data: entriesWithDocs }, { data: recentReceiptActivity }, { data: enabledToggles }, + { count: sieImportCount }, + { count: staleUncategorizedCount }, ] = await Promise.all([ supabase.from('profiles').select('full_name').eq('id', user.id).single(), supabase.from('company_settings').select('*').eq('user_id', user.id).single(), @@ -74,6 +76,8 @@ export default async function DashboardPage() { supabase.from('document_attachments').select('journal_entry_id').eq('user_id', user.id).eq('is_current_version', true).not('journal_entry_id', 'is', null), supabase.from('receipts').select('created_at').eq('user_id', user.id).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30), supabase.from('extension_toggles').select('sector_slug, extension_slug').eq('user_id', user.id).eq('enabled', true), + supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('status', 'completed'), + supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('user_id', user.id).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]), ]) const firstName = profile?.full_name?.split(' ')[0] || null @@ -82,6 +86,7 @@ export default async function DashboardPage() { hasCustomers: (customerCount || 0) > 0, hasInvoices: (invoiceCount || 0) > 0, hasBankConnected: (transactionCount || 0) > 0, + hasSIEImport: (sieImportCount || 0) > 0, } // Calculate totals from journal entry lines using account classes @@ -215,6 +220,7 @@ export default async function DashboardPage() { deadlines: (deadlines || []) as Deadline[], receiptQueue, missingUnderlagCount, + staleUncategorizedCount: staleUncategorizedCount || 0, }} onboardingProgress={onboardingProgress} enabledExtensions={enabledToggles || []} diff --git a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts new file mode 100644 index 00000000..bc8126b3 --- /dev/null +++ b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock dependencies — factory must not reference outer variables +const mockCreateSession = vi.fn() +const mockGetAccountBalance = vi.fn() +vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({ + createSession: (...args: unknown[]) => mockCreateSession(...args), + getAccountBalance: (...args: unknown[]) => mockGetAccountBalance(...args), +})) + +// Use hoisted to safely create mock objects referenced in vi.mock factories +const { mockFrom } = vi.hoisted(() => { + const mockFrom = vi.fn() + return { mockFrom } +}) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: vi.fn().mockResolvedValue({ + from: mockFrom, + }), +})) + +vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') + +import { GET } from '../route' + +function makeRequest(params: Record) { + const url = new URL('http://localhost:3000/api/extensions/enable-banking/callback') + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v) + } + return new Request(url.toString()) +} + +function mockChain(result: { data?: unknown; error?: unknown }) { + const chain: Record = {} + for (const m of ['select', 'eq', 'single', 'update', 'order', 'limit']) { + chain[m] = vi.fn().mockReturnValue(chain) + } + chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null }) + // For chains ending without .single() + chain.then = (resolve: (v: unknown) => void) => resolve({ data: result.data ?? null, error: result.error ?? null }) + return chain +} + +describe('GET /api/extensions/enable-banking/callback', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects when state does not match any pending connection', async () => { + mockFrom.mockImplementation(() => + mockChain({ data: null, error: { message: 'not found' } }) + ) + + const response = await GET(makeRequest({ code: 'auth-code', state: 'unknown-state' })) + + expect(response.status).toBe(307) + const location = response.headers.get('location') || '' + expect(location).toContain('bank_error=invalid_state') + }) + + it('activates connection and clears oauth_state on success', async () => { + let callIndex = 0 + mockFrom.mockImplementation(() => { + callIndex++ + if (callIndex === 1) { + // Find pending connection by oauth_state + return mockChain({ data: { id: 'conn-1', user_id: 'user-1' }, error: null }) + } + if (callIndex === 2) { + // Update connection + return mockChain({ data: null, error: null }) + } + // Company settings lookup + return mockChain({ data: { onboarding_complete: true }, error: null }) + }) + + mockCreateSession.mockResolvedValue({ + session_id: 'sess-1', + accounts: [], + access: { valid_until: '2024-12-31T00:00:00Z' }, + aspsp: { name: 'TestBank', country: 'SE' }, + }) + + const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' })) + + expect(response.status).toBe(307) + const location = response.headers.get('location') || '' + expect(location).toContain('bank_connected=true') + expect(location).toContain('connection_id=conn-1') + }) + + it('redirects with error when bank returns error param', async () => { + const response = await GET(makeRequest({ error: 'access_denied', error_description: 'User cancelled' })) + + expect(response.status).toBe(307) + const location = response.headers.get('location') || '' + expect(location).toContain('bank_error=User%20cancelled') + }) + + it('redirects with error when code or state is missing', async () => { + const response = await GET(makeRequest({ code: 'auth-code' })) + + expect(response.status).toBe(307) + const location = response.headers.get('location') || '' + expect(location).toContain('bank_error=missing_parameters') + }) +}) diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index f221f93e..3e6337d4 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -14,7 +14,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const code = searchParams.get('code') - const state = searchParams.get('state') // This is the user_id we passed during authorization + const state = searchParams.get('state') // Cryptographic oauth_state token const error = searchParams.get('error') const errorDescription = searchParams.get('error_description') @@ -35,8 +35,25 @@ export async function GET(request: Request) { 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') + .eq('oauth_state', state) + .eq('status', 'pending') + .single() + + if (findError || !pendingConnection) { + console.error('No pending connection for oauth_state:', findError) + return NextResponse.redirect( + `${baseUrl}/settings?bank_error=${encodeURIComponent('invalid_state')}` + ) + } + + const userId = pendingConnection.user_id + const sessionData = await createSession(code) - const { session_id, accounts, access, aspsp } = sessionData + const { session_id, accounts, access } = sessionData const consentExpiresAt = access.valid_until const accountsWithBalances: StoredAccount[] = await Promise.all( @@ -63,61 +80,28 @@ export async function GET(request: Request) { }) ) - const { data: pendingConnection, error: findError } = await supabase + const { error: updateError } = await supabase .from('bank_connections') - .select('id') - .eq('user_id', state) - .eq('status', 'pending') - .order('created_at', { ascending: false }) - .limit(1) - .single() + .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) - let connectionId: string - - if (findError || !pendingConnection) { - console.error('Could not find pending connection:', findError) - const { data: inserted, error: insertError } = await supabase - .from('bank_connections') - .insert({ - user_id: state, - provider: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`, - bank_name: aspsp.name, - session_id, - status: 'active', - accounts_data: accountsWithBalances, - consent_expires: consentExpiresAt, - last_synced_at: new Date().toISOString(), - }) - .select('id') - .single() - - if (insertError || !inserted) { - console.error('Insert error:', insertError) - throw new Error('Failed to create connection') - } - connectionId = inserted.id - } else { - 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(), - }) - .eq('id', pendingConnection.id) - - if (updateError) { - throw new Error('Failed to update connection') - } - connectionId = pendingConnection.id + if (updateError) { + throw new Error('Failed to update connection') } + const connectionId = pendingConnection.id + const { data: userSettings } = await supabase .from('company_settings') .select('onboarding_complete') - .eq('user_id', state) + .eq('user_id', userId) .single() const redirectTarget = userSettings?.onboarding_complete @@ -131,8 +115,8 @@ export async function GET(request: Request) { try { await supabase .from('bank_connections') - .update({ status: 'error' }) - .eq('user_id', state) + .update({ status: 'error', oauth_state: null }) + .eq('oauth_state', state) .eq('status', 'pending') } catch { // Ignore cleanup errors diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 2a9cf790..830ee6e5 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -1,7 +1,13 @@ -import { createClient } from '@supabase/supabase-js' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync' import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client' +import { getEmailService } from '@/lib/email/service' +import { + generateConsentExpiryEmailHtml, + generateConsentExpiryEmailText, + generateConsentExpiryEmailSubject, +} from '@/lib/email/consent-notification-templates' import { ensureInitialized } from '@/lib/init' import type { StoredAccount } from '@/extensions/general/enable-banking/types' @@ -12,7 +18,7 @@ ensureInitialized() * Automatic daily bank transaction sync * Runs at 05:00 UTC (07:00 Swedish time) * - * Processes up to 10 connections per run (Vercel Hobby 60s timeout). + * Processes up to 50 connections per run (Vercel Pro 300s timeout). * Prioritizes connections not synced for the longest time. * Deduplication via external_id makes repeated runs safe. */ @@ -68,6 +74,7 @@ export async function GET(request: Request) { const startTime = Date.now() const TIME_BUDGET_MS = 50_000 // 50s — leave 10s margin for Vercel timeout + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' const results: { connectionId: string @@ -96,6 +103,11 @@ export async function GET(request: Request) { .update({ status: 'expired' }) .eq('id', connection.id) + // Send expiry notification + await sendConsentExpiryNotification( + supabase, connection, 0, true, baseUrl + ) + results.push({ connectionId: connection.id, userId: connection.user_id, @@ -111,6 +123,13 @@ export async function GET(request: Request) { const expiringSoon = isConsentExpiringSoon(connection.consent_expires) + // Send consent expiry notifications at 7-day and 3-day thresholds + if (expiringSoon && daysLeft !== null && (daysLeft <= 3 || daysLeft === 7)) { + await sendConsentExpiryNotification( + supabase, connection, daysLeft, false, baseUrl + ) + } + const toDate = new Date().toISOString().split('T')[0] const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) .toISOString() @@ -133,11 +152,13 @@ export async function GET(request: Request) { const totalDuplicates = syncResults.reduce((sum, r) => sum + r.duplicates, 0) const totalErrors = syncResults.reduce((sum, r) => sum + r.errors, 0) + // Successful sync: update connection and clear any previous error state await supabase .from('bank_connections') .update({ accounts_data: accounts, last_synced_at: new Date().toISOString(), + ...(connection.error_message ? { error_message: null } : {}), }) .eq('id', connection.id) @@ -152,7 +173,15 @@ export async function GET(request: Request) { daysUntilExpiry: daysLeft, }) } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' console.error(`Sync failed for connection ${connection.id}:`, error) + + // Persist error status on sync failure + await supabase + .from('bank_connections') + .update({ status: 'error', error_message: message }) + .eq('id', connection.id) + results.push({ connectionId: connection.id, userId: connection.user_id, @@ -181,3 +210,65 @@ export async function GET(request: Request) { results, }) } + +/** + * Send consent expiry notification email. + * Guards with last_expiry_notification_at to avoid spamming (2-day cooldown). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function sendConsentExpiryNotification( + supabase: SupabaseClient, + connection: Record, + daysLeft: number, + isExpired: boolean, + baseUrl: string +): Promise { + try { + // Check cooldown: skip if notified within last 2 days + const lastNotified = connection.last_expiry_notification_at as string | null + if (lastNotified) { + const hoursSinceNotified = (Date.now() - new Date(lastNotified).getTime()) / (1000 * 60 * 60) + if (hoursSinceNotified < 48) return + } + + const emailService = getEmailService() + if (!emailService.isConfigured()) return + + const userId = connection.user_id as string + + // Look up user email + const { data: userData } = await supabase.auth.admin.getUserById(userId) + if (!userData?.user?.email) return + + // Look up company name + const { data: companySettings } = await supabase + .from('company_settings') + .select('company_name') + .eq('user_id', userId) + .single() + + const emailData = { + bankName: connection.bank_name as string, + daysUntilExpiry: daysLeft, + renewalUrl: `${baseUrl}/settings?tab=banking`, + companyName: companySettings?.company_name || 'gnubok', + isExpired, + } + + await emailService.sendEmail({ + to: userData.user.email, + subject: generateConsentExpiryEmailSubject(emailData), + html: generateConsentExpiryEmailHtml(emailData), + text: generateConsentExpiryEmailText(emailData), + }) + + // Update last notification timestamp + await supabase + .from('bank_connections') + .update({ last_expiry_notification_at: new Date().toISOString() }) + .eq('id', connection.id as string) + } catch (error) { + // Notification failure must not break the cron job + console.error(`[bank-sync-cron] Failed to send consent expiry notification:`, error) + } +} diff --git a/app/api/import/sie/parse/route.ts b/app/api/import/sie/parse/route.ts index 667d8f51..684377ff 100644 --- a/app/api/import/sie/parse/route.ts +++ b/app/api/import/sie/parse/route.ts @@ -57,7 +57,7 @@ export async function POST(request: Request) { if (duplicate) { return NextResponse.json({ error: 'duplicate', - message: `This file has already been imported on ${new Date(duplicate.imported_at!).toLocaleDateString('sv-SE')}`, + message: `This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`, importId: duplicate.id, }, { status: 409 }) } diff --git a/app/layout.tsx b/app/layout.tsx index e2bd8884..478b4638 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -46,7 +46,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +