/** * 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, EndCustomer, 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. * * Wenn ein Endkunde übergeben wird, werden DESSEN Daten eingefroren (für die Rechnung). * Ohne Endkunden-Übergabe: Partner-Profil als Fallback (Abwärtskompatibilität). * Fehlende Pflichtfelder werden mit leerem String aufgefüllt (Validierung im Wizard). */ export function buildCustomerSnapshot( profile: Partial, endCustomer?: EndCustomer | null ): CustomerSnapshot { if (endCustomer) { return { schema_version: 1, end_customer_id: endCustomer.id, company_name: endCustomer.company_name ?? '', vat_id: endCustomer.vat_id ?? '', first_name: endCustomer.first_name ?? '', last_name: endCustomer.last_name ?? '', address: endCustomer.street ?? '', zip_code: endCustomer.zip ?? '', city: endCustomer.city ?? '', } } 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[], billingInterval?: 'one_time' | 'monthly', moduleQuantities?: Record ): 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) => { const qty = moduleQuantities?.[mod.id] || 1 return { module_id: mod.id, module_name: mod.name, price: mod.price, quantity: qty, total_price: mod.price * qty, } }) const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || 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 billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln const billingCycle = billingInterval || (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, moduleQuantities?: Record ): 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 const qty = moduleQuantities?.[mod.id] || 1 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, quantity: qty, }) if (prod.billing_interval === 'monthly') monthlyTotal += mod.price * qty else oneTimeTotal += mod.price * qty } } 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) { const qty = mod.quantity || 1 const modPrice = mod.total_price || (mod.price * qty) options.push({ key: `${prodKey}__${slugify(mod.module_name)}`, label: mod.module_name, active: true, billing: item.billing_interval, price_net: mod.price, quantity: qty, }) if (item.billing_interval === 'monthly') monthlyTotal += modPrice else oneTimeTotal += modPrice } } 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, } }