Files
webshop/shop/lib/license-transform.ts
DanielS 1a2ace937c
Some checks failed
Staging Build / build (push) Has been cancelled
feat: add update price modifier for existing customers based on last license date
2026-06-25 23:04:48 +02:00

307 lines
9.3 KiB
TypeScript
Raw 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[],
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?.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) => {
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,
})
}
// 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
if (lastLicenseDate && billingCycle === 'one_time') {
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 item_total in items by multiplier
items.forEach(item => {
item.item_total = item.item_total * multiplier
})
subtotal = subtotal * multiplier
}
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 && billingCycle === 'one_time' ? 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,
}
}