'use server' import { createClient } from '@/lib/supabase/server'; import { createHash } from 'crypto'; import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'; import { getProducts, getCategories } from '@/lib/actions/products'; import { sendMail } from '@/utils/mail'; import { renderToBuffer } from '@react-pdf/renderer'; import React from 'react'; import { InvoicePDF } from '@/components/invoice-pdf'; import { buildEmailItemsSection } from '@/lib/actions/orders'; import { getOrderEmailTemplate } from '@/lib/actions/email-templates'; function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string { const year = new Date().getFullYear(); const rand = Math.floor(10000 + Math.random() * 90000); return `${prefix}-${year}-${rand}`; } function hashOrderPayload(endCustomerId: string, items: any[]): string { const str = endCustomerId + JSON.stringify(items); return createHash('sha256').update(str).digest('hex'); } // Asynchroner Hintergrund-Worker für Post-Processing (PDF-Erstellung und E-Mail) async function triggerPostProcessing(orderId: string, supabase: any, customerSnapshot: any, orderSnapshot: any, type: string, email: string) { try { const { data: order, error } = await supabase .from('orders') .select('*') .eq('id', orderId) .single(); if (error || !order) { console.error(`Post-Processing failed: Order ${orderId} not found`); return; } // PDF generieren const buffer = await renderToBuffer( React.createElement(InvoicePDF, { order, customer: customerSnapshot, orderSnapshot, }) ); const fileName = `ab_${order.id}.pdf`; const { error: uploadError } = await supabase .storage .from('invoices') .upload(fileName, buffer, { contentType: 'application/pdf', upsert: true }); if (!uploadError) { await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id); } else { console.error(`PDF Upload failed for order ${orderId}:`, uploadError); } // E-Mail versenden if (email) { 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 = ` Einmaliger Betrag: Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} Monatlicher Betrag: Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat `; } else if (oneTimeNet > 0) { totalDetailsHtml = ` Netto-Gesamtbetrag: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} Gesamtbetrag (brutto): ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} `; } else { totalDetailsHtml = ` Netto-Gesamtbetrag: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat Gesamtbetrag (brutto): ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat `; } const itemsSection = buildEmailItemsSection(items); const emailTemplate = getOrderEmailTemplate({ orderNumber: order.order_number, formattedDate, customerCompanyName: customerSnapshot.company_name, totalDetailsText, totalDetailsHtml, itemsDetailsText: itemsSection.text, itemsDetailsHtml: itemsSection.html }, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false); await sendMail({ to: email, subject: `Anfragebestätigung ${order.order_number}`, text: emailTemplate.text, html: emailTemplate.html, attachments: [ { filename: `Anfragebestaetigung_${order.order_number}.pdf`, content: buffer, contentType: 'application/pdf', } ] }); } } catch (err) { console.error(`Error in post-processing for order ${orderId}:`, err); } } export async function checkoutAction(params: { items: any[]; endCustomerId: string; customerProfile?: any; }) { const { items, endCustomerId, customerProfile } = params; const supabase = await createClient(); // 1. Session & Auth Check const { data: { user } } = await supabase.auth.getUser(); if (!user) { throw new Error('Not authenticated'); } const { data: dbUser } = await supabase .from('users') .select('role, company_id') .eq('id', user.id) .single(); const isAdminUser = dbUser?.role === 'admin'; if (!isAdminUser && !dbUser?.company_id) { throw new Error('Kein Unternehmen zugewiesen. Zugriff verweigert.'); } // 2. Idempotency Guard const orderHash = hashOrderPayload(endCustomerId, items); const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); const { data: existingOrders } = await supabase .from('orders') .select('id') .eq('user_id', user.id) .eq('order_hash', orderHash) .gte('created_at', fiveMinutesAgo); if (existingOrders && existingOrders.length > 0) { return { success: true, orderIds: existingOrders.map(o => o.id), duplicated: true }; } // Fetch End Customer Stammdaten const { data: endCustomer, error: customerErr } = await supabase .from('end_customers') .select('*') .eq('id', endCustomerId) .single(); if (customerErr || !endCustomer) { throw new Error('Endkunde nicht gefunden.'); } const customerSnapshot = buildCustomerSnapshot(customerProfile || {}, endCustomer); const products = await getProducts(); const categories = await getCategories(); // 3. Checkout Split const purchaseItems = items.filter( (item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase' ); const subscriptionItems = items.filter( (item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription' ); const orderIds: string[] = []; const createOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => { if (groupItems.length === 0) return; const orderItemsList: any[] = []; let total = 0; for (const item of groupItems) { const itemSnapshot = buildOrderSnapshot( item.selections, products, categories, type === 'purchase' ? 'one_time' : 'monthly', item.moduleQuantities ); const itemsWithDevice = itemSnapshot.items.map(i => ({ ...i, device_name: item.deviceName })); orderItemsList.push(...itemsWithDevice); total += itemSnapshot.total; } const orderSnapshot = { schema_version: 1, billing_cycle: type === 'purchase' ? 'one_time' : 'monthly', items: orderItemsList, total: total, tax_rate: 19, tax_amount: Math.round(total * 0.19 * 100) / 100, subtotal: Math.round((total / 1.19) * 100) / 100 }; const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE'); const { data: order, error: orderError } = await supabase .from('orders') .insert([{ user_id: user.id, company_id: dbUser?.company_id || null, order_number: orderNumber, order_hash: orderHash, end_customer_id: endCustomerId, total_price: total, customer_data: customerSnapshot, order_data: orderSnapshot, type: type, payment_method: paymentMethod, status: 'pending' }]) .select() .single(); if (orderError) throw orderError; orderIds.push(order.id); // Entkoppeltes Post-Processing (asynchroner Aufruf) if (user.email) { Promise.resolve().then(() => { triggerPostProcessing(order.id, supabase, customerSnapshot, orderSnapshot, type, user.email!); }); } }; await createOrderGroup(purchaseItems, 'purchase', 'invoice'); await createOrderGroup(subscriptionItems, 'subscription', 'sepa'); return { success: true, orderIds, duplicated: false }; }