333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
/**
|
||
* 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 ?? '',
|
||
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<string, number>,
|
||
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<typeof m> => !!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 ? "" : ""),
|
||
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<Profile>,
|
||
moduleQuantities?: Record<string, number>
|
||
): 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,
|
||
}
|
||
}
|