Compare commits
7 Commits
6b35917a06
...
cb12a6ced4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb12a6ced4 | ||
|
|
66039eb4cf | ||
|
|
5eef6c76ef | ||
|
|
f71fc7f88d | ||
|
|
60e64c689d | ||
|
|
bd7d5dd1b2 | ||
|
|
54b9dea70f |
13
.agents/skills/grand-functions/SKILL.md
Normal file
13
.agents/skills/grand-functions/SKILL.md
Normal 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)
|
||||
16
.agents/skills/grand-functions/references/overviews.md
Normal file
16
.agents/skills/grand-functions/references/overviews.md
Normal 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.
|
||||
73
.agents/skills/grand-functions/references/processes.md
Normal file
73
.agents/skills/grand-functions/references/processes.md
Normal 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.
|
||||
|
||||
|
||||
84
.agents/skills/grand-functions/references/schema.md
Normal file
84
.agents/skills/grand-functions/references/schema.md
Normal 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)
|
||||
15
.agents/skills/grand-functions/references/security.md
Normal file
15
.agents/skills/grand-functions/references/security.md
Normal 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.
|
||||
144
.agentsspec
144
.agentsspec
@@ -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**:
|
||||
165
shop/app/api/orders/checkout/route.ts
Normal file
165
shop/app/api/orders/checkout/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
99
shop/lib/wizard-state.ts
Normal 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;
|
||||
}
|
||||
107
shop/supabase/migrations/20260709220000_consolidated_schema.sql
Normal file
107
shop/supabase/migrations/20260709220000_consolidated_schema.sql
Normal 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()
|
||||
);
|
||||
Reference in New Issue
Block a user