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({
|
const styles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
padding: 50,
|
padding: 50,
|
||||||
@@ -31,7 +30,6 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
|
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -63,16 +61,11 @@ const styles = StyleSheet.create({
|
|||||||
padding: 8,
|
padding: 8,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
},
|
},
|
||||||
tableCell: {
|
|
||||||
margin: 'auto',
|
|
||||||
marginTop: 5,
|
|
||||||
fontSize: 10,
|
|
||||||
},
|
|
||||||
total: {
|
total: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
borderTop: 1,
|
borderTopWidth: 1,
|
||||||
borderColor: '#000',
|
borderColor: '#000',
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
},
|
},
|
||||||
@@ -88,26 +81,30 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#999',
|
color: '#999',
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
borderTop: 1,
|
borderTopWidth: 1,
|
||||||
borderColor: '#eee',
|
borderColor: '#eee',
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
},
|
},
|
||||||
|
sepaBox: {
|
||||||
|
marginTop: 15,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||||
const items = orderSnapshot?.items ?? [];
|
const items = orderSnapshot?.items ?? [];
|
||||||
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
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 totalNet = items.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
|
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 formattedPrice = (val: number) =>
|
||||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
@@ -125,54 +122,26 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.title}>Anfragebestätigung</Text>
|
<Text style={styles.title}>
|
||||||
|
{isSubscription ? 'Vertragsbestätigung (Abonnement)' : 'B2B-Rechnung'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.label}>Anfragender Kunde</Text>
|
<Text style={styles.label}>Kunde</Text>
|
||||||
<Text>{customer.company_name}</Text>
|
<Text>{customer.company_name}</Text>
|
||||||
<Text>{customer.first_name} {customer.last_name}</Text>
|
<Text>{customer.first_name} {customer.last_name}</Text>
|
||||||
<Text>{customer.address}</Text>
|
<Text>{customer.address}</Text>
|
||||||
<Text>{customer.zip_code} {customer.city}</Text>
|
<Text>{customer.zip_code} {customer.city}</Text>
|
||||||
<Text>USt-IdNr: {customer.vat_id}</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>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.label}>Anfragedetails</Text>
|
<Text style={styles.label}>Bestelldetails</Text>
|
||||||
<Text>Anfrage-Nummer: {order.order_number || order.id}</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>
|
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||||
</View>
|
</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.table}>
|
||||||
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||||
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||||
@@ -187,25 +156,23 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.tableColPrice}>
|
<View style={styles.tableColPrice}>
|
||||||
<Text>
|
<Text>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
{formattedPrice(item.base_price)} {isSubscription ? 'mtl.' : 'einmalig'}
|
||||||
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{item.selected_modules?.map((mod: any, mIdx: number) => {
|
{item.selected_modules?.map((mod: any, mIdx: number) => {
|
||||||
const qty = mod.quantity || 1;
|
const qty = mod.quantity || 1;
|
||||||
const price = mod.total_price ?? (mod.price * qty);
|
const price = mod.total_price ?? (mod.price * qty);
|
||||||
const hasQty = qty > 1;
|
|
||||||
return (
|
return (
|
||||||
<View key={mIdx} style={styles.tableRow}>
|
<View key={mIdx} style={styles.tableRow}>
|
||||||
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
||||||
<Text style={{ color: '#666' }}>
|
<Text style={{ color: '#666' }}>
|
||||||
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
|
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.tableColPrice}>
|
<View style={styles.tableColPrice}>
|
||||||
<Text style={{ color: '#666' }}>
|
<Text style={{ color: '#666' }}>
|
||||||
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
|
+{formattedPrice(price)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -217,63 +184,49 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
|||||||
|
|
||||||
<View style={styles.total}>
|
<View style={styles.total}>
|
||||||
<View style={{ width: '70%' }}>
|
<View style={{ width: '70%' }}>
|
||||||
{oneTimeNet > 0 && (
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<View style={{ marginBottom: monthlyNet > 0 ? 10 : 0 }}>
|
<Text>Netto-Zwischensumme:</Text>
|
||||||
{monthlyNet > 0 && (
|
<Text>{formattedPrice(totalNet)}{isSubscription ? ' / mtl.' : ''}</Text>
|
||||||
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Einmaliger Betrag:</Text>
|
</View>
|
||||||
)}
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
<Text>zzgl. {taxRate}% USt:</Text>
|
||||||
<Text>Netto-Zwischensumme:</Text>
|
<Text>{formattedPrice(totalTax)}{isSubscription ? ' / mtl.' : ''}</Text>
|
||||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</Text>
|
</View>
|
||||||
</View>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
<Text style={styles.totalLabel}>
|
||||||
<Text>zzgl. {taxRate}% USt:</Text>
|
Gesamtbetrag (brutto):
|
||||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</Text>
|
</Text>
|
||||||
</View>
|
<Text style={styles.totalLabel}>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5, gap: 15 }}>
|
{formattedPrice(totalGross)}{isSubscription ? ' / mtl.' : ''}
|
||||||
<Text style={[styles.totalLabel, { flexShrink: 1 }]}>
|
</Text>
|
||||||
{monthlyNet > 0 ? 'Gesamtbetrag einmalig (brutto):' : 'Gesamtbetrag (brutto):'}
|
</View>
|
||||||
</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>
|
</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}>
|
<View style={styles.footer}>
|
||||||
<Text>Vielen Dank für Ihre Bestellung!</Text>
|
<Text>CASPOS Computerabrechnungssysteme GmbH · Amtsgericht Zweibrücken HRB 12345</Text>
|
||||||
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
|
<Text>Dies ist ein automatisch erzeugtes Dokument.</Text>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</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