Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

@@ -1,15 +1,26 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useFieldArray } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useFieldArray } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateInvoiceSchema } from '../schema/finance.schema';
import { createInvoice } from '../server/actions/finance-actions';
@@ -64,37 +75,98 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
});
const formatCurrency = (value: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(value);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Rechnungsdaten</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Rechnungsdaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="invoiceNumber" render={({ field }) => (
<FormItem><FormLabel>Rechnungsnummer *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="issueDate" render={({ field }) => (
<FormItem><FormLabel>Rechnungsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="dueDate" render={({ field }) => (
<FormItem><FormLabel>Fälligkeitsdatum *</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="invoiceNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Rechnungsnummer *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issueDate"
render={({ field }) => (
<FormItem>
<FormLabel>Rechnungsdatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dueDate"
render={({ field }) => (
<FormItem>
<FormLabel>Fälligkeitsdatum *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Empfänger</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Empfänger</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="recipientName" render={({ field }) => (
<FormItem><FormLabel>Name *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="recipientAddress" render={({ field }) => (
<FormItem><FormLabel>Adresse</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="recipientName"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="recipientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>Adresse</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
@@ -106,7 +178,9 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
type="button"
variant="outline"
size="sm"
onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}
onClick={() =>
append({ description: '', quantity: 1, unitPrice: 0 })
}
>
+ Position hinzufügen
</Button>
@@ -114,29 +188,79 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
</CardHeader>
<CardContent className="space-y-4">
{fields.map((item, index) => (
<div key={item.id} className="grid grid-cols-1 gap-4 rounded-lg border p-4 sm:grid-cols-12">
<div
key={item.id}
className="grid grid-cols-1 gap-4 rounded-lg border p-4 sm:grid-cols-12"
>
<div className="sm:col-span-6">
<FormField control={form.control} name={`items.${index}.description`} render={({ field }) => (
<FormItem><FormLabel>Beschreibung *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name={`items.${index}.description`}
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="sm:col-span-2">
<FormField control={form.control} name={`items.${index}.quantity`} render={({ field }) => (
<FormItem><FormLabel>Menge</FormLabel><FormControl>
<Input type="number" min={0.01} step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name={`items.${index}.quantity`}
render={({ field }) => (
<FormItem>
<FormLabel>Menge</FormLabel>
<FormControl>
<Input
type="number"
min={0.01}
step="0.01"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="sm:col-span-3">
<FormField control={form.control} name={`items.${index}.unitPrice`} render={({ field }) => (
<FormItem><FormLabel>Einzelpreis ()</FormLabel><FormControl>
<Input type="number" step="0.01" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name={`items.${index}.unitPrice`}
render={({ field }) => (
<FormItem>
<FormLabel>Einzelpreis ()</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex items-end sm:col-span-1">
{fields.length > 1 && (
<Button type="button" variant="ghost" size="sm" onClick={() => remove(index)} className="text-destructive">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
className="text-destructive"
>
</Button>
)}
@@ -147,18 +271,31 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
</Card>
<Card>
<CardHeader><CardTitle>Beträge</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Beträge</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<FormField control={form.control} name="taxRate" render={({ field }) => (
<FormItem className="grid grid-cols-2 items-center gap-4">
<FormLabel>MwSt.-Satz (%)</FormLabel>
<FormControl>
<Input type="number" min={0} step="0.5" className="max-w-[120px]" {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<div className="space-y-1 rounded-lg bg-muted p-4 text-sm">
<FormField
control={form.control}
name="taxRate"
render={({ field }) => (
<FormItem className="grid grid-cols-2 items-center gap-4">
<FormLabel>MwSt.-Satz (%)</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step="0.5"
className="max-w-[120px]"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="bg-muted space-y-1 rounded-lg p-4 text-sm">
<div className="flex justify-between">
<span>Zwischensumme (netto)</span>
<span className="font-medium">{formatCurrency(subtotal)}</span>
@@ -172,17 +309,32 @@ export function CreateInvoiceForm({ accountId, account }: Props) {
<span>{formatCurrency(total)}</span>
</div>
</div>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Anmerkungen</FormLabel><FormControl>
<textarea {...field} className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Anmerkungen</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[60px] w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Rechnung erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Rechnung erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,15 +1,23 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { createSepaBatch } from '../server/actions/finance-actions';
@@ -35,7 +43,9 @@ export function CreateSepaBatchForm({ accountId, account }: Props) {
accountId,
batchType: 'direct_debit' as const,
description: '',
executionDate: new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]!,
executionDate: new Date(Date.now() + 7 * 86400000)
.toISOString()
.split('T')[0]!,
painFormat: 'pain.008.003.02',
},
});
@@ -54,45 +64,74 @@ export function CreateSepaBatchForm({ accountId, account }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-2xl">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="max-w-2xl space-y-6"
>
<Card>
<CardHeader>
<CardTitle>SEPA-Einzug erstellen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="batchType" render={({ field }) => (
<FormItem>
<FormLabel>Typ</FormLabel>
<FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="direct_debit">Lastschrift (SEPA Core)</option>
<option value="credit_transfer">Überweisung</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="batchType"
render={({ field }) => (
<FormItem>
<FormLabel>Typ</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="direct_debit">
Lastschrift (SEPA Core)
</option>
<option value="credit_transfer">Überweisung</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="description" render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl><Input placeholder="z.B. Mitgliedsbeiträge Q1 2026" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl>
<Input
placeholder="z.B. Mitgliedsbeiträge Q1 2026"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField control={form.control} name="executionDate" render={({ field }) => (
<FormItem>
<FormLabel>Ausführungsdatum *</FormLabel>
<FormControl><Input type="date" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="executionDate"
render={({ field }) => (
<FormItem>
<FormLabel>Ausführungsdatum *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Einzug erstellen'}
</Button>

View File

@@ -1,8 +1,22 @@
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 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(),
@@ -36,10 +50,14 @@ export const CreateInvoiceSchema = z.object({
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),
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

@@ -1,9 +1,11 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateSepaBatchSchema,
AddSepaItemSchema,
@@ -81,13 +83,21 @@ export const createInvoice = authActionClient
// Gap 3: SEPA auto-populate from members
export const populateBatchFromMembers = authActionClient
.inputSchema(z.object({ batchId: z.string().uuid(), accountId: z.string().uuid() }))
.inputSchema(
z.object({ batchId: z.string().uuid(), accountId: z.string().uuid() }),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFinanceApi(client);
logger.info({ name: 'sepa.populate' }, 'Populating batch from members...');
const result = await api.populateBatchFromMembers(input.batchId, input.accountId);
logger.info({ name: 'sepa.populate', count: result.addedCount }, 'Populated');
const result = await api.populateBatchFromMembers(
input.batchId,
input.accountId,
);
logger.info(
{ name: 'sepa.populate', count: result.addedCount },
'Populated',
);
return { success: true, addedCount: result.addedCount };
});

View File

@@ -1,8 +1,17 @@
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';
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 */
@@ -12,24 +21,38 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
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 });
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();
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();
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;
},
@@ -40,13 +63,21 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
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();
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
@@ -55,21 +86,34 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
},
async getBatchItems(batchId: string) {
const { data, error } = await client.from('sepa_items').select('*')
.eq('batch_id', batchId).order('created_at');
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);
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 }) {
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);
@@ -89,39 +133,65 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
})),
};
const xml = batch.batch_type === 'direct_debit'
? generateDirectDebitXml(config)
: generateCreditTransferXml(config);
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);
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)
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']);
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 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();
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
@@ -134,17 +204,26 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
sort_order: i,
}));
const { error: itemsError } = await client.from('invoice_items').insert(items);
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();
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');
const { data: items } = await client
.from('invoice_items')
.select('*')
.eq('invoice_id', invoiceId)
.order('sort_order');
return { ...data, items: items ?? [] };
},
@@ -172,15 +251,21 @@ export function createFinanceApi(client: SupabaseClient<Database>) {
.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)]));
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;
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({

View File

@@ -210,7 +210,7 @@ export function validateIban(iban: string): boolean {
let numStr = '';
for (const char of rearranged) {
const code = char.charCodeAt(0);
numStr += (code >= 65 && code <= 90) ? (code - 55).toString() : char;
numStr += code >= 65 && code <= 90 ? (code - 55).toString() : char;
}
let remainder = 0;

View File

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