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

View File

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

View File

@@ -146,29 +146,11 @@ export function OrderWizard({
const handleSubmit = async () => { const handleSubmit = async () => {
try { 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({ await submitOrder({
product_id: items[0]!.product_id, // primary (first category) selections,
selected_modules: items.flatMap(i => i!.selected_modules), products,
total_amount: totalPrice, categories,
customer_details: customerData, customerProfile: customerData,
// @ts-ignore extended payload
items,
}) })
alert('Bestellung erfolgreich aufgegeben!') alert('Bestellung erfolgreich aufgegeben!')
window.location.href = '/' window.location.href = '/'
@@ -178,6 +160,7 @@ export function OrderWizard({
} }
} }
return ( return (
<div className="max-w-5xl mx-auto py-12 px-4"> <div className="max-w-5xl mx-auto py-12 px-4">
{/* Progress Stepper */} {/* Progress Stepper */}

View File

@@ -5,89 +5,74 @@ import { revalidatePath } from 'next/cache'
import { InvoicePDF } from '@/components/invoice-pdf' import { InvoicePDF } from '@/components/invoice-pdf'
import { renderToBuffer } from '@react-pdf/renderer' import { renderToBuffer } from '@react-pdf/renderer'
import React from 'react' 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: { // ─── submitOrder ──────────────────────────────────────────────────────────────
product_id: string;
selected_modules: string[]; /**
total_amount: number; * Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
customer_details: any; * 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 supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated') if (!user) throw new Error('Not authenticated')
// 1. Fetch Product and Module Details for Invoice // 1. Snapshots aufbauen
const { data: product } = await supabase const customerSnapshot = buildCustomerSnapshot(customerProfile)
.from('products') const orderSnapshot = buildOrderSnapshot(selections, products, categories)
.select('*')
.eq('id', orderData.product_id)
.single()
const { data: modules } = await supabase // 2. Bestellung in DB schreiben
.from('product_modules')
.select('*')
.in('id', orderData.selected_modules)
if (!product) throw new Error('Product not found')
// 2. Create the Order in DB
const { data: order, error: orderError } = await supabase const { data: order, error: orderError } = await supabase
.from('orders') .from('orders')
.insert([{ .insert([{
user_id: user.id, user_id: user.id,
total_price: orderData.total_amount, // Matched column name in initial_schema.sql total_price: orderSnapshot.total,
order_data: { customer_data: customerSnapshot,
product_id: orderData.product_id, order_data: orderSnapshot,
modules: orderData.selected_modules status: 'pending',
},
customer_data: orderData.customer_details,
status: 'pending'
}]) }])
.select() .select()
.single() .single()
if (orderError) throw orderError if (orderError) throw orderError
// 3. Generate PDF // 3. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
try { try {
const buffer = await renderToBuffer( const buffer = await renderToBuffer(
React.createElement(InvoicePDF, { React.createElement(InvoicePDF, {
order: order, order,
product: product, customer: customerSnapshot,
modules: modules || [], orderSnapshot,
customer: orderData.customer_details
}) })
) )
// 4. Upload to Supabase Storage // 4. PDF in Supabase Storage hochladen
const fileName = `invoice_${order.id}.pdf` const fileName = `invoice_${order.id}.pdf`
const { data: uploadData, error: uploadError } = await supabase const { error: uploadError } = await supabase
.storage .storage
.from('invoices') .from('invoices')
.upload(fileName, buffer, { .upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
contentType: 'application/pdf',
upsert: true
})
if (uploadError) { if (uploadError) {
console.error('PDF Upload Error:', uploadError) console.error('PDF Upload Error:', uploadError)
} else { } else {
// 5. Update Order with PDF URL const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
const { data: publicUrlData } = supabase await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
.storage
.from('invoices')
.getPublicUrl(fileName)
await supabase
.from('orders')
.update({ pdf_url: publicUrlData.publicUrl })
.eq('id', order.id)
} }
} catch (pdfError) { } catch (pdfError) {
console.error('PDF Generation Error:', pdfError) console.error('PDF Generation Error:', pdfError)
} }
revalidatePath('/protected') revalidatePath('/protected')
return order return order as Order
} }

View File

@@ -2,7 +2,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { Product, ProductModule } from '../types' import { Category, Product, ProductModule } from '../types'
export async function getProducts() { export async function getProducts() {
const supabase = await createClient() const supabase = await createClient()
@@ -109,7 +109,6 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
name: m.name, name: m.name,
description: m.description || null, description: m.description || null,
price: m.price, price: m.price,
is_required: m.is_required,
requirements: m.requirements || [], requirements: m.requirements || [],
exclusions: m.exclusions || [], exclusions: m.exclusions || [],
product_id: id 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 = { export type Product = {
id: string; id: string
name: string; name: string
description?: string | null | undefined; description?: string | null
base_price: number; base_price: number
is_active: boolean; tax_rate: number
billing_interval: 'one_time' | 'monthly'; billing_interval: BillingInterval
created_at: string; category_id?: string | null
updated_at: string; category?: Category | null
category_id?: string | null; modules?: ProductModule[]
category?: Category | null; created_at: string
modules?: ProductModule[]; }
};
export type Category = { export type Category = {
id: string; id: string
name: string; name: string
description?: string | null; description?: string | null
icon?: string | null; icon?: string | null
sort_order: number; sort_order: number
is_required: boolean; is_required: boolean
created_at: string; created_at: string
}; }
export type ProductModule = { export type ProductModule = {
id: string; id: string
product_id: string; product_id: string
name: string; name: string
description?: string | null | undefined; description?: string | null
price: number; price: number
is_required: boolean; requirements: string[] // UUID[] anderer Module, die aktiv sein müssen
is_active?: boolean; exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen
requirements: string[]; created_at: string
exclusions: string[]; }
created_at: string;
};
export type Profile = { export type Profile = {
id: string; id: string
company_name: string | null; company_name: string | null
vat_id: string | null; vat_id: string | null
first_name: string | null; first_name: string | null
last_name: string | null; last_name: string | null
address: string | null; address: string | null
city: string | null; city: string | null
zip_code: string | null; zip_code: string | null
country: string; country: string
updated_at: 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 = { export type Order = {
id: string; id: string
user_id: string; user_id: string | null
status: 'pending' | 'completed' | 'cancelled'; customer_data: CustomerSnapshot
total_amount: number; order_data: OrderSnapshot
items: any; // Snapshot of product and modules total_price: number
billing_details: any; pdf_url: string | null
created_at: string; 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
}