{/* Progress Stepper */}
diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts
index fd48449..1703d36 100644
--- a/shop/lib/actions/orders.ts
+++ b/shop/lib/actions/orders.ts
@@ -5,89 +5,74 @@ import { revalidatePath } from 'next/cache'
import { InvoicePDF } from '@/components/invoice-pdf'
import { renderToBuffer } from '@react-pdf/renderer'
import React from 'react'
+import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
+import type { Category, Order, Product, Profile, WizardSelections } from '@/lib/types'
-export async function submitOrder(orderData: {
- product_id: string;
- selected_modules: string[];
- total_amount: number;
- customer_details: any;
-}) {
+// ─── submitOrder ──────────────────────────────────────────────────────────────
+
+/**
+ * Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
+ * generiert die PDF-Rechnung und lädt sie in den Supabase Storage hoch.
+ */
+export async function submitOrder(params: {
+ selections: WizardSelections
+ products: Product[]
+ categories: Category[]
+ customerProfile: Partial
+}): Promise {
+ const { selections, products, categories, customerProfile } = params
const supabase = await createClient()
-
+
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
- // 1. Fetch Product and Module Details for Invoice
- const { data: product } = await supabase
- .from('products')
- .select('*')
- .eq('id', orderData.product_id)
- .single()
+ // 1. Snapshots aufbauen
+ const customerSnapshot = buildCustomerSnapshot(customerProfile)
+ const orderSnapshot = buildOrderSnapshot(selections, products, categories)
- const { data: modules } = await supabase
- .from('product_modules')
- .select('*')
- .in('id', orderData.selected_modules)
-
- if (!product) throw new Error('Product not found')
-
- // 2. Create the Order in DB
+ // 2. Bestellung in DB schreiben
const { data: order, error: orderError } = await supabase
.from('orders')
.insert([{
user_id: user.id,
- total_price: orderData.total_amount, // Matched column name in initial_schema.sql
- order_data: {
- product_id: orderData.product_id,
- modules: orderData.selected_modules
- },
- customer_data: orderData.customer_details,
- status: 'pending'
+ total_price: orderSnapshot.total,
+ customer_data: customerSnapshot,
+ order_data: orderSnapshot,
+ status: 'pending',
}])
.select()
.single()
if (orderError) throw orderError
- // 3. Generate PDF
+ // 3. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
try {
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
- order: order,
- product: product,
- modules: modules || [],
- customer: orderData.customer_details
+ order,
+ customer: customerSnapshot,
+ orderSnapshot,
})
)
- // 4. Upload to Supabase Storage
+ // 4. PDF in Supabase Storage hochladen
const fileName = `invoice_${order.id}.pdf`
- const { data: uploadData, error: uploadError } = await supabase
+ const { error: uploadError } = await supabase
.storage
.from('invoices')
- .upload(fileName, buffer, {
- contentType: 'application/pdf',
- upsert: true
- })
+ .upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
if (uploadError) {
console.error('PDF Upload Error:', uploadError)
} else {
- // 5. Update Order with PDF URL
- const { data: publicUrlData } = supabase
- .storage
- .from('invoices')
- .getPublicUrl(fileName)
-
- await supabase
- .from('orders')
- .update({ pdf_url: publicUrlData.publicUrl })
- .eq('id', order.id)
+ const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
+ await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
}
} catch (pdfError) {
console.error('PDF Generation Error:', pdfError)
}
revalidatePath('/protected')
- return order
+ return order as Order
}
+
diff --git a/shop/lib/actions/products.ts b/shop/lib/actions/products.ts
index ffb8542..bf4f218 100644
--- a/shop/lib/actions/products.ts
+++ b/shop/lib/actions/products.ts
@@ -2,7 +2,7 @@
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
-import { Product, ProductModule } from '../types'
+import { Category, Product, ProductModule } from '../types'
export async function getProducts() {
const supabase = await createClient()
@@ -109,7 +109,6 @@ export async function updateProduct(id: string, product: Partial, modul
name: m.name,
description: m.description || null,
price: m.price,
- is_required: m.is_required,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
product_id: id
diff --git a/shop/lib/license-transform.ts b/shop/lib/license-transform.ts
new file mode 100644
index 0000000..bd3106a
--- /dev/null
+++ b/shop/lib/license-transform.ts
@@ -0,0 +1,242 @@
+/**
+ * license-transform.ts
+ *
+ * Transformationsschicht: Wandelt den Wizard-Zustand in strukturierte
+ * Datenstrukturen für DB-Speicherung (OrderSnapshot) und Lizenzserver (LicenseBundle) um.
+ */
+
+import type {
+ Category,
+ CategorySelection,
+ CustomerSnapshot,
+ LicenseBundle,
+ LicenseOption,
+ OrderItem,
+ OrderSnapshot,
+ Product,
+ Profile,
+ WizardSelections,
+} from '@/lib/types'
+
+// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
+
+/**
+ * Wandelt einen Produktnamen in einen stabilen, URL-sicheren Slug um.
+ * Beispiel: "CASPOS Basic" → "caspos_basic"
+ */
+export function slugify(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/ä/g, 'ae')
+ .replace(/ö/g, 'oe')
+ .replace(/ü/g, 'ue')
+ .replace(/ß/g, 'ss')
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_|_$/g, '')
+}
+
+/**
+ * Baut einen validen CustomerSnapshot aus einem Profil oder Formulardaten.
+ * Fehlende Pflichtfelder werden mit leerem String aufgefüllt (kein throw –
+ * Validierung findet im Wizard-UI statt).
+ */
+export function buildCustomerSnapshot(profile: Partial): CustomerSnapshot {
+ return {
+ schema_version: 1,
+ company_name: profile.company_name ?? '',
+ vat_id: profile.vat_id ?? '',
+ first_name: profile.first_name ?? '',
+ last_name: profile.last_name ?? '',
+ address: profile.address ?? '',
+ zip_code: profile.zip_code ?? '',
+ city: profile.city ?? '',
+ ...(profile.email ? { email: profile.email } : {}),
+ }
+}
+
+// ─── OrderSnapshot ────────────────────────────────────────────────────────────
+
+/**
+ * Baut den unveränderlichen OrderSnapshot für die JSONB-Spalte `orders.order_data`.
+ *
+ * WICHTIG: Alle Namen und Preise werden hier eingefroren (Snapshot-Prinzip).
+ * Spätere Änderungen am Produktkatalog brechen keine historischen Bestellungen.
+ */
+export function buildOrderSnapshot(
+ selections: WizardSelections,
+ products: Product[],
+ categories: Category[]
+): OrderSnapshot {
+ const items: OrderItem[] = []
+ let subtotal = 0
+ let dominantTaxRate = 19 // Fallback; wird durch das erste Produkt mit tax_rate überschrieben
+
+ for (const cat of categories) {
+ const sel: CategorySelection | undefined = selections[cat.id]
+ if (!sel?.productId) continue
+
+ const prod = products.find((p) => p.id === sel.productId)
+ if (!prod) continue
+
+ dominantTaxRate = prod.tax_rate
+
+ const selectedModules = sel.moduleIds
+ .map((modId) => prod.modules?.find((m) => m.id === modId))
+ .filter((m): m is NonNullable => !!m)
+ .map((mod) => ({
+ module_id: mod.id,
+ module_name: mod.name,
+ price: mod.price,
+ }))
+
+ const moduleTotal = selectedModules.reduce((acc, m) => acc + m.price, 0)
+ const itemTotal = prod.base_price + moduleTotal
+ subtotal += itemTotal
+
+ items.push({
+ category_id: cat.id,
+ category_name: cat.name,
+ product_id: prod.id,
+ product_name: prod.name,
+ base_price: prod.base_price,
+ billing_interval: prod.billing_interval,
+ selected_modules: selectedModules,
+ item_total: itemTotal,
+ })
+ }
+
+ const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100
+ const total = Math.round((subtotal + taxAmount) * 100) / 100
+
+ // Wenn alle Items one_time sind → billing_cycle = 'one_time', sonst 'monthly'
+ const billingCycle = items.every((i) => i.billing_interval === 'one_time')
+ ? 'one_time'
+ : 'monthly'
+
+ return {
+ schema_version: 1,
+ billing_cycle: billingCycle,
+ items,
+ subtotal,
+ tax_rate: dominantTaxRate,
+ tax_amount: taxAmount,
+ total,
+ }
+}
+
+// ─── LicenseBundle ────────────────────────────────────────────────────────────
+
+/**
+ * Transformiert einen gespeicherten OrderSnapshot (oder den aktuellen Wizard-Zustand)
+ * in ein LicenseBundle für den Lizenzserver / das ERP-System.
+ *
+ * Jedes Produkt und jedes Modul wird auf einen stabilen `key` (Slug) gemappt,
+ * der als Identifier im Lizenzserver verwendet werden kann.
+ */
+export function transformToLicenseBundle(
+ selections: WizardSelections,
+ products: Product[],
+ categories: Category[],
+ customer: Partial
+): LicenseBundle {
+ const options: LicenseOption[] = []
+ let monthlyTotal = 0
+ let oneTimeTotal = 0
+
+ for (const cat of categories) {
+ const sel: CategorySelection | undefined = selections[cat.id]
+ if (!sel?.productId) continue
+
+ const prod = products.find((p) => p.id === sel.productId)
+ if (!prod) continue
+
+ const prodKey = slugify(prod.name)
+
+ options.push({
+ key: prodKey,
+ label: prod.name,
+ active: true,
+ billing: prod.billing_interval,
+ price_net: prod.base_price,
+ })
+
+ if (prod.billing_interval === 'monthly') monthlyTotal += prod.base_price
+ else oneTimeTotal += prod.base_price
+
+ for (const modId of sel.moduleIds) {
+ const mod = prod.modules?.find((m) => m.id === modId)
+ if (!mod) continue
+
+ options.push({
+ key: `${prodKey}__${slugify(mod.name)}`,
+ label: mod.name,
+ active: true,
+ // Module erben das Billing-Intervall des übergeordneten Produkts
+ billing: prod.billing_interval,
+ price_net: mod.price,
+ })
+
+ if (prod.billing_interval === 'monthly') monthlyTotal += mod.price
+ else oneTimeTotal += mod.price
+ }
+ }
+
+ return {
+ customer_ref: customer.company_name ?? customer.last_name ?? 'unknown',
+ generated_at: new Date().toISOString(),
+ license_key_prefix: `CASP-${new Date().getFullYear()}-`,
+ options,
+ monthly_total_net: monthlyTotal,
+ one_time_total_net: oneTimeTotal,
+ }
+}
+
+/**
+ * Rekonstruiert ein LicenseBundle direkt aus einem gespeicherten OrderSnapshot
+ * (ohne Zugriff auf den Live-Katalog – gut für historische Bestellungen).
+ */
+export function licenseBundleFromSnapshot(
+ snapshot: OrderSnapshot,
+ customerRef: string
+): LicenseBundle {
+ const options: LicenseOption[] = []
+ let monthlyTotal = 0
+ let oneTimeTotal = 0
+
+ for (const item of snapshot.items) {
+ const prodKey = slugify(item.product_name)
+
+ options.push({
+ key: prodKey,
+ label: item.product_name,
+ active: true,
+ billing: item.billing_interval,
+ price_net: item.base_price,
+ })
+
+ if (item.billing_interval === 'monthly') monthlyTotal += item.base_price
+ else oneTimeTotal += item.base_price
+
+ for (const mod of item.selected_modules) {
+ options.push({
+ key: `${prodKey}__${slugify(mod.module_name)}`,
+ label: mod.module_name,
+ active: true,
+ billing: item.billing_interval,
+ price_net: mod.price,
+ })
+
+ if (item.billing_interval === 'monthly') monthlyTotal += mod.price
+ else oneTimeTotal += mod.price
+ }
+ }
+
+ return {
+ customer_ref: customerRef,
+ generated_at: new Date().toISOString(),
+ license_key_prefix: `CASP-${new Date().getFullYear()}-`,
+ options,
+ monthly_total_net: monthlyTotal,
+ one_time_total_net: oneTimeTotal,
+ }
+}
diff --git a/shop/lib/types.ts b/shop/lib/types.ts
index a3be56f..4143655 100644
--- a/shop/lib/types.ts
+++ b/shop/lib/types.ts
@@ -1,59 +1,143 @@
+// ─── Katalog (gelesen aus Supabase) ──────────────────────────────────────────
+
+export type BillingInterval = 'one_time' | 'monthly'
+
export type Product = {
- id: string;
- name: string;
- description?: string | null | undefined;
- base_price: number;
- is_active: boolean;
- billing_interval: 'one_time' | 'monthly';
- created_at: string;
- updated_at: string;
- category_id?: string | null;
- category?: Category | null;
- modules?: ProductModule[];
-};
+ id: string
+ name: string
+ description?: string | null
+ base_price: number
+ tax_rate: number
+ billing_interval: BillingInterval
+ category_id?: string | null
+ category?: Category | null
+ modules?: ProductModule[]
+ created_at: string
+}
export type Category = {
- id: string;
- name: string;
- description?: string | null;
- icon?: string | null;
- sort_order: number;
- is_required: boolean;
- created_at: string;
-};
+ id: string
+ name: string
+ description?: string | null
+ icon?: string | null
+ sort_order: number
+ is_required: boolean
+ created_at: string
+}
export type ProductModule = {
- id: string;
- product_id: string;
- name: string;
- description?: string | null | undefined;
- price: number;
- is_required: boolean;
- is_active?: boolean;
- requirements: string[];
- exclusions: string[];
- created_at: string;
-};
+ id: string
+ product_id: string
+ name: string
+ description?: string | null
+ price: number
+ requirements: string[] // UUID[] anderer Module, die aktiv sein müssen
+ exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen
+ created_at: string
+}
export type Profile = {
- id: string;
- company_name: string | null;
- vat_id: string | null;
- first_name: string | null;
- last_name: string | null;
- address: string | null;
- city: string | null;
- zip_code: string | null;
- country: string;
- updated_at: string;
-};
+ id: string
+ company_name: string | null
+ vat_id: string | null
+ first_name: string | null
+ last_name: string | null
+ address: string | null
+ city: string | null
+ zip_code: string | null
+ country: string
+ email?: string | null
+ updated_at: string
+}
+
+// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
+
+export type CategorySelection = {
+ productId: string | null
+ moduleIds: string[]
+}
+
+/** key = category_id */
+export type WizardSelections = Record
+
+// ─── Versioned Snapshots (unveränderlich in JSONB gespeichert) ────────────────
+
+/**
+ * Kundendaten zum Zeitpunkt der Bestellung.
+ * schema_version erlaubt spätere Migrationsfunktionen ohne DB-Änderung.
+ */
+export type CustomerSnapshot = {
+ schema_version: 1
+ company_name: string
+ vat_id: string
+ first_name: string
+ last_name: string
+ address: string
+ zip_code: string
+ city: string
+ email?: string
+}
+
+export type OrderModuleSnapshot = {
+ module_id: string
+ module_name: string
+ price: number
+}
+
+export type OrderItem = {
+ category_id: string
+ category_name: string
+ product_id: string
+ product_name: string
+ base_price: number
+ billing_interval: BillingInterval
+ selected_modules: OrderModuleSnapshot[]
+ item_total: number
+}
+
+/**
+ * Vollständiger Bestell-Snapshot – wird in orders.order_data (JSONB) gespeichert.
+ */
+export type OrderSnapshot = {
+ schema_version: 1
+ billing_cycle: BillingInterval
+ items: OrderItem[]
+ subtotal: number
+ tax_rate: number
+ tax_amount: number
+ total: number
+}
+
+// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
export type Order = {
- id: string;
- user_id: string;
- status: 'pending' | 'completed' | 'cancelled';
- total_amount: number;
- items: any; // Snapshot of product and modules
- billing_details: any;
- created_at: string;
-};
+ id: string
+ user_id: string | null
+ customer_data: CustomerSnapshot
+ order_data: OrderSnapshot
+ total_price: number
+ pdf_url: string | null
+ status: 'pending' | 'active' | 'cancelled'
+ created_at: string
+}
+
+// ─── Lizenz-Output (für Lizenzserver / ERP) ───────────────────────────────────
+
+export type LicenseOption = {
+ /** Stabiler Slug-Key, z. B. "caspos_basic__backoffice" */
+ key: string
+ label: string
+ active: boolean
+ billing: BillingInterval
+ price_net: number
+}
+
+export type LicenseBundle = {
+ customer_ref: string
+ generated_at: string
+ license_key_prefix: string
+ options: LicenseOption[]
+ monthly_total_net: number
+ one_time_total_net: number
+}
+