/** * 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 ?? '', bank_iban: endCustomer.bank_iban ?? undefined, bank_bic: endCustomer.bank_bic ?? undefined, bank_name: endCustomer.bank_name ?? undefined, bank_owner: endCustomer.bank_owner ?? undefined, } } 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, lastLicenseDate?: string | null ): 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) continue const selectedProds: Product[] = [] if (cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) { sel.productIds.forEach(pId => { const p = products.find(prod => prod.id === pId) if (p) selectedProds.push(p) }) } else if (sel.productId) { const p = products.find(prod => prod.id === sel.productId) if (p) selectedProds.push(p) } if (selectedProds.length === 0) continue // Apply free items limit: sort selected products by base_price ascending so that the cheapest are free const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price) const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0 sortedProds.forEach((prod, sortedIndex) => { 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) // If this product falls under the free limit, set its base price to 0 const actualBasePrice = (sortedIndex < freeLimit) ? 0 : prod.base_price const itemTotal = actualBasePrice + moduleTotal subtotal += itemTotal items.push({ category_id: cat.id, category_name: cat.name, product_id: prod.id, product_name: prod.name + (actualBasePrice === 0 ? " (Inklusive/Frei)" : ""), base_price: actualBasePrice, billing_interval: prod.billing_interval, selected_modules: selectedModules, item_total: itemTotal, }) }) } // Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln const billingCycle = billingInterval || (items.every((i) => i.billing_interval === 'one_time') ? 'one_time' : 'monthly') let multiplier = 1 const hasOneTime = items.some(item => item.billing_interval === 'one_time') if (lastLicenseDate && hasOneTime) { const lastDate = new Date(lastLicenseDate) const today = new Date() const yearsDiff = today.getFullYear() - lastDate.getFullYear() const monthsDiff = today.getMonth() - lastDate.getMonth() const months = yearsDiff * 12 + monthsDiff if (months <= 12) { multiplier = 0 } else if (months <= 14) { multiplier = 0.15 } else if (months <= 25) { multiplier = 0.30 } else if (months <= 37) { multiplier = 0.45 } else if (months <= 49) { multiplier = 0.60 } else { multiplier = 0.90 } // Multiply only one_time item totals by multiplier items.forEach(item => { if (item.billing_interval === 'one_time') { item.item_total = item.item_total * multiplier } }) subtotal = items.reduce((acc, item) => acc + item.item_total, 0) } const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100 const total = Math.round((subtotal + taxAmount) * 100) / 100 return { schema_version: 1, billing_cycle: billingCycle, items, subtotal, tax_rate: dominantTaxRate, tax_amount: taxAmount, total, last_license_date: lastLicenseDate || null, price_multiplier: lastLicenseDate && hasOneTime ? multiplier : null, } } // ─── 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, } }