feat: add update price modifier for existing customers based on last license date
Some checks failed
Staging Build / build (push) Has been cancelled

This commit is contained in:
DanielS
2026-06-25 23:04:48 +02:00
parent 16fb713665
commit 1a2ace937c
6 changed files with 182 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight } from 'lucide-react' import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight, Calendar } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -110,6 +110,33 @@ export default async function OrderSuccessPage({
</span> </span>
</div> </div>
{o.order_data?.price_multiplier !== null && o.order_data?.price_multiplier !== undefined && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-3 rounded-lg border border-green-500/20 space-y-1">
<p>Update-Rabatt angewendet:</p>
<p className="text-xs font-normal text-slate-300">
{o.order_data.price_multiplier === 0
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
: o.order_data.price_multiplier === 0.15
? "Update (13-14 Monate): 15% vom Listenpreis"
: o.order_data.price_multiplier === 0.30
? "Update (15-25 Monate): 30% vom Listenpreis"
: o.order_data.price_multiplier === 0.45
? "Update (26-37 Monate): 45% vom Listenpreis"
: o.order_data.price_multiplier === 0.60
? "Update (38-49 Monate): 60% vom Listenpreis"
: o.order_data.price_multiplier === 0.90
? "Neukauf (>= 50 Monate): 10% Rabatt"
: `Gebühr: ${o.order_data.price_multiplier * 100}%`}
</p>
{o.order_data.last_license_date && (
<p className="text-xs font-normal text-slate-400 flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-primary" />
Letzte Lizenz vom: {new Date(o.order_data.last_license_date).toLocaleDateString('de-DE')}
</p>
)}
</div>
)}
<Separator className="bg-white/10" /> <Separator className="bg-white/10" />
{/* Kundendaten */} {/* Kundendaten */}

View File

@@ -134,6 +134,32 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text> <Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
</View> </View>
{orderSnapshot?.price_multiplier !== null && orderSnapshot?.price_multiplier !== undefined && (
<View style={[styles.section, { backgroundColor: '#f9f9f9', padding: 8 }]}>
<Text style={styles.label}>Update-Konditionen</Text>
<Text style={{ fontWeight: 'bold' }}>
{orderSnapshot.price_multiplier === 0
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
: orderSnapshot.price_multiplier === 0.15
? "Update (13-14 Monate): 15% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.30
? "Update (15-25 Monate): 30% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.45
? "Update (26-37 Monate): 45% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.60
? "Update (38-49 Monate): 60% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.90
? "Neukauf (>= 50 Monate): 10% Rabatt auf Neukauf"
: `Gebühr: ${orderSnapshot.price_multiplier * 100}%`}
</Text>
{orderSnapshot.last_license_date && (
<Text style={{ marginTop: 2, color: '#666' }}>
Letzte Lizenz vom: {new Date(orderSnapshot.last_license_date).toLocaleDateString('de-DE')}
</Text>
)}
</View>
)}
<View style={styles.table}> <View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}> <View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View> <View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>

View File

@@ -113,6 +113,37 @@ export function OrderWizard({
// Modulmengen (moduleId -> Menge) // Modulmengen (moduleId -> Menge)
const [moduleQuantities, setModuleQuantities] = useState<Record<string, number>>({}) const [moduleQuantities, setModuleQuantities] = useState<Record<string, number>>({})
// Datum der letzten Lizenz (Bestandskunden-Update-Logik)
const [lastLicenseDate, setLastLicenseDate] = useState<string>("")
const lastLicenseMonths = useMemo(() => {
if (!lastLicenseDate || customerMode !== 'select' || !selectedEndCustomerId) return null
const lastDate = new Date(lastLicenseDate)
const today = new Date()
const yearsDiff = today.getFullYear() - lastDate.getFullYear()
const monthsDiff = today.getMonth() - lastDate.getMonth()
return yearsDiff * 12 + monthsDiff
}, [lastLicenseDate, customerMode, selectedEndCustomerId])
const updatePriceModifier = useMemo(() => {
if (lastLicenseMonths === null || selectedBillingInterval === 'monthly') {
return { multiplier: 1, discount: 0, label: null }
}
if (lastLicenseMonths <= 12) {
return { multiplier: 0, discount: 1, label: "Update (0-12 Mon.): 100% Rabatt (inklusive)" }
} else if (lastLicenseMonths <= 14) {
return { multiplier: 0.15, discount: 0.85, label: "Update (13-14 Mon.): 15% vom Listenpreis" }
} else if (lastLicenseMonths <= 25) {
return { multiplier: 0.30, discount: 0.70, label: "Update (15-25 Mon.): 30% vom Listenpreis" }
} else if (lastLicenseMonths <= 37) {
return { multiplier: 0.45, discount: 0.55, label: "Update (26-37 Mon.): 45% vom Listenpreis" }
} else if (lastLicenseMonths <= 49) {
return { multiplier: 0.60, discount: 0.40, label: "Update (38-49 Mon.): 60% vom Listenpreis" }
} else {
return { multiplier: 0.90, discount: 0.10, label: "Neukauf (>= 50 Mon.): 10% Rabatt" }
}
}, [lastLicenseMonths, selectedBillingInterval])
// Per-category selection map: categoryId -> { productId, moduleIds } // Per-category selection map: categoryId -> { productId, moduleIds }
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => { const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
const init: Record<string, CategorySelection> = {} const init: Record<string, CategorySelection> = {}
@@ -203,8 +234,12 @@ export function OrderWizard({
monthly += totalItem monthly += totalItem
} }
} }
// Apply update modifier to oneTime total
if (updatePriceModifier.multiplier !== 1) {
oneTime = oneTime * updatePriceModifier.multiplier
}
return { monthlyTotal: monthly, oneTimeTotal: oneTime } return { monthlyTotal: monthly, oneTimeTotal: oneTime }
}, [selections, products, categories, moduleQuantities]) }, [selections, products, categories, moduleQuantities, updatePriceModifier])
// Helper: update product selection for a category (resets modules) // Helper: update product selection for a category (resets modules)
function selectProduct(catId: string, productId: string) { function selectProduct(catId: string, productId: string) {
@@ -280,6 +315,7 @@ export function OrderWizard({
endCustomerId: selectedEndCustomerId, endCustomerId: selectedEndCustomerId,
endCustomer: selectedEndCustomer, endCustomer: selectedEndCustomer,
billingInterval: selectedBillingInterval, billingInterval: selectedBillingInterval,
lastLicenseDate: lastLicenseDate || null,
}) })
router.push(`/order/success?id=${order.id}`) router.push(`/order/success?id=${order.id}`)
} catch (error) { } catch (error) {
@@ -401,6 +437,24 @@ export function OrderWizard({
))} ))}
</div> </div>
)} )}
{selectedEndCustomerId && (
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 mt-4 max-w-md">
<Label htmlFor="last-license-date" className="text-white font-medium flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
Datum der letzten CAS-Lizenz (falls vorhanden)
</Label>
<Input
id="last-license-date"
type="date"
value={lastLicenseDate}
onChange={e => setLastLicenseDate(e.target.value)}
className="bg-[#0b1329] border-white/10 text-white focus:border-primary"
/>
<p className="text-xs text-slate-400">
Falls dieser Kunde bereits Lizenzen besitzt, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
</p>
</div>
)}
</div> </div>
)} )}
@@ -819,6 +873,15 @@ export function OrderWizard({
</span> </span>
</div> </div>
)} )}
{updatePriceModifier.label && (
<div className="text-xs text-green-400 bg-green-500/10 p-2.5 rounded-lg border border-green-500/20 space-y-1">
<p className="font-semibold flex items-center gap-1">
<Check className="w-3.5 h-3.5" />
Update-Rabatt aktiv
</p>
<p>{updatePriceModifier.label}</p>
</div>
)}
{!allCategoriesFilled && ( {!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1"> <p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3 h-3" />
@@ -920,6 +983,12 @@ export function OrderWizard({
<p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p> <p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p>
<p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p> <p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p>
<p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p> <p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p>
{lastLicenseDate && (
<p className="text-xs text-slate-400 flex items-center gap-1 mt-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-primary" />
Letzte Lizenz vom: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
</p>
)}
</> </>
) : ( ) : (
<> <>
@@ -938,6 +1007,11 @@ export function OrderWizard({
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)} {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span> </span>
</div> </div>
{updatePriceModifier.label && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
{updatePriceModifier.label}
</div>
)}
<div className="flex justify-between text-lg font-bold text-white"> <div className="flex justify-between text-lg font-bold text-white">
<span className="text-slate-400">Monatlicher Gesamtbetrag:</span> <span className="text-slate-400">Monatlicher Gesamtbetrag:</span>
<span> <span>
@@ -947,11 +1021,18 @@ export function OrderWizard({
</div> </div>
</div> </div>
) : oneTimeTotal > 0 ? ( ) : oneTimeTotal > 0 ? (
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4"> <div className="flex flex-col border-t border-white/10 pt-4 gap-1.5">
<span>Gesamtbetrag:</span> <div className="flex justify-between text-xl font-bold text-gradient">
<span> <span>Gesamtbetrag:</span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)} <span>
</span> {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
{updatePriceModifier.label && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 self-end">
{updatePriceModifier.label}
</div>
)}
</div> </div>
) : ( ) : (
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4"> <div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">

View File

@@ -57,8 +57,9 @@ export async function submitOrder(params: {
endCustomerId?: string | null endCustomerId?: string | null
endCustomer?: EndCustomer | null endCustomer?: EndCustomer | null
billingInterval?: 'one_time' | 'monthly' billingInterval?: 'one_time' | 'monthly'
lastLicenseDate?: string | null
}): Promise<Order> { }): Promise<Order> {
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval } = params const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
const supabase = await createClient() const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
@@ -139,7 +140,7 @@ export async function submitOrder(params: {
// 1. Snapshots aufbauen // 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities) const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert // 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
const orderHash = hashOrderSnapshot(orderSnapshot) const orderHash = hashOrderSnapshot(orderSnapshot)

View File

@@ -86,7 +86,8 @@ export function buildOrderSnapshot(
products: Product[], products: Product[],
categories: Category[], categories: Category[],
billingInterval?: 'one_time' | 'monthly', billingInterval?: 'one_time' | 'monthly',
moduleQuantities?: Record<string, number> moduleQuantities?: Record<string, number>,
lastLicenseDate?: string | null
): OrderSnapshot { ): OrderSnapshot {
const items: OrderItem[] = [] const items: OrderItem[] = []
let subtotal = 0 let subtotal = 0
@@ -131,14 +132,43 @@ export function buildOrderSnapshot(
}) })
} }
const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100
const total = Math.round((subtotal + taxAmount) * 100) / 100
// Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln // Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln
const billingCycle = billingInterval || (items.every((i) => i.billing_interval === 'one_time') const billingCycle = billingInterval || (items.every((i) => i.billing_interval === 'one_time')
? 'one_time' ? 'one_time'
: 'monthly') : '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 { return {
schema_version: 1, schema_version: 1,
billing_cycle: billingCycle, billing_cycle: billingCycle,
@@ -147,6 +177,8 @@ export function buildOrderSnapshot(
tax_rate: dominantTaxRate, tax_rate: dominantTaxRate,
tax_amount: taxAmount, tax_amount: taxAmount,
total, total,
last_license_date: lastLicenseDate || null,
price_multiplier: lastLicenseDate && billingCycle === 'one_time' ? multiplier : null,
} }
} }

View File

@@ -132,6 +132,8 @@ export type OrderSnapshot = {
tax_rate: number tax_rate: number
tax_amount: number tax_amount: number
total: number total: number
last_license_date?: string | null
price_multiplier?: number | null
} }
// ─── DB-Zeile (orders-Tabelle) ──────────────────────────────────────────────── // ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────