Files
myeasycms-v2/packages/features/finance/src/server/api.ts
T. Zehetbauer 7b078f298b
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m50s
Workflow / ⚫️ Test (push) Has been skipped
feat: enhance API response handling and add new components for module management
2026-04-01 15:18:24 +02:00

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,
};
}