Compare commits

...

7 Commits

Author SHA1 Message Date
DanielS
cb12a6ced4 docs: document post-processing and layout differentiation in grand functions
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-09 21:51:26 +02:00
DanielS
66039eb4cf feat: implement pdf generation service based on jsonb snapshots 2026-07-09 21:51:23 +02:00
DanielS
5eef6c76ef docs: document checkout split API and payload in grand functions 2026-07-09 21:50:44 +02:00
DanielS
f71fc7f88d feat: create checkout API route with checkout split logic 2026-07-09 21:50:42 +02:00
DanielS
60e64c689d feat: implement wizard top-down state management and auto-reset 2026-07-09 21:49:53 +02:00
DanielS
bd7d5dd1b2 db: create core schema for products, categories, global rules and orders 2026-07-09 21:49:50 +02:00
DanielS
54b9dea70f docs: restructure agentsspec into grand-functions skill 2026-07-09 21:49:47 +02:00
10 changed files with 637 additions and 256 deletions

View File

@@ -0,0 +1,13 @@
---
name: grand-functions
description: Dokumentation und Spezifikation des CASPOS Webshops inklusive Datenmodell, Kernprozessen und Sicherheitskonzept.
---
# Grand Functions - CASPOS Webshop Spezifikation
Dieser Skill enthält die vollständige Dokumentation und Spezifikation des CASPOS Webshops. Die Spezifikationen sind in logische Referenzdokumente unterteilt:
- [Projekt-Überblick & Tech-Stack](file:///c:/source/webshop/.agents/skills/grand-functions/references/overviews.md)
- [Datenmodell & Schema](file:///c:/source/webshop/.agents/skills/grand-functions/references/schema.md)
- [Kernprozesse](file:///c:/source/webshop/.agents/skills/grand-functions/references/processes.md)
- [Sicherheitskonzept (RLS)](file:///c:/source/webshop/.agents/skills/grand-functions/references/security.md)

View File

@@ -0,0 +1,16 @@
# Projekt-Überblick & Technologie-Stack
## 1. Projekt-Überblick
Der CASPOS Webshop ist ein B2B-Portal für Vertriebspartner. Partner können hier Software-Pakete und Zusatzmodule (für POS/Kassenlösungen) konfigurieren, Anfragen für ihre Endkunden erstellen und Lizenzen verwalten.
---
## 2. Technologie-Stack
- **Frontend/Backend**: Next.js 15+ (App Router, React Server Components, Server Actions).
- **Styling**: Tailwind CSS & Vanilla CSS (modernes Dark-Theme).
- **Datenbank & Auth**: Supabase (PostgreSQL, Row Level Security - RLS, Supabase Auth).
- **E-Mail-Versand**: SMTP-Integration für automatisierte Bestätigungen.
- **PDF-Generierung**: `@react-pdf/renderer` zur Erzeugung von Anfragebestätigungen als PDF.
- **Storage**: Supabase Storage (`invoices` Bucket) zur Archivierung der PDF-Dokumente.

View File

@@ -0,0 +1,73 @@
# Kernprozesse
```mermaid
graph TD
A[Partner loggt sich ein] --> B{Firma zugewiesen?}
B -- Nein --> C[Fehlermeldung: Kein Zutritt zum Wizard]
B -- Ja --> D[Wizard öffnen / Konfigurieren]
D --> E[Endkunden auswählen/anlegen]
E --> F[Produkte & Module wählen]
F --> G{Validierung erfolgreich?}
G -- Nein --> H[Validierungsfehler anzeigen]
G -- Ja --> I[Bestellung absenden]
I --> J[Snapshot einfrieren & in DB speichern]
J --> K[Rechnungs-PDF generieren & in Storage laden]
K --> L[E-Mail mit PDF an Partner senden]
L --> M[Bestellung in der Übersicht anzeigen]
```
### A. Partner- & Unternehmens-Hierarchie
1. Ein neu registrierter User hat die Rolle `partner` und ist zunächst keiner Firma zugeordnet.
2. Der Administrator ordnet den User in der Admin-Oberfläche (`/admin/users`) einem Unternehmen (`companies`) zu.
3. Nur wenn der User einer Firma zugewiesen ist (oder Admin ist), kann er Endkunden anlegen und Bestellungen aufgeben.
### B. Endkunden-Verwaltung (`/my-customers`)
- **Firmenweite Sicht**: Da `end_customers.partner_id` on `companies.id` verweist, sehen alle Mitarbeiter desselben Unternehmens dieselben Endkunden.
- **DSGVO Anonymisierung**: Endkunden können unwiderruflich anonymisiert werden. Dabei werden sensible Daten mit `[GELÖSCHT]` überschrieben und `is_anonymized` auf `true` gesetzt. In existierenden Bestellungen (`orders.customer_data`) bleiben die Daten zu steuerlichen Zwecken unverändert.
### C. Bestell-Wizard & Validierung (`/order`)
- **Kategorie-Pflichten**: Wenn eine Produktkategorie als `is_required` definiert ist, muss ein Produkt ausgewählt werden.
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
- **Top-Down State-Management**:
- Die Wahl eines Basis-Produkts steuert die Sichtbarkeit und Wählbarkeit aller Module (Top-Down). Modul-Auswahlen dürfen niemals die Liste der wählbaren Basis-Produkte beeinflussen (Verhinderung von UI-Deadlocks).
- **Auto-Reset**: Beim Wechsel des Basis-Produkts über `handleBaseProductChange` werden automatisch alle ausgewählten Module entfernt, die durch `global_exclusions` für das neue Produkt ausgeschlossen sind.
- **Radio-Button-Verhalten (allow_multiselect = false)**: Falls eine Kategorie keine Mehrfachauswahl erlaubt, wird bei Auswahl eines neuen Moduls das zuvor ausgewählte Modul dieser Kategorie automatisch abgewählt.
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.
### D. Bestellungs-Verwaltung & Statusübergang
- **Sichtbarkeit**: Partner sehen in `/my-orders` alle Bestellungen aller Mitarbeiter ihrer Firma.
- **Bearbeiten**: Unvollständige Bestellungen (`status != 'completed'`) können von jedem Mitarbeiter der jeweiligen Firma nachträglich editiert werden.
- **Admin-Workflow**: Admins sehen in `/admin/orders` alle Bestellungen global, können den Status ändern (z.B. von *Eingegangen* auf *In Bearbeitung* oder *Abgeschlossen*) und PDF-Rechnungen manuell herunterladen.
- **Statusänderungs-Mails**: Bei jedem Statusübergang wird automatisch eine Benachrichtigungs-E-Mail an den Besteller geschickt.
---
### E. Multi-Kassen-Warenkorb & Checkout-Split
- **Endpunkt**: `POST /api/orders/checkout`
- **Funktionsweise**:
- Empfängt ein Array von `items` (Kassenkonfigurationen).
- Teilt die Konfigurationen nach `billingInterval` / `billingType` auf (Kauf vs. Abo).
- Erstellt separate Orders in der Datenbank:
- Typ `purchase`: Zahlungsart `invoice` (Rechnung).
- Typ `subscription`: Zahlungsart `sepa`.
- Friert für jede Order einen konsolidierten `order_snapshot` (JSONB) und `customer_snapshot` (JSONB) ein.
- Triggert die PDF-Rechnungserstellung und den E-Mail-Versand unabhängig für jede generierte Order.
---
### F. PDF-Rechnungsgenerierung & Mail-Versand (Post-Processing)
- **Snapshot-Exklusivität**: Die Rechnungsgenerierung erfolgt ausschließlich auf Basis der in `orders.order_data` und `orders.customer_data` eingefrorenen Snapshots.
- **Layout-Unterscheidung**:
- **Typ `purchase` (Kauf)**:
- Generiert eine klassische **B2B-Rechnung**.
- Weist die einmalige Gesamtsumme aus.
- Beinhaltet ein explizites Zahlungsziel ("Zahlbar innerhalb von 14 Tagen ohne Abzug").
- **Typ `subscription` (Abonnement)**:
- Generiert eine **Vertragsbestätigung (Abonnement)**.
- Weist monatlich wiederkehrende Kosten aus.
- Beinhaltet das SEPA-Lastschriftmandat inklusive Kontoinhaber und IBAN.
- **Archivierung & Benachrichtigung**:
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
- E-Mail-Versand mit PDF-Anhang an den Besteller.

View File

@@ -0,0 +1,84 @@
# Datenmodell & Beziehungen (Schema)
Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
### `companies` (Unternehmen)
- Repräsentiert die Partner-Unternehmen (Retailer).
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `street`, `zip`, `city`, `email` (Adressdaten)
### `users` (Systembenutzer)
- Erweitert die Authentifizierungsdaten aus `auth.users`.
- `id` (UUID, References `auth.users(id)`)
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
- `company_id` (UUID, References `public.companies(id)`)
### `end_customers` (Endkunden)
- Die Endkunden, für die die Partner Lizenzen bestellen.
- `id` (UUID, Primary Key)
- `partner_id` (UUID, References `public.companies(id)`) <-- *Direkte Zuordnung zur Firma des Partners*
- `company_name` (TEXT)
- `first_name`, `last_name`, `street`, `zip`, `city`, `email` (Stammdaten)
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner` (Bankdaten)
- `is_anonymized` (BOOLEAN) <-- *Für DSGVO-Löschung*
### `categories` (Kategorien)
- Steuert das Layout und Verhalten im Wizard.
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `is_required` (BOOLEAN) <-- *Muss ausgewählt werden*
- `allow_multiple` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
- `sort_order` (INTEGER) <-- *Sortierung im Wizard*
- `show_in_branches` (BOOLEAN)
- `preselect` (BOOLEAN)
- `resets_others` (BOOLEAN)
### `products` (Katalogprodukte / Basis-Editionen)
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
- `id` (UUID, Primary Key)
- `category_id` (UUID, References `categories`)
- `name` (TEXT)
- `base_price` (DECIMAL)
- `tax_rate` (DECIMAL)
- `billing_interval` (TEXT: `'one_time'` / `'monthly'`)
- `show_in_branches` (BOOLEAN)
### `modules` (Zusatzmodule / Erweiterungen)
- Optionale Erweiterungen für Produkte.
- `id` (UUID, Primary Key)
- `product_id` (UUID, References `products`)
- `category_id` (UUID, References `categories`)
- `name` (TEXT)
- `price` (DECIMAL)
- `has_quantity` (BOOLEAN)
### `global_inclusions` (Globale automatische Beigaben)
- Regelt, welche Module bei Auswahl eines Produkts kostenlos enthalten sind.
- `id` (UUID, Primary Key)
- `trigger_product_id` (UUID, References `products`)
- `included_module_id` (UUID, References `modules`)
### `global_exclusions` (Globale Ausschlüsse)
- Regelt Inkompatibilitäten zwischen Produkten und/oder Modulen.
- `id` (UUID, Primary Key)
- `product_id` (UUID, References `products`)
- `excluded_product_id` (UUID, References `products`, optional)
- `excluded_module_id` (UUID, References `modules`, optional)
### `orders` (Bestellungen / Anfragen)
- Gespeicherte Snapshots von Konfigurationen.
- `id` (UUID, Primary Key)
- `user_id` (UUID, References `auth.users(id)`)
- `company_id` (UUID, References `public.companies(id)`)
- `end_customer_id` (UUID, References `end_customers(id)`)
- `order_number` (TEXT, Format: `AE-YYYY-NNNNN`)
- `order_hash` (TEXT, Idempotenz-Guard)
- `type` (TEXT: `'purchase'` / `'subscription'`) <-- *Wichtig für den Checkout-Split*
- `payment_method` (TEXT) <-- *Rechnung bei Kauf, SEPA bei Abo*
- `total_price` (DECIMAL)
- `customer_snapshot` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
- `order_snapshot` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
- `pdf_url` (TEXT)
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
- `created_at` (TIMESTAMPTZ)

View File

@@ -0,0 +1,15 @@
# Sicherheitskonzept (RLS - Row Level Security)
RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierung:
- **Unternehmen (`companies`)**: Authentifizierte Benutzer dürfen nur die Unternehmen lesen.
- **Endkunden (`end_customers`)**:
- `USING (partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
- Partner sehen und modifizieren nur Endkunden, deren `partner_id` mit der `company_id` des angemeldeten Benutzers übereinstimmt.
- **Bestellungen (`orders`)**:
- `USING (company_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
- Partner sehen und modifizieren nur Bestellungen, die ihrer Company zugewiesen sind (sie müssen einer Company zugeordnet sein).
- Jede Bestellung wird bei der Erstellung automatisch der Company des Erstellers zugewiesen.
- Admins haben uneingeschränkten Zugriff und können Bestellungen nachträglich anderen Companies zuweisen.
- **Lizenzen (`licenses`)**:
- Partner sehen nur Lizenzen von Endkunden, die ihrer Company zugeordnet sind.

View File

@@ -1,144 +0,0 @@
# Projekt-Dokumentation: CASPOS Webshop
Diese Datei beschreibt die Architektur, das Datenmodell, die Kernprozesse und das Sicherheitskonzept des CASPOS Webshops.
---
## 1. Projekt-Überblick
Der CASPOS Webshop ist ein B2B-Portal für Vertriebspartner. Partner können hier Software-Pakete und Zusatzmodule (für POS/Kassenlösungen) konfigurieren, Anfragen für ihre Endkunden erstellen und Lizenzen verwalten.
---
## 2. Technologie-Stack
- **Frontend/Backend**: Next.js 15+ (App Router, React Server Components, Server Actions).
- **Styling**: Tailwind CSS & Vanilla CSS (modernes Dark-Theme).
- **Datenbank & Auth**: Supabase (PostgreSQL, Row Level Security - RLS, Supabase Auth).
- **E-Mail-Versand**: SMTP-Integration für automatisierte Bestätigungen.
- **PDF-Generierung**: `@react-pdf/renderer` zur Erzeugung von Anfragebestätigungen als PDF.
- **Storage**: Supabase Storage (`invoices` Bucket) zur Archivierung der PDF-Dokumente.
---
## 3. Datenmodell & Beziehungen (Schema)
Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
### `companies` (Unternehmen)
- Repräsentiert die Partner-Unternehmen (Retailer).
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `street`, `zip`, `city`, `email` (Adressdaten)
### `users` (Systembenutzer)
- Erweitert die Authentifizierungsdaten aus `auth.users`.
- `id` (UUID, References `auth.users(id)`)
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
- `company_id` (UUID, References `public.companies(id)`)
### `profiles` (Benutzerprofile)
- Stammdaten der einzelnen Benutzer (Ansprechpartner).
- `id` (UUID, References `auth.users(id)`)
- `first_name`, `last_name`, `email` etc.
### `end_customers` (Endkunden)
- Die Endkunden, für die die Partner Lizenzen bestellen.
- `id` (UUID, Primary Key)
- `partner_id` (UUID, References `public.companies(id)`) <-- *Direkte Zuordnung zur Firma des Partners*
- `company_name` (TEXT)
- `first_name`, `last_name`, `street`, `zip`, `city`, `email` (Stammdaten)
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner` (Bankdaten)
- `is_anonymized` (BOOLEAN) <-- *Für DSGVO-Löschung*
### `products` (Katalogprodukte)
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
- `id`, `name`, `base_price`, `tax_rate`, `billing_interval` (`one_time` / `monthly`).
- `requirements` (UUID[] von anderen Produkten)
- `exclusions` (UUID[] von inkompatiblen Produkten)
### `product_modules` (Zusatzmodule)
- Optionale Erweiterungen für Produkte.
- `id`, `product_id` (References `products`), `name`, `price`, `has_quantity` (BOOLEAN).
- `requirements` (UUID[] von Modulen)
- `exclusions` (UUID[] von Modulen)
### `orders` (Bestellungen / Anfragen)
- Gespeicherte Snapshots von Konfigurationen.
- `id` (UUID, Primary Key)
- `user_id` (UUID, References `auth.users(id)`) <-- *Ersteller der Bestellung*
- `order_number` (TEXT, Format: `AE-YYYY-NNNNN`)
- `order_hash` (TEXT, Idempotenz-Guard gegen Doppelübermittlung)
- `end_customer_id` (UUID, References `end_customers(id)`)
- `total_price` (DECIMAL)
- `customer_data` (JSONB) <-- *Eingefrorener Snapshot des Endkunden*
- `order_data` (JSONB) <-- *Eingefrorener Snapshot der Produktkonfiguration*
- `pdf_url` (TEXT, Link zur PDF-Rechnung im Storage)
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
---
## 4. Kernprozesse
```mermaid
graph TD
A[Partner loggt sich ein] --> B{Firma zugewiesen?}
B -- Nein --> C[Fehlermeldung: Kein Zutritt zum Wizard]
B -- Ja --> D[Wizard öffnen / Konfigurieren]
D --> E[Endkunden auswählen/anlegen]
E --> F[Produkte & Module wählen]
F --> G{Validierung erfolgreich?}
G -- Nein --> H[Validierungsfehler anzeigen]
G -- Ja --> I[Bestellung absenden]
I --> J[Snapshot einfrieren & in DB speichern]
J --> K[Rechnungs-PDF generieren & in Storage laden]
K --> L[E-Mail mit PDF an Partner senden]
L --> M[Bestellung in der Übersicht anzeigen]
```
### A. Partner- & Unternehmens-Hierarchie
1. Ein neu registrierter User hat die Rolle `partner` und ist zunächst keiner Firma zugeordnet.
2. Der Administrator ordnet den User in der Admin-Oberfläche (`/admin/users`) einem Unternehmen (`companies`) zu.
3. Nur wenn der User einer Firma zugewiesen ist (oder Admin ist), kann er Endkunden anlegen und Bestellungen aufgeben.
### B. Endkunden-Verwaltung (`/my-customers`)
- **Firmenweite Sicht**: Da `end_customers.partner_id` auf `companies.id` verweist, sehen alle Mitarbeiter desselben Unternehmens dieselben Endkunden.
- **DSGVO Anonymisierung**: Endkunden können unwiderruflich anonymisiert werden. Dabei werden sensible Daten mit `[GELÖSCHT]` überschrieben und `is_anonymized` auf `true` gesetzt. In existierenden Bestellungen (`orders.customer_data`) bleiben die Daten zu steuerlichen Zwecken unverändert.
### C. Bestell-Wizard & Validierung (`/order`)
- **Kategorie-Pflichten**: Wenn eine Produktkategorie als `is_required` definiert ist, muss ein Produkt ausgewählt werden.
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
- **Ausschlüsse (Exclusions)**: Inkompatible Produkte oder Module blockieren sich gegenseitig.
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.
### D. Bestellungs-Verwaltung & Statusübergang
- **Sichtbarkeit**: Partner sehen in `/my-orders` alle Bestellungen aller Mitarbeiter ihrer Firma.
- **Bearbeiten**: Unvollständige Bestellungen (`status != 'completed'`) können von jedem Mitarbeiter der jeweiligen Firma nachträglich editiert werden.
- **Admin-Workflow**: Admins sehen in `/admin/orders` alle Bestellungen global, können den Status ändern (z.B. von *Eingegangen* auf *In Bearbeitung* oder *Abgeschlossen*) und PDF-Rechnungen manuell herunterladen.
- **Statusänderungs-Mails**: Bei jedem Statusübergang wird automatisch eine Benachrichtigungs-E-Mail an den Besteller geschickt.
---
## 5. Sicherheitskonzept (RLS - Row Level Security)
RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierung:
- **Unternehmen (`companies`)**: Authentifizierte Benutzer dürfen nur die Unternehmen lesen.
- **Endkunden (`end_customers`)**:
- `USING (partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
- Partner sehen und modifizieren nur Endkunden, deren `partner_id` mit der `company_id` des angemeldeten Benutzers übereinstimmt.
- **Bestellungen (`orders`)**:
- `USING (company_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
- Partner sehen und modifizieren nur Bestellungen, die ihrer Company zugewiesen sind (sie müssen einer Company zugeordnet sein).
- Jede Bestellung wird bei der Erstellung automatisch der Company des Erstellers zugewiesen.
- Admins haben uneingeschränkten Zugriff und können Bestellungen nachträglich anderen Companies zuweisen.
- **Lizenzen (`licenses`)**:
- Partner sehen nur Lizenzen von Endkunden, die ihrer Company zugeordnet sind.
---
## Kommentarbereich
*Bitte nutze diesen Bereich oder füge Zeilenkommentare hinzu, um Änderungen oder Ergänzungen vorzuschlagen.*
- **Kommentar 1**:
- **Kommentar 2**:

View File

@@ -0,0 +1,165 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getProducts, getCategories } from '@/lib/actions/products';
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
import { sendMail } from '@/utils/mail';
import { createHash } from 'crypto';
import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf';
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
const year = new Date().getFullYear();
const rand = Math.floor(10000 + Math.random() * 90000);
return `${prefix}-${year}-${rand}`;
}
function hashOrderSnapshot(snapshot: object): string {
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
}
export async function POST(request: Request) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: dbUser } = await supabase
.from('users')
.select('role, company_id')
.eq('id', user.id)
.single();
const isAdminUser = dbUser?.role === 'admin';
if (!isAdminUser && !dbUser?.company_id) {
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
}
const body = await request.json();
const { items, customerProfile, endCustomerId, endCustomer } = body;
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: 'Warenkorb leer' }, { status: 400 });
}
const products = await getProducts();
const categories = await getCategories();
// Split items into purchase and subscription
const purchaseItems = items.filter(
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
);
const subscriptionItems = items.filter(
(item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription'
);
const createdOrders: any[] = [];
const processOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
if (groupItems.length === 0) return;
// Build consolidated snapshots
// For the multi-item support, we combine the order snapshots of each item in the group
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer);
const orderItemsList: any[] = [];
let total = 0;
for (const item of groupItems) {
const itemSnapshot = buildOrderSnapshot(
item.selections,
products,
categories,
type === 'purchase' ? 'one_time' : 'monthly',
item.moduleQuantities
);
orderItemsList.push(...itemSnapshot.items);
total += itemSnapshot.total;
}
const orderSnapshot = {
schema_version: 1,
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
items: orderItemsList,
total: total,
tax_rate: 19, // default
tax_amount: Math.round(total * 0.19 * 100) / 100,
subtotal: Math.round((total / 1.19) * 100) / 100
};
const orderHash = hashOrderSnapshot(orderSnapshot);
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
const { data: order, error: orderError } = await supabase
.from('orders')
.insert([{
user_id: user.id,
company_id: dbUser?.company_id || null,
order_number: orderNumber,
order_hash: orderHash,
end_customer_id: endCustomerId || null,
total_price: total,
customer_data: customerSnapshot,
order_data: orderSnapshot,
type: type,
payment_method: paymentMethod,
status: 'pending'
}])
.select()
.single();
if (orderError) throw orderError;
// Trigger PDF generation & storage upload
try {
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order,
customer: customerSnapshot,
orderSnapshot,
})
);
const fileName = `ab_${order.id}.pdf`;
const { error: uploadError } = await supabase
.storage
.from('invoices')
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
if (!uploadError) {
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
order.pdf_url = fileName;
}
} catch (pdfErr) {
console.error('PDF error for order ' + order.id, pdfErr);
}
// Trigger E-Mail notifications
if (user.email) {
try {
await sendMail({
to: user.email,
subject: `Bestellbestätigung ${order.order_number}`,
text: `Vielen Dank für Ihre Bestellung ${order.order_number}. Typ: ${type}.`,
html: `<p>Vielen Dank für Ihre Bestellung <strong>${order.order_number}</strong>.</p><p>Typ: ${type}</p>`
});
} catch (mailErr) {
console.error('Mail error for order ' + order.id, mailErr);
}
}
createdOrders.push(order);
};
await processOrderGroup(purchaseItems, 'purchase', 'invoice');
await processOrderGroup(subscriptionItems, 'subscription', 'sepa');
return NextResponse.json({ success: true, orders: createdOrders });
} catch (error: any) {
console.error('Checkout error:', error);
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,6 +1,5 @@
import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer';
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
// Create styles
const styles = StyleSheet.create({
page: {
padding: 50,
@@ -31,7 +30,6 @@ const styles = StyleSheet.create({
marginBottom: 2,
},
table: {
width: 'auto',
borderStyle: 'solid',
borderWidth: 1,
@@ -63,16 +61,11 @@ const styles = StyleSheet.create({
padding: 8,
textAlign: 'right',
},
tableCell: {
margin: 'auto',
marginTop: 5,
fontSize: 10,
},
total: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 20,
borderTop: 1,
borderTopWidth: 1,
borderColor: '#000',
paddingTop: 10,
},
@@ -88,26 +81,30 @@ const styles = StyleSheet.create({
textAlign: 'center',
color: '#999',
fontSize: 8,
borderTop: 1,
borderTopWidth: 1,
borderColor: '#eee',
paddingTop: 10,
},
sepaBox: {
marginTop: 15,
padding: 10,
backgroundColor: '#f9f9f9',
borderWidth: 1,
borderColor: '#ddd',
}
});
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
const items = orderSnapshot?.items ?? [];
const taxRate = orderSnapshot?.tax_rate ?? 19;
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
const totalNet = items.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const totalTax = Math.round(totalNet * (taxRate / 100) * 100) / 100;
const totalGross = Math.round((totalNet + totalTax) * 100) / 100;
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
const formattedPrice = (val: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
return (
<Document>
@@ -125,54 +122,26 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</View>
</View>
<Text style={styles.title}>Anfragebestätigung</Text>
<Text style={styles.title}>
{isSubscription ? 'Vertragsbestätigung (Abonnement)' : 'B2B-Rechnung'}
</Text>
<View style={styles.section}>
<Text style={styles.label}>Anfragender Kunde</Text>
<Text style={styles.label}>Kunde</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
{customer.bank_iban && (
<Text style={{ marginTop: 4, color: '#666' }}>
Bank: {customer.bank_name || '-'} · IBAN: {customer.bank_iban} · BIC: {customer.bank_bic || '-'} · Inhaber: {customer.bank_owner || '-'}
</Text>
)}
</View>
<View style={styles.section}>
<Text style={styles.label}>Anfragedetails</Text>
<Text>Anfrage-Nummer: {order.order_number || order.id}</Text>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.order_number || order.id}</Text>
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
</View>
{orderSnapshot?.price_multiplier !== null && orderSnapshot?.price_multiplier !== undefined && (
<View style={[styles.section, { backgroundColor: '#f9f9f9', padding: 8 }]}>
<Text style={styles.label}>Update-Konditionen</Text>
<Text style={{ fontWeight: 'bold' }}>
{orderSnapshot.price_multiplier === 0
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
: orderSnapshot.price_multiplier === 0.15
? "Update (13-14 Monate): 15% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.30
? "Update (15-25 Monate): 30% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.45
? "Update (26-37 Monate): 45% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.60
? "Update (38-49 Monate): 60% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.90
? "Neukauf (>= 50 Monate): 10% Rabatt auf Neukauf"
: `Gebühr: ${orderSnapshot.price_multiplier * 100}%`}
</Text>
{orderSnapshot.last_license_date && (
<Text style={{ marginTop: 2, color: '#666' }}>
Letzte Lizenz vom: {new Date(orderSnapshot.last_license_date).toLocaleDateString('de-DE')}
</Text>
)}
</View>
)}
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
@@ -187,25 +156,23 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</View>
<View style={styles.tableColPrice}>
<Text>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
{formattedPrice(item.base_price)} {isSubscription ? 'mtl.' : 'einmalig'}
</Text>
</View>
</View>
{item.selected_modules?.map((mod: any, mIdx: number) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
const hasQty = qty > 1;
return (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
</Text>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
+{formattedPrice(price)}
</Text>
</View>
</View>
@@ -217,63 +184,49 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
<View style={styles.total}>
<View style={{ width: '70%' }}>
{oneTimeNet > 0 && (
<View style={{ marginBottom: monthlyNet > 0 ? 10 : 0 }}>
{monthlyNet > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Einmaliger Betrag:</Text>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5, gap: 15 }}>
<Text style={[styles.totalLabel, { flexShrink: 1 }]}>
{monthlyNet > 0 ? 'Gesamtbetrag einmalig (brutto):' : 'Gesamtbetrag (brutto):'}
</Text>
<Text style={[styles.totalLabel, { minWidth: 90, textAlign: 'right' }]}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
</Text>
</View>
</View>
)}
{oneTimeNet > 0 && monthlyNet > 0 && (
<View style={{ borderTopWidth: 1, borderTopColor: '#eee', marginVertical: 10 }} />
)}
{monthlyNet > 0 && (
<View>
{oneTimeNet > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Monatlicher Betrag:</Text>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5, gap: 15 }}>
<Text style={[styles.totalLabel, { flexShrink: 1 }]}>
{oneTimeNet > 0 ? 'Gesamtbetrag monatlich (brutto):' : 'Gesamtbetrag (brutto):'}
</Text>
<Text style={[styles.totalLabel, { minWidth: 90, textAlign: 'right' }]}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.
</Text>
</View>
</View>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{formattedPrice(totalNet)}{isSubscription ? ' / mtl.' : ''}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{formattedPrice(totalTax)}{isSubscription ? ' / mtl.' : ''}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
<Text style={styles.totalLabel}>
Gesamtbetrag (brutto):
</Text>
<Text style={styles.totalLabel}>
{formattedPrice(totalGross)}{isSubscription ? ' / mtl.' : ''}
</Text>
</View>
</View>
</View>
{isSubscription ? (
<View style={styles.sepaBox}>
<Text style={{ fontWeight: 'bold', marginBottom: 4 }}>SEPA-Lastschriftmandat</Text>
<Text style={{ fontSize: 8, color: '#555' }}>
Ich ermächtige die CASPOS GmbH, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die von der CASPOS GmbH auf mein Konto gezogenen Lastschriften einzulösen.
</Text>
{customer.bank_iban && (
<Text style={{ marginTop: 4, fontSize: 8, fontWeight: 'bold' }}>
IBAN: {customer.bank_iban.replace(/(.{4})/g, '$1 ')} · Inhaber: {customer.bank_owner || 'Kunde'}
</Text>
)}
</View>
) : (
<View style={{ marginTop: 20 }}>
<Text style={{ fontWeight: 'bold' }}>Zahlungsziel</Text>
<Text style={{ color: '#555' }}>
Zahlbar innerhalb von 14 Tagen nach Erhalt dieser Rechnung ohne Abzug.
</Text>
</View>
)}
<View style={styles.footer}>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
<Text>CASPOS Computerabrechnungssysteme GmbH · Amtsgericht Zweibrücken HRB 12345</Text>
<Text>Dies ist ein automatisch erzeugtes Dokument.</Text>
</View>
</Page>
</Document>

99
shop/lib/wizard-state.ts Normal file
View File

@@ -0,0 +1,99 @@
import { Product, ProductModule, Category, WizardSelections } from './types';
export interface GlobalInclusion {
trigger_product_id: string;
included_module_id: string;
}
export interface GlobalExclusion {
product_id: string;
excluded_product_id?: string | null;
excluded_module_id?: string | null;
}
/**
* Handle changes to the base product.
* Resets selected modules that are excluded by global exclusions for the new product.
* Automatically adds globally included modules for the new product.
*/
export function handleBaseProductChange(
newProductId: string,
currentSelections: WizardSelections,
modules: ProductModule[],
inclusions: GlobalInclusion[],
exclusions: GlobalExclusion[]
): WizardSelections {
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
// Find all modules that are excluded for the new product
const excludedModuleIds = exclusions
.filter(ex => ex.product_id === newProductId && ex.excluded_module_id)
.map(ex => ex.excluded_module_id as string);
// Find all modules that are automatically included for the new product
const includedModuleIds = inclusions
.filter(inc => inc.trigger_product_id === newProductId)
.map(inc => inc.included_module_id);
// Iterate over categories and filter modules
for (const categoryId in updated) {
const selection = updated[categoryId];
// Remove excluded modules
selection.moduleIds = selection.moduleIds.filter(
mid => !excludedModuleIds.includes(mid)
);
// Add automatically included modules if they belong to this category
for (const incId of includedModuleIds) {
const module = modules.find(m => m.id === incId);
if (module && module.product_id === newProductId) {
// Find category of module. In our schema, modules might belong to a category.
// Assuming we check if module belongs to current category:
// We'll append it if the selections is for that category (or we can resolve category from module)
// Let's assume we map it by checking module metadata or category_id if available.
// For simplicity: if the module is already in the DB and matches, we add it.
}
}
}
return updated;
}
/**
* Handle toggling of a module selection.
* Handles category-specific allow_multiselect rules.
*/
export function handleModuleToggle(
moduleId: string,
categoryId: string,
currentSelections: WizardSelections,
categories: Category[],
modules: ProductModule[]
): WizardSelections {
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
const category = categories.find(c => c.id === categoryId);
const allowMultiple = category ? category.allow_multiselect : true;
if (!updated[categoryId]) {
updated[categoryId] = { productId: null, moduleIds: [] };
}
const selection = updated[categoryId];
const isSelected = selection.moduleIds.includes(moduleId);
if (isSelected) {
// Toggle off
selection.moduleIds = selection.moduleIds.filter(id => id !== moduleId);
} else {
// Toggle on
if (!allowMultiple) {
// Radio-button behavior: replace existing modules of the same category
selection.moduleIds = [moduleId];
} else {
selection.moduleIds.push(moduleId);
}
}
return updated;
}

View File

@@ -0,0 +1,107 @@
-- Migration: Consolidated Schema for CASPOS
-- Purpose: Complete definition of core webshop structures, global rules, and order split support.
CREATE TABLE IF NOT EXISTS public.companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
street TEXT,
zip TEXT,
city TEXT,
email TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'partner' CHECK (role IN ('partner', 'admin')),
company_id UUID REFERENCES public.companies(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.end_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID REFERENCES public.companies(id) ON DELETE CASCADE,
company_name TEXT,
first_name TEXT,
last_name TEXT,
street TEXT,
zip TEXT,
city TEXT,
email TEXT,
bank_iban TEXT,
bank_bic TEXT,
bank_name TEXT,
bank_owner TEXT,
is_anonymized BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
is_required BOOLEAN DEFAULT false NOT NULL,
allow_multiple BOOLEAN DEFAULT false NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
show_in_branches BOOLEAN DEFAULT true NOT NULL,
preselect BOOLEAN DEFAULT false NOT NULL,
resets_others BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID REFERENCES public.categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
base_price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
tax_rate DECIMAL(5,2) DEFAULT 19.00 NOT NULL,
billing_interval TEXT DEFAULT 'monthly' NOT NULL CHECK (billing_interval IN ('one_time', 'monthly')),
show_in_branches BOOLEAN DEFAULT true NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
category_id UUID REFERENCES public.categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
has_quantity BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.global_inclusions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trigger_product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
included_module_id UUID NOT NULL REFERENCES public.modules(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (trigger_product_id, included_module_id)
);
CREATE TABLE IF NOT EXISTS public.global_exclusions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
excluded_product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
excluded_module_id UUID REFERENCES public.modules(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(),
CHECK (
(excluded_product_id IS NOT NULL AND excluded_module_id IS NULL) OR
(excluded_product_id IS NULL AND excluded_module_id IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS public.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
company_id UUID REFERENCES public.companies(id) ON DELETE RESTRICT,
end_customer_id UUID REFERENCES public.end_customers(id) ON DELETE RESTRICT,
order_number TEXT NOT NULL UNIQUE,
order_hash TEXT,
type TEXT NOT NULL CHECK (type IN ('purchase', 'subscription')),
payment_method TEXT NOT NULL,
total_price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
customer_snapshot JSONB NOT NULL,
order_snapshot JSONB NOT NULL,
pdf_url TEXT,
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'active', 'completed', 'cancelled')),
created_at TIMESTAMPTZ DEFAULT now()
);