Files
webshop/shop/lib/license-transform.ts
DanielS 27fe6c6eef
All checks were successful
Staging Build / build (push) Successful in 2m50s
feat(admin): move allow_update_discount option to product level
2026-07-10 09:15:34 +02:00

337 lines
11 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 ?? '',
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 if product allows it
items.forEach(item => {
if (item.billing_interval === 'one_time') {
const prod = products.find(p => p.id === item.product_id);
const allowDiscount = prod ? prod.allow_update_discount !== false : true;
if (allowDiscount) {
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,
}
}