Initial state for GitNexus analysis
This commit is contained in:
34
packages/features/finance/package.json
Normal file
34
packages/features/finance/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
1
packages/features/finance/src/components/index.ts
Normal file
1
packages/features/finance/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
45
packages/features/finance/src/schema/finance.schema.ts
Normal file
45
packages/features/finance/src/schema/finance.schema.ts
Normal 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>;
|
||||
154
packages/features/finance/src/server/api.ts
Normal file
154
packages/features/finance/src/server/api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
6
packages/features/finance/tsconfig.json
Normal file
6
packages/features/finance/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user