Files
webshop/shop/lib/license-transform.ts

262 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Profile>,
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[]
): 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,
}
}