Implement order data model modernization and license transformation logic

This commit is contained in:
DanielS
2026-05-03 20:13:36 +02:00
parent f947798bf6
commit 5c393ecb57
7 changed files with 422 additions and 132 deletions

View File

@@ -29,7 +29,7 @@ import { Plus, Trash2, X, PlusCircle } from 'lucide-react'
import { createProduct, updateProduct } from '@/lib/actions/products'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Category } from '@/lib/types'
import { Category, Product } from '@/lib/types'
import {
Select,
SelectContent,
@@ -52,7 +52,6 @@ const productSchema = z.object({
description: z.string().optional(),
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
is_active: z.boolean().default(true),
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
modules: z.array(moduleSchema).default([]),
})
@@ -69,13 +68,12 @@ export function CreateProductDialog({ children, categories, product }: { childre
description: product?.description || '',
base_price: product?.base_price || 0,
category_id: product?.category_id || '',
is_active: product?.is_active ?? true,
billing_interval: product?.billing_interval || 'monthly',
modules: product?.modules?.map(m => ({
name: m.name,
description: m.description || '',
price: m.price,
is_required: m.is_required,
is_required: false,
requirements: m.requirements?.join(', ') || '',
exclusions: m.exclusions?.join(', ') || '',
})) || [],

View File

@@ -1,6 +1,6 @@
'use client'
import { Product } from '@/lib/types'
import { Product, Category } from '@/lib/types'
import {
Table,
TableBody,
@@ -16,7 +16,6 @@ import { Button } from '@/components/ui/button'
import { deleteProduct } from '@/lib/actions/products'
import { useState } from 'react'
import { CreateProductDialog } from './create-product-dialog'
import { Category } from '@/lib/types'
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
const [products, setProducts] = useState(initialProducts)
@@ -77,8 +76,8 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
</div>
</TableCell>
<TableCell>
<Badge variant={product.is_active ? "default" : "secondary"}>
{product.is_active ? "Aktiv" : "Inaktiv"}
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
</Badge>
</TableCell>
<TableCell className="text-right">

View File

@@ -146,29 +146,11 @@ export function OrderWizard({
const handleSubmit = async () => {
try {
// Build multi-product order payload submit each category item
const items = categories
.map(cat => {
const sel = selections[cat.id]
if (!sel?.productId) return null
const prod = products.find(p => p.id === sel.productId)!
return {
category: cat.name,
product_id: sel.productId,
product_name: prod.name,
base_price: prod.base_price,
selected_modules: sel.moduleIds,
}
})
.filter(Boolean)
await submitOrder({
product_id: items[0]!.product_id, // primary (first category)
selected_modules: items.flatMap(i => i!.selected_modules),
total_amount: totalPrice,
customer_details: customerData,
// @ts-ignore extended payload
items,
selections,
products,
categories,
customerProfile: customerData,
})
alert('Bestellung erfolgreich aufgegeben!')
window.location.href = '/'
@@ -178,6 +160,7 @@ export function OrderWizard({
}
}
return (
<div className="max-w-5xl mx-auto py-12 px-4">
{/* Progress Stepper */}

View File

@@ -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<Profile>
}): Promise<Order> {
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
}

View File

@@ -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<Product>, 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

View File

@@ -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<Profile>): 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<typeof m> => !!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<Profile>
): 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,
}
}

View File

@@ -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<string, CategorySelection>
// ─── 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
}