773 lines
38 KiB
TypeScript
773 lines
38 KiB
TypeScript
'use server'
|
|
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { createAdminClient } from '@/lib/supabase/admin'
|
|
import { revalidatePath } from 'next/cache'
|
|
import { sendMail } from '@/utils/mail'
|
|
import { InvoicePDF } from '@/components/invoice-pdf'
|
|
import { renderToBuffer } from '@react-pdf/renderer'
|
|
import React from 'react'
|
|
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
|
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
|
import { getProducts, getCategories } from '@/lib/actions/products'
|
|
|
|
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
|
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
|
|
* Kein Rückschluss auf Gesamtanzahl der Bestellungen möglich.
|
|
*/
|
|
function generateOrderNumber(): string {
|
|
const year = new Date().getFullYear()
|
|
const rand = Math.floor(10000 + Math.random() * 90000) // 5-stellig, niemals < 10000
|
|
return `AE-${year}-${rand}`
|
|
}
|
|
|
|
/**
|
|
* Erstellt einen einfachen deterministischen Hash aus dem Bestell-Snapshot.
|
|
* Wird für den Idempotenz-Guard verwendet (verhindert Doppelbestellungen).
|
|
*/
|
|
function hashOrderSnapshot(snapshot: object): string {
|
|
const str = JSON.stringify(snapshot)
|
|
let hash = 0
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i)
|
|
hash = ((hash << 5) - hash) + char
|
|
hash = hash & hash // 32-bit integer
|
|
}
|
|
return Math.abs(hash).toString(36)
|
|
}
|
|
|
|
// ─── submitOrder ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
|
|
* generiert die PDF-Rechnung und lädt sie in den Supabase Storage hoch.
|
|
*
|
|
* Enthält Idempotenz-Guard: Wenn innerhalb von 30 Sekunden eine identische
|
|
* Bestellung vom gleichen User existiert, wird diese zurückgegeben statt
|
|
* einer neuen erstellt (Schutz gegen Doppelklick / Netzwerk-Retry).
|
|
*/
|
|
export async function submitOrder(params: {
|
|
selections: WizardSelections
|
|
moduleQuantities?: Record<string, number>
|
|
products?: Product[]
|
|
categories?: Category[]
|
|
customerProfile: Partial<Profile>
|
|
endCustomerId?: string | null
|
|
endCustomer?: EndCustomer | null
|
|
billingInterval?: 'one_time' | 'monthly'
|
|
lastLicenseDate?: string | null
|
|
}): Promise<Order> {
|
|
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
|
|
const supabase = await createClient()
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) throw new Error('Not authenticated')
|
|
|
|
// Fetch catalog directly from DB to prevent client tampering
|
|
const dbProducts = await getProducts()
|
|
const dbCategories = await getCategories()
|
|
|
|
// ─── Constraint Validation ──────────────────────────────────────────────────
|
|
for (const cat of dbCategories) {
|
|
const sel = selections[cat.id]
|
|
if (cat.is_required && (!sel || !sel.productId)) {
|
|
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
|
|
}
|
|
if (sel?.productId) {
|
|
const prod = dbProducts.find(p => p.id === sel.productId)
|
|
if (!prod) {
|
|
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
|
|
}
|
|
|
|
// Validate selected modules
|
|
for (const mId of sel.moduleIds) {
|
|
const mod = prod.modules?.find(m => m.id === mId)
|
|
if (!mod) {
|
|
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
|
|
}
|
|
// Requirements check
|
|
if (mod.requirements && mod.requirements.length > 0) {
|
|
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
|
if (!hasMetRequirement) {
|
|
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
|
|
}
|
|
}
|
|
// Exclusions check
|
|
if (mod.exclusions && mod.exclusions.length > 0) {
|
|
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
|
if (conflicting.length > 0) {
|
|
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Product-level constraints validation
|
|
const selectedProductIds = Object.values(selections)
|
|
.map(s => s.productId)
|
|
.filter((id): id is string => !!id)
|
|
|
|
for (const prodId of selectedProductIds) {
|
|
const prod = dbProducts.find(p => p.id === prodId)
|
|
if (!prod) continue
|
|
|
|
// Product requirements check
|
|
if (prod.requirements && prod.requirements.length > 0) {
|
|
const hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId))
|
|
if (!hasMetRequirement) {
|
|
const reqNames = prod.requirements
|
|
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
|
|
.join(' oder ')
|
|
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`)
|
|
}
|
|
}
|
|
|
|
// Product exclusions check
|
|
if (prod.exclusions && prod.exclusions.length > 0) {
|
|
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
|
|
if (conflicting.length > 0) {
|
|
const conflictingNames = conflicting
|
|
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
|
|
.join(', ')
|
|
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, lastLicenseDate)
|
|
|
|
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
|
const orderHash = hashOrderSnapshot(orderSnapshot)
|
|
const thirtySecondsAgo = new Date(Date.now() - 30_000).toISOString()
|
|
|
|
const { data: existingOrder } = await supabase
|
|
.from('orders')
|
|
.select('*')
|
|
.eq('user_id', user.id)
|
|
.eq('order_hash', orderHash)
|
|
.gte('created_at', thirtySecondsAgo)
|
|
.maybeSingle()
|
|
|
|
if (existingOrder) {
|
|
console.warn('[submitOrder] Duplicate order detected, returning existing order:', existingOrder.order_number)
|
|
return existingOrder as Order
|
|
}
|
|
|
|
// 3. Eindeutige Bestellnummer generieren (Retry bei Kollision)
|
|
let orderNumber: string = ''
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
const candidate = generateOrderNumber()
|
|
const { data: collision } = await supabase
|
|
.from('orders')
|
|
.select('id')
|
|
.eq('order_number', candidate)
|
|
.maybeSingle()
|
|
if (!collision) {
|
|
orderNumber = candidate
|
|
break
|
|
}
|
|
}
|
|
if (!orderNumber) throw new Error('Konnte keine eindeutige Bestellnummer generieren.')
|
|
|
|
// 4. Bestellung in DB schreiben
|
|
const { data: order, error: orderError } = await supabase
|
|
.from('orders')
|
|
.insert([{
|
|
user_id: user.id,
|
|
order_number: orderNumber,
|
|
order_hash: orderHash,
|
|
end_customer_id: endCustomerId ?? null,
|
|
total_price: orderSnapshot.total,
|
|
customer_data: customerSnapshot,
|
|
order_data: orderSnapshot,
|
|
status: 'pending',
|
|
}])
|
|
.select()
|
|
.single()
|
|
|
|
if (orderError) throw orderError
|
|
|
|
// 5. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
|
|
try {
|
|
const buffer = await renderToBuffer(
|
|
React.createElement(InvoicePDF, {
|
|
order,
|
|
customer: customerSnapshot,
|
|
orderSnapshot,
|
|
})
|
|
)
|
|
|
|
// 6. PDF in Supabase Storage hochladen
|
|
const fileName = `ab_${order.id}.pdf`
|
|
const { error: uploadError } = await supabase
|
|
.storage
|
|
.from('invoices')
|
|
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
|
|
|
|
if (uploadError) {
|
|
console.error('PDF Upload Error:', uploadError)
|
|
} else {
|
|
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
|
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
|
order.pdf_url = publicUrlData.publicUrl
|
|
}
|
|
|
|
// 7. E-Mail mit PDF-Anhang an den Besteller senden
|
|
if (user.email) {
|
|
try {
|
|
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE')
|
|
|
|
const taxRate = orderSnapshot.tax_rate ?? 19
|
|
const items = orderSnapshot.items ?? []
|
|
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time')
|
|
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly')
|
|
|
|
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
|
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100
|
|
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100
|
|
|
|
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
|
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
|
|
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
|
|
|
|
let totalDetailsText = ''
|
|
if (oneTimeNet > 0 && monthlyNet > 0) {
|
|
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
|
} else if (oneTimeNet > 0) {
|
|
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`
|
|
} else {
|
|
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
|
}
|
|
|
|
let totalDetailsHtml = ''
|
|
if (oneTimeNet > 0 && monthlyNet > 0) {
|
|
totalDetailsHtml = `
|
|
<tr>
|
|
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
`
|
|
} else if (oneTimeNet > 0) {
|
|
totalDetailsHtml = `
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
`
|
|
} else {
|
|
totalDetailsHtml = `
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
`
|
|
}
|
|
|
|
await sendMail({
|
|
to: user.email,
|
|
subject: `Anfragebestätigung ${orderNumber}`,
|
|
text: `Hallo,\n\nvielen Dank für Ihre Anfrage bei CASPOS Shop!\n\nAnfragedetails:\n- Anfrage-Nummer: ${orderNumber}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n${totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre Anfragebestätigung als PDF-Dokument.\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
|
html: `
|
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
|
<h2 style="color: #0f172a; margin-bottom: 16px;">Vielen Dank für Ihre Anfrage!</h2>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">wir freuen uns sehr über Ihre Anfrage bei CASPOS Shop. Ihre Anfrage wurde erfolgreich entgegengenommen und wird nun verarbeitet.</p>
|
|
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
|
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Anfrage-Details</h3>
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Anfrage-Nummer:</td>
|
|
<td style="padding: 4px 0;">${orderNumber}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
|
|
<td style="padding: 4px 0;">${formattedDate}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
|
|
<td style="padding: 4px 0;">${customerSnapshot.company_name}</td>
|
|
</tr>
|
|
${totalDetailsHtml}
|
|
</table>
|
|
</div>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre Anfragebestätigung als PDF-Dokument.</p>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
|
|
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
|
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
|
</div>
|
|
`,
|
|
attachments: [
|
|
{
|
|
filename: `Anfragebestaetigung_${orderNumber}.pdf`,
|
|
content: buffer,
|
|
contentType: 'application/pdf',
|
|
}
|
|
]
|
|
})
|
|
} catch (mailError) {
|
|
console.error('Failed to send order confirmation mail:', mailError)
|
|
}
|
|
}
|
|
} catch (pdfError) {
|
|
console.error('PDF Generation Error:', pdfError)
|
|
}
|
|
|
|
revalidatePath('/my-customers')
|
|
revalidatePath('/my-orders')
|
|
return order as Order
|
|
}
|
|
|
|
export async function updateOrderStatus(
|
|
orderId: string,
|
|
newStatus: 'pending' | 'active' | 'completed' | 'cancelled'
|
|
) {
|
|
const admin = createAdminClient()
|
|
|
|
// 1. Fetch current order
|
|
const { data: order, error: fetchError } = await admin
|
|
.from('orders')
|
|
.select('id, user_id, order_number, status')
|
|
.eq('id', orderId)
|
|
.single()
|
|
|
|
if (fetchError || !order) {
|
|
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
|
|
}
|
|
|
|
const oldStatus = order.status
|
|
|
|
if (oldStatus === newStatus) {
|
|
return order
|
|
}
|
|
|
|
// 2. Update order status
|
|
const { data: updatedOrder, error: updateError } = await admin
|
|
.from('orders')
|
|
.update({ status: newStatus })
|
|
.eq('id', orderId)
|
|
.select()
|
|
.single()
|
|
|
|
if (updateError || !updatedOrder) {
|
|
throw new Error(`Fehler beim Aktualisieren des Status: ${updateError?.message || 'Unbekannt'}`)
|
|
}
|
|
|
|
// 3. Send email to the retailer (händler)
|
|
if (order.user_id) {
|
|
try {
|
|
const { data: { user }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
|
|
|
|
if (!userError && user && user.email) {
|
|
const orderNumber = order.order_number || order.id.slice(0, 8)
|
|
const statusLabelMap: Record<string, string> = {
|
|
pending: 'Eingegangen',
|
|
active: 'In Bearbeitung',
|
|
completed: 'Abgeschlossen',
|
|
cancelled: 'Storniert',
|
|
}
|
|
const oldLabel = statusLabelMap[oldStatus] || oldStatus
|
|
const newLabel = statusLabelMap[newStatus] || newStatus
|
|
|
|
await sendMail({
|
|
to: user.email,
|
|
subject: `Statusänderung Ihrer Bestellung ${orderNumber}`,
|
|
text: `Hallo,\n\nder Status Ihrer Bestellung mit der Nummer ${orderNumber} hat sich geändert.\n\nNeuer Status: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
|
html: `
|
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
|
<h2 style="color: #0f172a; margin-bottom: 16px;">Status Ihrer Bestellung hat sich geändert</h2>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status Ihrer Bestellung mit der Nummer <strong>${orderNumber}</strong> wurde aktualisiert.</p>
|
|
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Bestellnummer:</td>
|
|
<td style="padding: 4px 0;">${orderNumber}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold;">Alter Status:</td>
|
|
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold;">Neuer Status:</td>
|
|
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Ihr CASPOS Shop-Team</p>
|
|
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
|
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
|
</div>
|
|
`
|
|
})
|
|
}
|
|
} catch (mailError) {
|
|
console.error('Failed to send status update email:', mailError)
|
|
}
|
|
}
|
|
|
|
revalidatePath('/admin/orders')
|
|
revalidatePath('/my-orders')
|
|
return updatedOrder as Order
|
|
}
|
|
|
|
export async function updateOrder(
|
|
orderId: string,
|
|
params: {
|
|
selections: WizardSelections
|
|
moduleQuantities?: Record<string, number>
|
|
customerProfile: Partial<Profile>
|
|
endCustomerId?: string | null
|
|
endCustomer?: EndCustomer | null
|
|
billingInterval?: 'one_time' | 'monthly'
|
|
lastLicenseDate?: string | null
|
|
}
|
|
): Promise<Order> {
|
|
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
|
|
const supabase = await createClient()
|
|
const admin = createAdminClient()
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) throw new Error('Not authenticated')
|
|
|
|
// 1. Fetch current order & verify permissions
|
|
const { data: existingOrder, error: fetchError } = await admin
|
|
.from('orders')
|
|
.select('*')
|
|
.eq('id', orderId)
|
|
.single()
|
|
|
|
if (fetchError || !existingOrder) {
|
|
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
|
|
}
|
|
|
|
const { data: dbUser } = await admin
|
|
.from('users')
|
|
.select('role')
|
|
.eq('id', user.id)
|
|
.single()
|
|
|
|
const isAdmin = dbUser?.role === 'admin'
|
|
const isOwner = existingOrder.user_id === user.id
|
|
|
|
if (!isAdmin && !isOwner) {
|
|
throw new Error('Keine Berechtigung dieses Anfrage zu bearbeiten.')
|
|
}
|
|
|
|
if (existingOrder.status === 'completed' && !isAdmin) {
|
|
throw new Error('Abgeschlossene Anfragen können nicht mehr bearbeitet werden.')
|
|
}
|
|
|
|
// Fetch catalog directly from DB to prevent client tampering
|
|
const dbProducts = await getProducts()
|
|
const dbCategories = await getCategories()
|
|
|
|
// ─── Constraint Validation ──────────────────────────────────────────────────
|
|
for (const cat of dbCategories) {
|
|
const sel = selections[cat.id]
|
|
if (cat.is_required && (!sel || !sel.productId)) {
|
|
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
|
|
}
|
|
if (sel?.productId) {
|
|
const prod = dbProducts.find(p => p.id === sel.productId)
|
|
if (!prod) {
|
|
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
|
|
}
|
|
|
|
// Validate selected modules
|
|
for (const mId of sel.moduleIds) {
|
|
const mod = prod.modules?.find(m => m.id === mId)
|
|
if (!mod) {
|
|
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
|
|
}
|
|
// Requirements check
|
|
if (mod.requirements && mod.requirements.length > 0) {
|
|
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
|
if (!hasMetRequirement) {
|
|
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
|
|
}
|
|
}
|
|
// Exclusions check
|
|
if (mod.exclusions && mod.exclusions.length > 0) {
|
|
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
|
if (conflicting.length > 0) {
|
|
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Product-level constraints validation
|
|
const selectedProductIds = Object.values(selections)
|
|
.map(s => s.productId)
|
|
.filter((id): id is string => !!id)
|
|
|
|
for (const prodId of selectedProductIds) {
|
|
const prod = dbProducts.find(p => p.id === prodId)
|
|
if (!prod) continue
|
|
|
|
// Product requirements check
|
|
if (prod.requirements && prod.requirements.length > 0) {
|
|
const hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId))
|
|
if (!hasMetRequirement) {
|
|
const reqNames = prod.requirements
|
|
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
|
|
.join(' oder ')
|
|
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`)
|
|
}
|
|
}
|
|
|
|
// Product exclusions check
|
|
if (prod.exclusions && prod.exclusions.length > 0) {
|
|
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
|
|
if (conflicting.length > 0) {
|
|
const conflictingNames = conflicting
|
|
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
|
|
.join(', ')
|
|
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Snapshots aufbauen
|
|
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
|
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
|
|
const orderHash = hashOrderSnapshot(orderSnapshot)
|
|
|
|
// 2. Bestellung in DB aktualisieren
|
|
const { data: order, error: orderError } = await admin
|
|
.from('orders')
|
|
.update({
|
|
end_customer_id: endCustomerId ?? null,
|
|
total_price: orderSnapshot.total,
|
|
customer_data: customerSnapshot,
|
|
order_data: orderSnapshot,
|
|
order_hash: orderHash,
|
|
})
|
|
.eq('id', orderId)
|
|
.select()
|
|
.single()
|
|
|
|
if (orderError || !order) throw orderError || new Error('Fehler beim Aktualisieren der Bestellung.')
|
|
|
|
// 3. PDF generieren und überschreiben
|
|
try {
|
|
const buffer = await renderToBuffer(
|
|
React.createElement(InvoicePDF, {
|
|
order,
|
|
customer: customerSnapshot,
|
|
orderSnapshot,
|
|
})
|
|
)
|
|
|
|
// PDF hochladen (upsert: true, um das alte zu ersetzen)
|
|
const fileName = `ab_${order.id}.pdf`
|
|
const { error: uploadError } = await supabase
|
|
.storage
|
|
.from('invoices')
|
|
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
|
|
|
|
if (uploadError) {
|
|
console.error('PDF Upload Error:', uploadError)
|
|
} else {
|
|
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
|
await admin.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
|
order.pdf_url = publicUrlData.publicUrl
|
|
}
|
|
|
|
// 4. E-Mail mit PDF-Anhang an den Besteller senden (Info über Änderung)
|
|
const orderUserEmail = user.email || (await admin.auth.admin.getUserById(existingOrder.user_id).then(res => res.data.user?.email))
|
|
if (orderUserEmail) {
|
|
try {
|
|
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE')
|
|
|
|
const taxRate = orderSnapshot.tax_rate ?? 19
|
|
const items = orderSnapshot.items ?? []
|
|
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time')
|
|
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly')
|
|
|
|
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
|
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100
|
|
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100
|
|
|
|
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
|
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
|
|
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
|
|
|
|
let totalDetailsText = ''
|
|
if (oneTimeNet > 0 && monthlyNet > 0) {
|
|
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
|
} else if (oneTimeNet > 0) {
|
|
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`
|
|
} else {
|
|
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
|
}
|
|
|
|
let totalDetailsHtml = ''
|
|
if (oneTimeNet > 0 && monthlyNet > 0) {
|
|
totalDetailsHtml = `
|
|
<tr>
|
|
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
`
|
|
} else if (oneTimeNet > 0) {
|
|
totalDetailsHtml = `
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
|
</tr>
|
|
`
|
|
} else {
|
|
totalDetailsHtml = `
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
|
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
|
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
|
</tr>
|
|
`
|
|
}
|
|
|
|
await sendMail({
|
|
to: orderUserEmail,
|
|
subject: `Anfrageänderung ${order.order_number}`,
|
|
text: `Hallo,\n\nihre Anfrage wurde erfolgreich aktualisiert!\n\nNeue Anfragedetails:\n- Anfrage-Nummer: ${order.order_number}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n${totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre aktualisierte Anfrage als PDF-Dokument.\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
|
html: `
|
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
|
<h2 style="color: #0f172a; margin-bottom: 16px;">Ihre Anfrage wurde aktualisiert!</h2>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Ihr Anfrage mit der Nummer ${order.order_number} wurde erfolgreich aktualisiert.</p>
|
|
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
|
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Neue Anfrage-Details</h3>
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Anfrage-Nummer:</td>
|
|
<td style="padding: 4px 0;">${order.order_number}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
|
|
<td style="padding: 4px 0;">${formattedDate}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
|
|
<td style="padding: 4px 0;">${customerSnapshot.company_name}</td>
|
|
</tr>
|
|
${totalDetailsHtml}
|
|
</table>
|
|
</div>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre aktualisierte Anfragebestätigung als PDF-Dokument.</p>
|
|
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
|
|
</div>
|
|
`,
|
|
attachments: [
|
|
{
|
|
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
|
content: buffer,
|
|
contentType: 'application/pdf',
|
|
}
|
|
]
|
|
})
|
|
} catch (mailError) {
|
|
console.error('Failed to send order update confirmation mail:', mailError)
|
|
}
|
|
}
|
|
} catch (pdfError) {
|
|
console.error('PDF Re-generation Error:', pdfError)
|
|
}
|
|
|
|
revalidatePath('/my-customers')
|
|
revalidatePath('/my-orders')
|
|
revalidatePath('/admin/orders')
|
|
return order as Order
|
|
}
|