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 { 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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user