350 lines
9.9 KiB
TypeScript
350 lines
9.9 KiB
TypeScript
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<Database>) {
|
|
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,
|
|
};
|
|
}
|