144 lines
3.6 KiB
TypeScript
144 lines
3.6 KiB
TypeScript
import { SupabaseClient } from '@supabase/supabase-js'
|
|
import { getTransactions, getAccountBalance } from '@/lib/banking/enable-banking'
|
|
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
|
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
|
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
|
|
import type { Transaction } from '@/types'
|
|
|
|
interface StoredAccount {
|
|
uid: string
|
|
iban?: string
|
|
name?: string
|
|
currency: string
|
|
balance?: number
|
|
}
|
|
|
|
export interface SyncResult {
|
|
imported: number
|
|
duplicates: number
|
|
errors: number
|
|
}
|
|
|
|
/**
|
|
* Sync transactions for a single bank account
|
|
* Shared logic used by both manual sync and cron job
|
|
*/
|
|
export async function syncAccountTransactions(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
connectionId: string,
|
|
account: StoredAccount,
|
|
fromDate: string,
|
|
toDate: string
|
|
): Promise<SyncResult> {
|
|
const result: SyncResult = { imported: 0, duplicates: 0, errors: 0 }
|
|
|
|
const bankTransactions = await getTransactions(
|
|
account.uid,
|
|
fromDate,
|
|
toDate,
|
|
account.currency
|
|
)
|
|
|
|
for (const tx of bankTransactions) {
|
|
const externalId = `${connectionId}_${tx.id}`
|
|
|
|
// Check for duplicates
|
|
const { data: existing } = await supabase
|
|
.from('transactions')
|
|
.select('id')
|
|
.eq('user_id', userId)
|
|
.eq('external_id', externalId)
|
|
.single()
|
|
|
|
if (existing) {
|
|
result.duplicates++
|
|
continue
|
|
}
|
|
|
|
// Insert new transaction
|
|
const { data: newTransaction, error: insertError } = await supabase
|
|
.from('transactions')
|
|
.insert({
|
|
user_id: userId,
|
|
bank_connection_id: connectionId,
|
|
external_id: externalId,
|
|
date: tx.booking_date || tx.date,
|
|
description: tx.description || tx.counterparty_name || 'Unknown',
|
|
amount: tx.amount,
|
|
currency: tx.currency || account.currency,
|
|
category: 'uncategorized',
|
|
is_business: null,
|
|
mcc_code: tx.merchant_category_code || null,
|
|
merchant_name: tx.counterparty_name || null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (insertError || !newTransaction) {
|
|
result.errors++
|
|
continue
|
|
}
|
|
|
|
result.imported++
|
|
|
|
// For income transactions, try to find matching invoices
|
|
if (newTransaction.amount > 0) {
|
|
try {
|
|
const bestMatch = await getBestInvoiceMatch(
|
|
userId,
|
|
newTransaction as Transaction,
|
|
0.50
|
|
)
|
|
|
|
if (bestMatch) {
|
|
await supabase
|
|
.from('transactions')
|
|
.update({ potential_invoice_id: bestMatch.invoice.id })
|
|
.eq('id', newTransaction.id)
|
|
}
|
|
} catch {
|
|
// Non-critical
|
|
}
|
|
}
|
|
|
|
// Evaluate mapping rules for auto-categorization
|
|
try {
|
|
const mappingResult = await evaluateMappingRules(
|
|
userId,
|
|
newTransaction as Transaction
|
|
)
|
|
|
|
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
|
|
const journalEntry = await createTransactionJournalEntry(
|
|
userId,
|
|
newTransaction as Transaction,
|
|
mappingResult
|
|
)
|
|
|
|
if (journalEntry) {
|
|
await supabase
|
|
.from('transactions')
|
|
.update({
|
|
journal_entry_id: journalEntry.id,
|
|
is_business: !mappingResult.default_private,
|
|
})
|
|
.eq('id', newTransaction.id)
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-critical
|
|
}
|
|
}
|
|
|
|
// Update account balance
|
|
try {
|
|
const balance = await getAccountBalance(account.uid)
|
|
account.balance = balance.amount
|
|
} catch {
|
|
// Ignore balance fetch errors
|
|
}
|
|
|
|
return result
|
|
}
|