feat: add update price modifier for existing customers based on last license date
Some checks failed
Staging Build / build (push) Has been cancelled
Some checks failed
Staging Build / build (push) Has been cancelled
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -110,6 +110,33 @@ export default async function OrderSuccessPage({
|
||||
</span>
|
||||
</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" />
|
||||
|
||||
{/* Kundendaten */}
|
||||
|
||||
@@ -134,6 +134,32 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||
</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.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||
|
||||
@@ -113,6 +113,37 @@ export function OrderWizard({
|
||||
// Modulmengen (moduleId -> Menge)
|
||||
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 }
|
||||
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
|
||||
const init: Record<string, CategorySelection> = {}
|
||||
@@ -203,8 +234,12 @@ export function OrderWizard({
|
||||
monthly += totalItem
|
||||
}
|
||||
}
|
||||
// Apply update modifier to oneTime total
|
||||
if (updatePriceModifier.multiplier !== 1) {
|
||||
oneTime = oneTime * updatePriceModifier.multiplier
|
||||
}
|
||||
return { monthlyTotal: monthly, oneTimeTotal: oneTime }
|
||||
}, [selections, products, categories, moduleQuantities])
|
||||
}, [selections, products, categories, moduleQuantities, updatePriceModifier])
|
||||
|
||||
// Helper: update product selection for a category (resets modules)
|
||||
function selectProduct(catId: string, productId: string) {
|
||||
@@ -280,6 +315,7 @@ export function OrderWizard({
|
||||
endCustomerId: selectedEndCustomerId,
|
||||
endCustomer: selectedEndCustomer,
|
||||
billingInterval: selectedBillingInterval,
|
||||
lastLicenseDate: lastLicenseDate || null,
|
||||
})
|
||||
router.push(`/order/success?id=${order.id}`)
|
||||
} catch (error) {
|
||||
@@ -401,6 +437,24 @@ export function OrderWizard({
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -819,6 +873,15 @@ export function OrderWizard({
|
||||
</span>
|
||||
</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 && (
|
||||
<p className="text-xs text-destructive flex items-center gap-1">
|
||||
<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.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</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)}
|
||||
</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 my-1">
|
||||
{updatePriceModifier.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold text-white">
|
||||
<span className="text-slate-400">Monatlicher Gesamtbetrag:</span>
|
||||
<span>
|
||||
@@ -947,12 +1021,19 @@ export function OrderWizard({
|
||||
</div>
|
||||
</div>
|
||||
) : 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">
|
||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||
<span>Gesamtbetrag:</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 className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
|
||||
<span>Gesamtbetrag:</span>
|
||||
|
||||
@@ -57,8 +57,9 @@ export async function submitOrder(params: {
|
||||
endCustomerId?: string | null
|
||||
endCustomer?: EndCustomer | null
|
||||
billingInterval?: 'one_time' | 'monthly'
|
||||
lastLicenseDate?: string | null
|
||||
}): Promise<Order> {
|
||||
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval } = params
|
||||
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
@@ -139,7 +140,7 @@ export async function submitOrder(params: {
|
||||
// 1. Snapshots aufbauen
|
||||
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
||||
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
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||
|
||||
@@ -86,7 +86,8 @@ export function buildOrderSnapshot(
|
||||
products: Product[],
|
||||
categories: Category[],
|
||||
billingInterval?: 'one_time' | 'monthly',
|
||||
moduleQuantities?: Record<string, number>
|
||||
moduleQuantities?: Record<string, number>,
|
||||
lastLicenseDate?: string | null
|
||||
): OrderSnapshot {
|
||||
const items: OrderItem[] = []
|
||||
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
|
||||
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,
|
||||
@@ -147,6 +177,8 @@ export function buildOrderSnapshot(
|
||||
tax_rate: dominantTaxRate,
|
||||
tax_amount: taxAmount,
|
||||
total,
|
||||
last_license_date: lastLicenseDate || null,
|
||||
price_multiplier: lastLicenseDate && billingCycle === 'one_time' ? multiplier : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ export type OrderSnapshot = {
|
||||
tax_rate: number
|
||||
tax_amount: number
|
||||
total: number
|
||||
last_license_date?: string | null
|
||||
price_multiplier?: number | null
|
||||
}
|
||||
|
||||
// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user