Initial state for GitNexus analysis

This commit is contained in:
Zaid Marzguioui
2026-03-29 19:44:57 +02:00
parent 9d7c7f8030
commit 61ff48cb73
155 changed files with 23483 additions and 1722 deletions

View File

@@ -0,0 +1,34 @@
{
"name": "@kit/finance",
"version": "0.1.0",
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@types/react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,45 @@
import { z } from 'zod';
export const SepaBatchTypeEnum = z.enum(['direct_debit', 'credit_transfer']);
export const SepaBatchStatusEnum = z.enum(['draft', 'ready', 'submitted', 'executed', 'failed', 'cancelled']);
export const InvoiceStatusEnum = z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited']);
export const CreateSepaBatchSchema = z.object({
accountId: z.string().uuid(),
batchType: SepaBatchTypeEnum,
description: z.string().optional(),
executionDate: z.string(),
painFormat: z.string().default('pain.008.003.02'),
});
export type CreateSepaBatchInput = z.infer<typeof CreateSepaBatchSchema>;
export const AddSepaItemSchema = z.object({
batchId: z.string().uuid(),
memberId: z.string().uuid().optional(),
debtorName: z.string().min(1),
debtorIban: z.string().min(15).max(34),
debtorBic: z.string().optional(),
amount: z.number().min(0.01),
mandateId: z.string().optional(),
mandateDate: z.string().optional(),
remittanceInfo: z.string().optional(),
});
export type AddSepaItemInput = z.infer<typeof AddSepaItemSchema>;
export const CreateInvoiceSchema = z.object({
accountId: z.string().uuid(),
invoiceNumber: z.string().min(1),
memberId: z.string().uuid().optional(),
recipientName: z.string().min(1),
recipientAddress: z.string().optional(),
issueDate: z.string().default(() => new Date().toISOString().split('T')[0]!),
dueDate: z.string(),
taxRate: z.number().min(0).default(0),
notes: z.string().optional(),
items: z.array(z.object({
description: z.string().min(1),
quantity: z.number().min(0.01).default(1),
unitPrice: z.number(),
})).min(1),
});
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;

View File

@@ -0,0 +1,154 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
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) {
const { data, error } = await client.from('sepa_batches').select('*')
.eq('account_id', accountId).order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
},
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?: { status?: string }) {
let query = client.from('invoices').select('*').eq('account_id', accountId)
.order('issue_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status as Database['public']['Enums']['invoice_status']);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
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 ?? [] };
},
// --- Utilities ---
validateIban,
};
}

View File

@@ -0,0 +1,221 @@
/**
* SEPA XML Generator — pain.008.003.02 (Direct Debit) + pain.001.003.03 (Credit Transfer)
* German banking standard compliance.
* Migrated from legacy __include_export_sepa.php
*/
interface SepaCreditor {
name: string;
iban: string;
bic: string;
creditorId: string; // Gläubiger-ID
}
interface SepaTransaction {
debtorName: string;
debtorIban: string;
debtorBic?: string;
amount: number; // in EUR
mandateId: string;
mandateDate: string; // YYYY-MM-DD
remittanceInfo: string;
}
interface SepaBatchConfig {
messageId: string;
creationDateTime: string; // ISO
executionDate: string; // YYYY-MM-DD
creditor: SepaCreditor;
transactions: SepaTransaction[];
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function formatAmount(amount: number): string {
return amount.toFixed(2);
}
/**
* Generate SEPA Direct Debit XML (pain.008.003.02)
*/
export function generateDirectDebitXml(config: SepaBatchConfig): string {
const totalAmount = config.transactions.reduce((sum, t) => sum + t.amount, 0);
const numberOfTxs = config.transactions.length;
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.008.003.02"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<CstmrDrctDbtInitn>
<GrpHdr>
<MsgId>${escapeXml(config.messageId)}</MsgId>
<CreDtTm>${config.creationDateTime}</CreDtTm>
<NbOfTxs>${numberOfTxs}</NbOfTxs>
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
<InitgPty>
<Nm>${escapeXml(config.creditor.name)}</Nm>
</InitgPty>
</GrpHdr>
<PmtInf>
<PmtInfId>${escapeXml(config.messageId)}-1</PmtInfId>
<PmtMtd>DD</PmtMtd>
<BtchBookg>true</BtchBookg>
<NbOfTxs>${numberOfTxs}</NbOfTxs>
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
<PmtTpInf>
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
<LclInstrm><Cd>CORE</Cd></LclInstrm>
<SeqTp>RCUR</SeqTp>
</PmtTpInf>
<ReqdColltnDt>${config.executionDate}</ReqdColltnDt>
<Cdtr>
<Nm>${escapeXml(config.creditor.name)}</Nm>
</Cdtr>
<CdtrAcct>
<Id><IBAN>${config.creditor.iban}</IBAN></Id>
</CdtrAcct>
<CdtrAgt>
<FinInstnId><BIC>${config.creditor.bic}</BIC></FinInstnId>
</CdtrAgt>
<CdtrSchmeId>
<Id><PrvtId><Othr>
<Id>${escapeXml(config.creditor.creditorId)}</Id>
<SchmeNm><Prtry>SEPA</Prtry></SchmeNm>
</Othr></PrvtId></Id>
</CdtrSchmeId>`;
for (const tx of config.transactions) {
xml += `
<DrctDbtTxInf>
<PmtId>
<EndToEndId>${escapeXml(tx.mandateId)}</EndToEndId>
</PmtId>
<InstdAmt Ccy="EUR">${formatAmount(tx.amount)}</InstdAmt>
<DrctDbtTx>
<MndtRltdInf>
<MndtId>${escapeXml(tx.mandateId)}</MndtId>
<DtOfSgntr>${tx.mandateDate}</DtOfSgntr>
</MndtRltdInf>
</DrctDbtTx>
<DbtrAgt>
<FinInstnId>${tx.debtorBic ? `<BIC>${tx.debtorBic}</BIC>` : '<Othr><Id>NOTPROVIDED</Id></Othr>'}</FinInstnId>
</DbtrAgt>
<Dbtr>
<Nm>${escapeXml(tx.debtorName)}</Nm>
</Dbtr>
<DbtrAcct>
<Id><IBAN>${tx.debtorIban}</IBAN></Id>
</DbtrAcct>
<RmtInf>
<Ustrd>${escapeXml(tx.remittanceInfo)}</Ustrd>
</RmtInf>
</DrctDbtTxInf>`;
}
xml += `
</PmtInf>
</CstmrDrctDbtInitn>
</Document>`;
return xml;
}
/**
* Generate SEPA Credit Transfer XML (pain.001.003.03)
*/
export function generateCreditTransferXml(config: SepaBatchConfig): string {
const totalAmount = config.transactions.reduce((sum, t) => sum + t.amount, 0);
const numberOfTxs = config.transactions.length;
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.003.03"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<CstmrCdtTrfInitn>
<GrpHdr>
<MsgId>${escapeXml(config.messageId)}</MsgId>
<CreDtTm>${config.creationDateTime}</CreDtTm>
<NbOfTxs>${numberOfTxs}</NbOfTxs>
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
<InitgPty>
<Nm>${escapeXml(config.creditor.name)}</Nm>
</InitgPty>
</GrpHdr>
<PmtInf>
<PmtInfId>${escapeXml(config.messageId)}-1</PmtInfId>
<PmtMtd>TRF</PmtMtd>
<BtchBookg>true</BtchBookg>
<NbOfTxs>${numberOfTxs}</NbOfTxs>
<CtrlSum>${formatAmount(totalAmount)}</CtrlSum>
<PmtTpInf>
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
</PmtTpInf>
<ReqdExctnDt>${config.executionDate}</ReqdExctnDt>
<Dbtr>
<Nm>${escapeXml(config.creditor.name)}</Nm>
</Dbtr>
<DbtrAcct>
<Id><IBAN>${config.creditor.iban}</IBAN></Id>
</DbtrAcct>
<DbtrAgt>
<FinInstnId><BIC>${config.creditor.bic}</BIC></FinInstnId>
</DbtrAgt>`;
for (const tx of config.transactions) {
xml += `
<CdtTrfTxInf>
<PmtId>
<EndToEndId>${escapeXml(tx.mandateId)}</EndToEndId>
</PmtId>
<Amt>
<InstdAmt Ccy="EUR">${formatAmount(tx.amount)}</InstdAmt>
</Amt>
<CdtrAgt>
<FinInstnId>${tx.debtorBic ? `<BIC>${tx.debtorBic}</BIC>` : '<Othr><Id>NOTPROVIDED</Id></Othr>'}</FinInstnId>
</CdtrAgt>
<Cdtr>
<Nm>${escapeXml(tx.debtorName)}</Nm>
</Cdtr>
<CdtrAcct>
<Id><IBAN>${tx.debtorIban}</IBAN></Id>
</CdtrAcct>
<RmtInf>
<Ustrd>${escapeXml(tx.remittanceInfo)}</Ustrd>
</RmtInf>
</CdtTrfTxInf>`;
}
xml += `
</PmtInf>
</CstmrCdtTrfInitn>
</Document>`;
return xml;
}
/**
* IBAN validation — modulo 97 check
* Migrated from legacy __include_banking.php
*/
export function validateIban(iban: string): boolean {
const cleaned = iban.replace(/\s/g, '').toUpperCase();
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/.test(cleaned)) return false;
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
let numStr = '';
for (const char of rearranged) {
const code = char.charCodeAt(0);
numStr += (code >= 65 && code <= 90) ? (code - 55).toString() : char;
}
let remainder = 0;
for (const digit of numStr) {
remainder = (remainder * 10 + parseInt(digit, 10)) % 97;
}
return remainder === 1;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}