import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database } from '@kit/supabase/database'; import type { CreateSepaBatchInput, AddSepaItemInput, CreateInvoiceInput, } from '../schema/finance.schema'; import { generateDirectDebitXml, generateCreditTransferXml, validateIban, } from './services/sepa-xml-generator.service'; /* eslint-disable @typescript-eslint/no-explicit-any */ export function createFinanceApi(client: SupabaseClient) { const db = client; return { // --- SEPA Batches --- async listBatches( accountId: string, opts?: { search?: string; status?: string; page?: number; pageSize?: number; }, ) { const page = opts?.page ?? 1; const pageSize = opts?.pageSize ?? 25; let query = client .from('sepa_batches') .select('*', { count: 'exact' }) .eq('account_id', accountId) .order('created_at', { ascending: false }); if (opts?.search) { query = query.ilike('description', `%${opts.search}%`); } if (opts?.status) { query = query.eq('status', opts.status); } query = query.range((page - 1) * pageSize, page * pageSize - 1); const { data, error, count } = await query; if (error) throw error; return { data: data ?? [], total: count ?? 0, page, pageSize, totalPages: Math.ceil((count ?? 0) / pageSize), }; }, async getBatch(batchId: string) { const { data, error } = await client .from('sepa_batches') .select('*') .eq('id', batchId) .single(); if (error) throw error; return data; }, async createBatch(input: CreateSepaBatchInput, userId: string) { const { data, error } = await client .from('sepa_batches') .insert({ account_id: input.accountId, batch_type: input.batchType, description: input.description, execution_date: input.executionDate, pain_format: input.painFormat, created_by: userId, }) .select() .single(); if (error) throw error; return data; }, async addItem(input: AddSepaItemInput) { // Validate IBAN if (!validateIban(input.debtorIban)) { throw new Error(`Invalid IBAN: ${input.debtorIban}`); } const { data, error } = await client .from('sepa_items') .insert({ batch_id: input.batchId, member_id: input.memberId, debtor_name: input.debtorName, debtor_iban: input.debtorIban.replace(/\s/g, '').toUpperCase(), debtor_bic: input.debtorBic, amount: input.amount, mandate_id: input.mandateId, mandate_date: input.mandateDate, remittance_info: input.remittanceInfo, }) .select() .single(); if (error) throw error; // Update batch totals await this.recalculateBatchTotals(input.batchId); return data; }, async getBatchItems(batchId: string) { const { data, error } = await client .from('sepa_items') .select('*') .eq('batch_id', batchId) .order('created_at'); if (error) throw error; return data ?? []; }, async recalculateBatchTotals(batchId: string) { const items = await this.getBatchItems(batchId); const total = items.reduce( (sum: number, i: any) => sum + Number(i.amount), 0, ); await client .from('sepa_batches') .update({ total_amount: total, item_count: items.length, }) .eq('id', batchId); }, async generateSepaXml( batchId: string, creditor: { name: string; iban: string; bic: string; creditorId: string }, ) { const batch = await this.getBatch(batchId); const items = await this.getBatchItems(batchId); const config = { messageId: `MSG-${batchId.slice(0, 8)}-${Date.now()}`, creationDateTime: new Date().toISOString(), executionDate: batch.execution_date, creditor, transactions: items.map((item: any) => ({ debtorName: item.debtor_name, debtorIban: item.debtor_iban, debtorBic: item.debtor_bic, amount: Number(item.amount), mandateId: item.mandate_id || `MNDT-${item.id.slice(0, 8)}`, mandateDate: item.mandate_date || batch.execution_date, remittanceInfo: item.remittance_info || 'SEPA Einzug', })), }; const xml = batch.batch_type === 'direct_debit' ? generateDirectDebitXml(config) : generateCreditTransferXml(config); // Update batch status await client .from('sepa_batches') .update({ status: 'ready' }) .eq('id', batchId); return xml; }, // --- Invoices --- async listInvoices( accountId: string, opts?: { search?: string; status?: string; page?: number; pageSize?: number; }, ) { const page = opts?.page ?? 1; const pageSize = opts?.pageSize ?? 25; let query = client .from('invoices') .select('*', { count: 'exact' }) .eq('account_id', accountId) .order('issue_date', { ascending: false }); if (opts?.status) { query = query.eq( 'status', opts.status as Database['public']['Enums']['invoice_status'], ); } if (opts?.search) { query = query.or( `invoice_number.ilike.%${opts.search}%,recipient_name.ilike.%${opts.search}%`, ); } query = query.range((page - 1) * pageSize, page * pageSize - 1); const { data, error, count } = await query; if (error) throw error; return { data: data ?? [], total: count ?? 0, page, pageSize, totalPages: Math.ceil((count ?? 0) / pageSize), }; }, async createInvoice(input: CreateInvoiceInput, userId: string) { const subtotal = input.items.reduce( (sum, item) => sum + item.quantity * item.unitPrice, 0, ); const taxAmount = subtotal * (input.taxRate / 100); const totalAmount = subtotal + taxAmount; const { data: invoice, error: invoiceError } = await client .from('invoices') .insert({ account_id: input.accountId, invoice_number: input.invoiceNumber, member_id: input.memberId, recipient_name: input.recipientName, recipient_address: input.recipientAddress, issue_date: input.issueDate, due_date: input.dueDate, status: 'draft', subtotal, tax_rate: input.taxRate, tax_amount: taxAmount, total_amount: totalAmount, notes: input.notes, created_by: userId, }) .select() .single(); if (invoiceError) throw invoiceError; // Insert line items const items = input.items.map((item, i) => ({ invoice_id: invoice.id, description: item.description, quantity: item.quantity, unit_price: item.unitPrice, total_price: item.quantity * item.unitPrice, sort_order: i, })); const { error: itemsError } = await client .from('invoice_items') .insert(items); if (itemsError) throw itemsError; return invoice; }, async getInvoiceWithItems(invoiceId: string) { const { data, error } = await client .from('invoices') .select('*') .eq('id', invoiceId) .single(); if (error) throw error; const { data: items } = await client .from('invoice_items') .select('*') .eq('invoice_id', invoiceId) .order('sort_order'); return { ...data, items: items ?? [] }; }, // --- SEPA auto-populate from members (Gap 3) --- async populateBatchFromMembers(batchId: string, accountId: string) { // Get all active members with active SEPA mandates + dues categories const { data: members, error: memberError } = await client .from('members') .select('id, first_name, last_name, dues_category_id') .eq('account_id', accountId) .eq('status', 'active'); if (memberError) throw memberError; const { data: mandates, error: mandateError } = await client .from('sepa_mandates') .select('*') .eq('account_id', accountId) .eq('status', 'active') .eq('is_primary', true); if (mandateError) throw mandateError; const { data: categories, error: catError } = await client .from('dues_categories') .select('id, amount') .eq('account_id', accountId); if (catError) throw catError; const mandateMap = new Map( (mandates ?? []).map((m: any) => [m.member_id, m]), ); const categoryMap = new Map( (categories ?? []).map((c: any) => [c.id, Number(c.amount)]), ); let addedCount = 0; for (const member of (members ?? []) as any[]) { const mandate = mandateMap.get(member.id); if (!mandate) continue; const amount = member.dues_category_id ? (categoryMap.get(member.dues_category_id) ?? 0) : 0; if (amount <= 0) continue; const { error } = await client.from('sepa_items').insert({ batch_id: batchId, member_id: member.id, debtor_name: `${member.first_name} ${member.last_name}`, debtor_iban: mandate.iban, debtor_bic: mandate.bic, amount, mandate_id: mandate.mandate_reference, mandate_date: mandate.mandate_date, remittance_info: `Mitgliedsbeitrag ${new Date().getFullYear()}`, }); if (!error) addedCount++; } await this.recalculateBatchTotals(batchId); return { addedCount }; }, // --- Utilities --- validateIban, }; }