'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 { getOrderEmailTemplate, buildEmailItemsSection } 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 };
}