Files
accounted/app/api/extensions/enable-banking/sync/cron/route.ts
T
Jakob WennbergandClaude Opus 4.6 77a316fec1 feat: move bank details from onboarding to first invoice creation (#140)
* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

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

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

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

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

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

* feat: add support contact links and improve SIE import UX

Add a SupportLink component with a contact dialog throughout the app
(nav, help page, settings, MFA, error pages, empty states). Improve
SIE import flow with phased loading states, structured skip breakdowns,
and an elapsed-time counter. Fix MFA enroll stale factor cleanup and
URL encoding for settings return path.

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

* fix: address PR review — open redirect, XSS, test cleanup, fallback email

- Validate returnTo is a relative path in MFA enroll (prevents open redirect)
- Add afterEach import to event-log-handler tests (fixes handler leak)
- HTML-escape user-supplied subject and message in support email body
- Replace hardcoded personal email with support@gnubok.se fallback

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

* feat: move bank details from onboarding to first invoice creation

Bank details (account, bankgiro, invoice prefix) are now collected
contextually when the user creates their first invoice, rather than
during onboarding where most users skip them. This ensures invoices
always have payment information on the PDF.

- Remove onboarding step 5 (bank details), simplify to 4 steps
- Delete Step6ConnectBank component
- Add BankDetailsSetupDialog with bank account, bankgiro (Luhn),
  IBAN/BIC (collapsible), and invoice prefix fields
- Intercept at "Granska & skapa" for invoice document type only
  (proforma and delivery notes pass through without bank details)
- Show soft info banner on invoice form when bank details are missing
- Add controlled mode (value/onChange) to BankNameCombobox for reuse

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

* fix: address PR review — null-check race, escape key, starting number

- Fix P1: use `hasBankDetails === false` instead of `!hasBankDetails`
  to avoid treating null (loading) state as missing bank details
- Fix P2: remove onEscapeKeyDown override so keyboard users can
  dismiss the dialog (WCAG AA compliance)
- Add starting invoice number field alongside prefix, so users can
  choose e.g. starting at 14 instead of 1

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

* feat: skip auto-categorization during bank sync when SIE overlap detected

Prevents double-booking when bank transactions are synced for a period
that already has journal entries from a SIE import. Reconciliation still
links transactions to existing GL lines; only new journal entry creation
is suppressed. A batch reconciliation sweep runs post-sync to catch
additional matches.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:28:15 +01:00

319 lines
10 KiB
TypeScript

import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
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'
ensureInitialized()
/**
* GET /api/extensions/enable-banking/sync/cron
* Automatic daily bank transaction sync
* Runs at 05:00 UTC (07:00 Swedish time)
*
* 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.
*/
export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Clean up stale pending connections (older than 1 hour)
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
const { data: stalePending } = await supabase
.from('bank_connections')
.delete()
.eq('status', 'pending')
.lt('created_at', oneHourAgo)
.select('id')
if (stalePending?.length) {
console.log(`[bank-sync-cron] Cleaned up ${stalePending.length} stale pending connections`)
}
const { data: connections, error: connError } = await supabase
.from('bank_connections')
.select('*')
.eq('status', 'active')
.order('last_synced_at', { ascending: true, nullsFirst: true })
.limit(50)
if (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 })
}
if (!connections || connections.length === 0) {
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
}
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
userId: string
bankName: string
imported: number
duplicates: number
errors: number
status: 'synced' | 'expired' | 'expiring_soon' | 'error'
daysUntilExpiry?: number | null
}[] = []
for (const connection of connections) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log(`[bank-sync-cron] Time budget reached after ${results.length} connections`)
break
}
try {
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
const isExpired = daysLeft !== null && daysLeft <= 0
if (isExpired) {
await supabase
.from('bank_connections')
.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,
bankName: connection.bank_name,
imported: 0,
duplicates: 0,
errors: 0,
status: 'expired',
daysUntilExpiry: 0,
})
continue
}
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()
.split('T')[0]
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
// Detect SIE overlap — skip auto-categorization if the sync range
// overlaps with a completed SIE import to prevent double-booking
const { data: sieOverlap } = await supabase
.from('sie_imports')
.select('id')
.eq('user_id', connection.user_id)
.eq('status', 'completed')
.gte('fiscal_year_end', fromDate)
.limit(1)
.maybeSingle()
const syncOptions = sieOverlap
? { skipAutoCategorization: true }
: undefined
const syncResults = await Promise.all(
accounts.map(account => syncAccountTransactions(
supabase,
connection.user_id,
connection.id,
account,
fromDate,
toDate,
undefined,
syncOptions
))
)
const totalImported = syncResults.reduce((sum, r) => sum + r.imported, 0)
const totalDuplicates = syncResults.reduce((sum, r) => sum + r.duplicates, 0)
const totalErrors = syncResults.reduce((sum, r) => sum + r.errors, 0)
// Batch reconciliation sweep when SIE overlap detected
if (sieOverlap && totalImported > 0) {
try {
await runReconciliation(supabase, connection.user_id, {
dateFrom: fromDate,
dateTo: toDate,
})
} catch {
// Non-critical
}
}
// 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)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: totalImported,
duplicates: totalDuplicates,
errors: totalErrors,
status: expiringSoon ? 'expiring_soon' : 'synced',
daysUntilExpiry: daysLeft,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown 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
.from('bank_connections')
.update({ status: 'error', error_message: message })
.eq('id', connection.id)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: 0,
duplicates: 0,
errors: 1,
status: 'error',
})
}
}
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
const totalExpired = results.filter(r => r.status === 'expired').length
const totalExpiringSoon = results.filter(r => r.status === 'expiring_soon').length
const totalFailed = results.filter(r => r.status === 'error').length
console.log(`[bank-sync-cron] Processed ${results.length} connections: ${totalImported} imported, ${totalExpired} expired, ${totalExpiringSoon} expiring soon, ${totalFailed} failed`)
return NextResponse.json({
processed: results.length,
totalImported,
totalExpired,
totalExpiringSoon,
totalFailed,
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<any>,
connection: Record<string, unknown>,
daysLeft: number,
isExpired: boolean,
baseUrl: string
): Promise<void> {
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)
}
}