Implement order data model modernization and license transformation logic
This commit is contained in:
@@ -29,7 +29,7 @@ import { Plus, Trash2, X, PlusCircle } from 'lucide-react'
|
|||||||
import { createProduct, updateProduct } from '@/lib/actions/products'
|
import { createProduct, updateProduct } from '@/lib/actions/products'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Category } from '@/lib/types'
|
import { Category, Product } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -52,7 +52,6 @@ const productSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
||||||
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
|
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
|
||||||
is_active: z.boolean().default(true),
|
|
||||||
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
|
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
|
||||||
modules: z.array(moduleSchema).default([]),
|
modules: z.array(moduleSchema).default([]),
|
||||||
})
|
})
|
||||||
@@ -69,13 +68,12 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
description: product?.description || '',
|
description: product?.description || '',
|
||||||
base_price: product?.base_price || 0,
|
base_price: product?.base_price || 0,
|
||||||
category_id: product?.category_id || '',
|
category_id: product?.category_id || '',
|
||||||
is_active: product?.is_active ?? true,
|
|
||||||
billing_interval: product?.billing_interval || 'monthly',
|
billing_interval: product?.billing_interval || 'monthly',
|
||||||
modules: product?.modules?.map(m => ({
|
modules: product?.modules?.map(m => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description || '',
|
description: m.description || '',
|
||||||
price: m.price,
|
price: m.price,
|
||||||
is_required: m.is_required,
|
is_required: false,
|
||||||
requirements: m.requirements?.join(', ') || '',
|
requirements: m.requirements?.join(', ') || '',
|
||||||
exclusions: m.exclusions?.join(', ') || '',
|
exclusions: m.exclusions?.join(', ') || '',
|
||||||
})) || [],
|
})) || [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Product } from '@/lib/types'
|
import { Product, Category } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -16,7 +16,6 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { deleteProduct } from '@/lib/actions/products'
|
import { deleteProduct } from '@/lib/actions/products'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { CreateProductDialog } from './create-product-dialog'
|
import { CreateProductDialog } from './create-product-dialog'
|
||||||
import { Category } from '@/lib/types'
|
|
||||||
|
|
||||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||||
const [products, setProducts] = useState(initialProducts)
|
const [products, setProducts] = useState(initialProducts)
|
||||||
@@ -77,8 +76,8 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={product.is_active ? "default" : "secondary"}>
|
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
|
||||||
{product.is_active ? "Aktiv" : "Inaktiv"}
|
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|||||||
@@ -146,29 +146,11 @@ export function OrderWizard({
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
// Build multi-product order payload – submit each category item
|
|
||||||
const items = categories
|
|
||||||
.map(cat => {
|
|
||||||
const sel = selections[cat.id]
|
|
||||||
if (!sel?.productId) return null
|
|
||||||
const prod = products.find(p => p.id === sel.productId)!
|
|
||||||
return {
|
|
||||||
category: cat.name,
|
|
||||||
product_id: sel.productId,
|
|
||||||
product_name: prod.name,
|
|
||||||
base_price: prod.base_price,
|
|
||||||
selected_modules: sel.moduleIds,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
await submitOrder({
|
await submitOrder({
|
||||||
product_id: items[0]!.product_id, // primary (first category)
|
selections,
|
||||||
selected_modules: items.flatMap(i => i!.selected_modules),
|
products,
|
||||||
total_amount: totalPrice,
|
categories,
|
||||||
customer_details: customerData,
|
customerProfile: customerData,
|
||||||
// @ts-ignore extended payload
|
|
||||||
items,
|
|
||||||
})
|
})
|
||||||
alert('Bestellung erfolgreich aufgegeben!')
|
alert('Bestellung erfolgreich aufgegeben!')
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
@@ -178,6 +160,7 @@ export function OrderWizard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto py-12 px-4">
|
<div className="max-w-5xl mx-auto py-12 px-4">
|
||||||
{/* Progress Stepper */}
|
{/* Progress Stepper */}
|
||||||
|
|||||||
@@ -5,89 +5,74 @@ import { revalidatePath } from 'next/cache'
|
|||||||
import { InvoicePDF } from '@/components/invoice-pdf'
|
import { InvoicePDF } from '@/components/invoice-pdf'
|
||||||
import { renderToBuffer } from '@react-pdf/renderer'
|
import { renderToBuffer } from '@react-pdf/renderer'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
||||||
|
import type { Category, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
||||||
|
|
||||||
export async function submitOrder(orderData: {
|
// ─── submitOrder ──────────────────────────────────────────────────────────────
|
||||||
product_id: string;
|
|
||||||
selected_modules: string[];
|
/**
|
||||||
total_amount: number;
|
* Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
|
||||||
customer_details: any;
|
* generiert die PDF-Rechnung und lädt sie in den Supabase Storage hoch.
|
||||||
}) {
|
*/
|
||||||
|
export async function submitOrder(params: {
|
||||||
|
selections: WizardSelections
|
||||||
|
products: Product[]
|
||||||
|
categories: Category[]
|
||||||
|
customerProfile: Partial<Profile>
|
||||||
|
}): Promise<Order> {
|
||||||
|
const { selections, products, categories, customerProfile } = params
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) throw new Error('Not authenticated')
|
if (!user) throw new Error('Not authenticated')
|
||||||
|
|
||||||
// 1. Fetch Product and Module Details for Invoice
|
// 1. Snapshots aufbauen
|
||||||
const { data: product } = await supabase
|
const customerSnapshot = buildCustomerSnapshot(customerProfile)
|
||||||
.from('products')
|
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
|
||||||
.select('*')
|
|
||||||
.eq('id', orderData.product_id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
const { data: modules } = await supabase
|
// 2. Bestellung in DB schreiben
|
||||||
.from('product_modules')
|
|
||||||
.select('*')
|
|
||||||
.in('id', orderData.selected_modules)
|
|
||||||
|
|
||||||
if (!product) throw new Error('Product not found')
|
|
||||||
|
|
||||||
// 2. Create the Order in DB
|
|
||||||
const { data: order, error: orderError } = await supabase
|
const { data: order, error: orderError } = await supabase
|
||||||
.from('orders')
|
.from('orders')
|
||||||
.insert([{
|
.insert([{
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
total_price: orderData.total_amount, // Matched column name in initial_schema.sql
|
total_price: orderSnapshot.total,
|
||||||
order_data: {
|
customer_data: customerSnapshot,
|
||||||
product_id: orderData.product_id,
|
order_data: orderSnapshot,
|
||||||
modules: orderData.selected_modules
|
status: 'pending',
|
||||||
},
|
|
||||||
customer_data: orderData.customer_details,
|
|
||||||
status: 'pending'
|
|
||||||
}])
|
}])
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (orderError) throw orderError
|
if (orderError) throw orderError
|
||||||
|
|
||||||
// 3. Generate PDF
|
// 3. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
|
||||||
try {
|
try {
|
||||||
const buffer = await renderToBuffer(
|
const buffer = await renderToBuffer(
|
||||||
React.createElement(InvoicePDF, {
|
React.createElement(InvoicePDF, {
|
||||||
order: order,
|
order,
|
||||||
product: product,
|
customer: customerSnapshot,
|
||||||
modules: modules || [],
|
orderSnapshot,
|
||||||
customer: orderData.customer_details
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Upload to Supabase Storage
|
// 4. PDF in Supabase Storage hochladen
|
||||||
const fileName = `invoice_${order.id}.pdf`
|
const fileName = `invoice_${order.id}.pdf`
|
||||||
const { data: uploadData, error: uploadError } = await supabase
|
const { error: uploadError } = await supabase
|
||||||
.storage
|
.storage
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.upload(fileName, buffer, {
|
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
|
||||||
contentType: 'application/pdf',
|
|
||||||
upsert: true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
console.error('PDF Upload Error:', uploadError)
|
console.error('PDF Upload Error:', uploadError)
|
||||||
} else {
|
} else {
|
||||||
// 5. Update Order with PDF URL
|
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
||||||
const { data: publicUrlData } = supabase
|
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
||||||
.storage
|
|
||||||
.from('invoices')
|
|
||||||
.getPublicUrl(fileName)
|
|
||||||
|
|
||||||
await supabase
|
|
||||||
.from('orders')
|
|
||||||
.update({ pdf_url: publicUrlData.publicUrl })
|
|
||||||
.eq('id', order.id)
|
|
||||||
}
|
}
|
||||||
} catch (pdfError) {
|
} catch (pdfError) {
|
||||||
console.error('PDF Generation Error:', pdfError)
|
console.error('PDF Generation Error:', pdfError)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/protected')
|
revalidatePath('/protected')
|
||||||
return order
|
return order as Order
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { Product, ProductModule } from '../types'
|
import { Category, Product, ProductModule } from '../types'
|
||||||
|
|
||||||
export async function getProducts() {
|
export async function getProducts() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -109,7 +109,6 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
|||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description || null,
|
description: m.description || null,
|
||||||
price: m.price,
|
price: m.price,
|
||||||
is_required: m.is_required,
|
|
||||||
requirements: m.requirements || [],
|
requirements: m.requirements || [],
|
||||||
exclusions: m.exclusions || [],
|
exclusions: m.exclusions || [],
|
||||||
product_id: id
|
product_id: id
|
||||||
|
|||||||
242
shop/lib/license-transform.ts
Normal file
242
shop/lib/license-transform.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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.
|
||||||
|
* Fehlende Pflichtfelder werden mit leerem String aufgefüllt (kein throw –
|
||||||
|
* Validierung findet im Wizard-UI statt).
|
||||||
|
*/
|
||||||
|
export function buildCustomerSnapshot(profile: Partial<Profile>): CustomerSnapshot {
|
||||||
|
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[]
|
||||||
|
): 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) => ({
|
||||||
|
module_id: mod.id,
|
||||||
|
module_name: mod.name,
|
||||||
|
price: mod.price,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const moduleTotal = selectedModules.reduce((acc, m) => acc + 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100
|
||||||
|
const total = Math.round((subtotal + taxAmount) * 100) / 100
|
||||||
|
|
||||||
|
// Wenn alle Items one_time sind → billing_cycle = 'one_time', sonst 'monthly'
|
||||||
|
const billingCycle = items.every((i) => i.billing_interval === 'one_time')
|
||||||
|
? 'one_time'
|
||||||
|
: 'monthly'
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: 1,
|
||||||
|
billing_cycle: billingCycle,
|
||||||
|
items,
|
||||||
|
subtotal,
|
||||||
|
tax_rate: dominantTaxRate,
|
||||||
|
tax_amount: taxAmount,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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>
|
||||||
|
): 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
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (prod.billing_interval === 'monthly') monthlyTotal += mod.price
|
||||||
|
else oneTimeTotal += mod.price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
options.push({
|
||||||
|
key: `${prodKey}__${slugify(mod.module_name)}`,
|
||||||
|
label: mod.module_name,
|
||||||
|
active: true,
|
||||||
|
billing: item.billing_interval,
|
||||||
|
price_net: mod.price,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (item.billing_interval === 'monthly') monthlyTotal += mod.price
|
||||||
|
else oneTimeTotal += mod.price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,59 +1,143 @@
|
|||||||
|
// ─── Katalog (gelesen aus Supabase) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export type BillingInterval = 'one_time' | 'monthly'
|
||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
description?: string | null | undefined;
|
description?: string | null
|
||||||
base_price: number;
|
base_price: number
|
||||||
is_active: boolean;
|
tax_rate: number
|
||||||
billing_interval: 'one_time' | 'monthly';
|
billing_interval: BillingInterval
|
||||||
created_at: string;
|
category_id?: string | null
|
||||||
updated_at: string;
|
category?: Category | null
|
||||||
category_id?: string | null;
|
modules?: ProductModule[]
|
||||||
category?: Category | null;
|
created_at: string
|
||||||
modules?: ProductModule[];
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
description?: string | null;
|
description?: string | null
|
||||||
icon?: string | null;
|
icon?: string | null
|
||||||
sort_order: number;
|
sort_order: number
|
||||||
is_required: boolean;
|
is_required: boolean
|
||||||
created_at: string;
|
created_at: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export type ProductModule = {
|
export type ProductModule = {
|
||||||
id: string;
|
id: string
|
||||||
product_id: string;
|
product_id: string
|
||||||
name: string;
|
name: string
|
||||||
description?: string | null | undefined;
|
description?: string | null
|
||||||
price: number;
|
price: number
|
||||||
is_required: boolean;
|
requirements: string[] // UUID[] anderer Module, die aktiv sein müssen
|
||||||
is_active?: boolean;
|
exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen
|
||||||
requirements: string[];
|
created_at: string
|
||||||
exclusions: string[];
|
}
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Profile = {
|
export type Profile = {
|
||||||
id: string;
|
id: string
|
||||||
company_name: string | null;
|
company_name: string | null
|
||||||
vat_id: string | null;
|
vat_id: string | null
|
||||||
first_name: string | null;
|
first_name: string | null
|
||||||
last_name: string | null;
|
last_name: string | null
|
||||||
address: string | null;
|
address: string | null
|
||||||
city: string | null;
|
city: string | null
|
||||||
zip_code: string | null;
|
zip_code: string | null
|
||||||
country: string;
|
country: string
|
||||||
updated_at: string;
|
email?: string | null
|
||||||
};
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
|
||||||
|
|
||||||
|
export type CategorySelection = {
|
||||||
|
productId: string | null
|
||||||
|
moduleIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key = category_id */
|
||||||
|
export type WizardSelections = Record<string, CategorySelection>
|
||||||
|
|
||||||
|
// ─── Versioned Snapshots (unveränderlich in JSONB gespeichert) ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kundendaten zum Zeitpunkt der Bestellung.
|
||||||
|
* schema_version erlaubt spätere Migrationsfunktionen ohne DB-Änderung.
|
||||||
|
*/
|
||||||
|
export type CustomerSnapshot = {
|
||||||
|
schema_version: 1
|
||||||
|
company_name: string
|
||||||
|
vat_id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
address: string
|
||||||
|
zip_code: string
|
||||||
|
city: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderModuleSnapshot = {
|
||||||
|
module_id: string
|
||||||
|
module_name: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderItem = {
|
||||||
|
category_id: string
|
||||||
|
category_name: string
|
||||||
|
product_id: string
|
||||||
|
product_name: string
|
||||||
|
base_price: number
|
||||||
|
billing_interval: BillingInterval
|
||||||
|
selected_modules: OrderModuleSnapshot[]
|
||||||
|
item_total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständiger Bestell-Snapshot – wird in orders.order_data (JSONB) gespeichert.
|
||||||
|
*/
|
||||||
|
export type OrderSnapshot = {
|
||||||
|
schema_version: 1
|
||||||
|
billing_cycle: BillingInterval
|
||||||
|
items: OrderItem[]
|
||||||
|
subtotal: number
|
||||||
|
tax_rate: number
|
||||||
|
tax_amount: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Order = {
|
export type Order = {
|
||||||
id: string;
|
id: string
|
||||||
user_id: string;
|
user_id: string | null
|
||||||
status: 'pending' | 'completed' | 'cancelled';
|
customer_data: CustomerSnapshot
|
||||||
total_amount: number;
|
order_data: OrderSnapshot
|
||||||
items: any; // Snapshot of product and modules
|
total_price: number
|
||||||
billing_details: any;
|
pdf_url: string | null
|
||||||
created_at: string;
|
status: 'pending' | 'active' | 'cancelled'
|
||||||
};
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lizenz-Output (für Lizenzserver / ERP) ───────────────────────────────────
|
||||||
|
|
||||||
|
export type LicenseOption = {
|
||||||
|
/** Stabiler Slug-Key, z. B. "caspos_basic__backoffice" */
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
billing: BillingInterval
|
||||||
|
price_net: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseBundle = {
|
||||||
|
customer_ref: string
|
||||||
|
generated_at: string
|
||||||
|
license_key_prefix: string
|
||||||
|
options: LicenseOption[]
|
||||||
|
monthly_total_net: number
|
||||||
|
one_time_total_net: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user