290 lines
14 KiB
TypeScript
290 lines
14 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { createClient } from '@/lib/supabase/server';
|
|
import { getProducts, getCategories } from '@/lib/actions/products';
|
|
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
|
|
import { sendMail } from '@/utils/mail';
|
|
import { createHash } from 'crypto';
|
|
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 hashOrderSnapshot(snapshot: object): string {
|
|
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
}
|
|
|
|
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) {
|
|
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { items, customerProfile, endCustomerId, endCustomer } = body;
|
|
|
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
|
return NextResponse.json({ error: 'Warenkorb leer' }, { status: 400 });
|
|
}
|
|
|
|
const products = await getProducts();
|
|
const categories = await getCategories();
|
|
|
|
// Split items into purchase and subscription
|
|
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 createdOrders: any[] = [];
|
|
|
|
const processOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
|
|
if (groupItems.length === 0) return;
|
|
|
|
// Build consolidated snapshots
|
|
// For the multi-item support, we combine the order snapshots of each item in the group
|
|
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer);
|
|
|
|
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, // default
|
|
tax_amount: Math.round(total * 0.19 * 100) / 100,
|
|
subtotal: Math.round((total / 1.19) * 100) / 100
|
|
};
|
|
|
|
const orderHash = hashOrderSnapshot(orderSnapshot);
|
|
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 || null,
|
|
total_price: total,
|
|
customer_data: customerSnapshot,
|
|
order_data: orderSnapshot,
|
|
type: type,
|
|
payment_method: paymentMethod,
|
|
status: 'pending'
|
|
}])
|
|
.select()
|
|
.single();
|
|
|
|
if (orderError) throw orderError;
|
|
|
|
const fileName = `ab_${order.id}.pdf`;
|
|
|
|
// Trigger PDF generation & storage upload
|
|
try {
|
|
const buffer = await renderToBuffer(
|
|
React.createElement(InvoicePDF, {
|
|
order,
|
|
customer: customerSnapshot,
|
|
orderSnapshot,
|
|
})
|
|
);
|
|
|
|
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);
|
|
order.pdf_url = fileName;
|
|
}
|
|
} catch (pdfErr) {
|
|
console.error('PDF error for order ' + order.id, pdfErr);
|
|
}
|
|
|
|
// Trigger E-Mail notifications
|
|
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>
|
|
`;
|
|
}
|
|
|
|
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);
|
|
|
|
const { data: bufferData, error: downloadError } = await supabase
|
|
.storage
|
|
.from('invoices')
|
|
.download(fileName);
|
|
|
|
const attachments: any[] = [];
|
|
if (!downloadError && bufferData) {
|
|
const buffer = Buffer.from(await bufferData.arrayBuffer());
|
|
attachments.push({
|
|
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
|
content: buffer,
|
|
contentType: 'application/pdf',
|
|
});
|
|
}
|
|
|
|
await sendMail({
|
|
to: user.email,
|
|
subject: `Anfragebestätigung ${order.order_number}`,
|
|
text: emailTemplate.text,
|
|
html: emailTemplate.html,
|
|
attachments
|
|
});
|
|
} catch (mailErr) {
|
|
console.error('Mail error for order ' + order.id, mailErr);
|
|
}
|
|
}
|
|
|
|
createdOrders.push(order);
|
|
};
|
|
|
|
await processOrderGroup(purchaseItems, 'purchase', 'invoice');
|
|
await processOrderGroup(subscriptionItems, 'subscription', 'sepa');
|
|
|
|
return NextResponse.json({ success: true, orders: createdOrders });
|
|
} catch (error: any) {
|
|
console.error('Checkout error:', error);
|
|
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|