feat: Equipment-System, Buchungsbearbeitung, Kundenadresse, LexOffice-Fix
- Vintage Modell hinzugefuegt - Equipment Multi-Select (Neue Buchung + Bearbeitung) - Kundenadresse in Formularen - Bearbeiten-Seite fuer Buchungen - Abbau-Zeiten in Formularen und Uebersicht - Vertrag PDF nur bei Privatkunden - LexOffice Kontakt-Erstellung Fix (BUSINESS) - Zurueck-Pfeil auf Touren-Seite
This commit is contained in:
189
lib/booking-automation.ts
Normal file
189
lib/booking-automation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { prisma } from './prisma';
|
||||
import { sendInitialBookingEmail } from './email-service';
|
||||
import { nextcloudCalendar } from './nextcloud-calendar';
|
||||
import { lexofficeService } from './lexoffice';
|
||||
import { generateContractFromTemplate } from './pdf-template-service';
|
||||
|
||||
export class BookingAutomationService {
|
||||
async runPostBookingActions(bookingId: string): Promise<{
|
||||
emailSent: boolean;
|
||||
calendarSynced: boolean;
|
||||
lexofficeCreated: boolean;
|
||||
contractGenerated: boolean;
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
let emailSent = false;
|
||||
let calendarSynced = false;
|
||||
let lexofficeCreated = false;
|
||||
let contractGenerated = false;
|
||||
|
||||
try {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
location: true,
|
||||
photobox: true,
|
||||
bookingEquipment: {
|
||||
include: {
|
||||
equipment: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
errors.push('Buchung nicht gefunden');
|
||||
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
|
||||
}
|
||||
|
||||
let priceConfig = null;
|
||||
if (booking.photobox?.model && booking.locationId) {
|
||||
priceConfig = await prisma.priceConfig.findUnique({
|
||||
where: {
|
||||
locationId_model: {
|
||||
locationId: booking.locationId,
|
||||
model: booking.photobox.model,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const bookingWithPriceConfig = {
|
||||
...booking,
|
||||
priceConfig,
|
||||
};
|
||||
|
||||
console.log(`🤖 Automatische Aktionen für Buchung ${booking.bookingNumber}...`);
|
||||
|
||||
let quotationPdf: Buffer | null = null;
|
||||
let contractPdf: Buffer | null = null;
|
||||
|
||||
// 1. LexOffice Contact + Quotation erstellen
|
||||
try {
|
||||
console.log(' 💼 Erstelle LexOffice-Kontakt und Angebot...');
|
||||
|
||||
const contactId = await lexofficeService.createContactFromBooking(bookingWithPriceConfig);
|
||||
console.log(` ✅ LexOffice-Kontakt erstellt: ${contactId}`);
|
||||
|
||||
const quotationId = await lexofficeService.createQuotationFromBooking(bookingWithPriceConfig, contactId);
|
||||
console.log(` ✅ LexOffice-Angebot erstellt: ${quotationId}`);
|
||||
|
||||
await prisma.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
lexofficeContactId: contactId,
|
||||
lexofficeOfferId: quotationId,
|
||||
},
|
||||
});
|
||||
|
||||
quotationPdf = await lexofficeService.getQuotationPDF(quotationId);
|
||||
console.log(' ✅ Angebots-PDF heruntergeladen');
|
||||
|
||||
lexofficeCreated = true;
|
||||
} catch (error: any) {
|
||||
console.error(' ❌ LexOffice Fehler:', error.message);
|
||||
errors.push(`LexOffice: ${error.message}`);
|
||||
}
|
||||
|
||||
// 2. Mietvertrag-PDF generieren
|
||||
try {
|
||||
console.log(' 📄 Generiere Mietvertrag-PDF...');
|
||||
|
||||
contractPdf = await generateContractFromTemplate(
|
||||
bookingWithPriceConfig,
|
||||
booking.location,
|
||||
booking.photobox
|
||||
);
|
||||
|
||||
await prisma.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
contractGenerated: true,
|
||||
contractGeneratedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(' ✅ Mietvertrag-PDF generiert');
|
||||
contractGenerated = true;
|
||||
} catch (error: any) {
|
||||
console.error(' ❌ PDF-Generierung Fehler:', error.message);
|
||||
errors.push(`PDF: ${error.message}`);
|
||||
}
|
||||
|
||||
// 3. E-Mail mit Angebot + Vertrag versenden
|
||||
if (quotationPdf && contractPdf) {
|
||||
try {
|
||||
console.log(' 📧 Sende E-Mail mit Angebot und Vertrag...');
|
||||
|
||||
await sendInitialBookingEmail(bookingWithPriceConfig, quotationPdf, contractPdf);
|
||||
|
||||
await prisma.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
contractSentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
emailSent = true;
|
||||
console.log(' ✅ E-Mail gesendet');
|
||||
} catch (error: any) {
|
||||
console.error(' ❌ E-Mail Fehler:', error.message);
|
||||
errors.push(`E-Mail: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(' ⚠️ E-Mail nicht gesendet - PDFs fehlen');
|
||||
errors.push('E-Mail: PDFs nicht verfügbar');
|
||||
}
|
||||
|
||||
// 4. Automatischer Nextcloud Kalender-Sync
|
||||
try {
|
||||
console.log(' 📅 Synchronisiere mit Nextcloud-Kalender...');
|
||||
|
||||
await nextcloudCalendar.syncBookingToCalendar(booking);
|
||||
|
||||
await prisma.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
calendarSynced: true,
|
||||
calendarSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
calendarSynced = true;
|
||||
console.log(' ✅ Kalender synchronisiert');
|
||||
} catch (error: any) {
|
||||
console.error(' ❌ Kalender-Sync Fehler:', error.message);
|
||||
errors.push(`Kalender: ${error.message}`);
|
||||
}
|
||||
|
||||
// 5. Admin-Benachrichtigung erstellen
|
||||
try {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'Neue Buchungsanfrage',
|
||||
message: `${booking.customerName} hat eine ${booking.photobox?.model || 'Fotobox'} für ${booking.eventCity} am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} angefragt.`,
|
||||
metadata: {
|
||||
bookingId: booking.id,
|
||||
bookingNumber: booking.bookingNumber,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(' ✅ Admin-Benachrichtigung erstellt');
|
||||
} catch (error: any) {
|
||||
console.error(' ❌ Notification Fehler:', error.message);
|
||||
}
|
||||
|
||||
console.log(`✅ Automatische Aktionen abgeschlossen (${errors.length} Fehler)`);
|
||||
|
||||
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
|
||||
} catch (error: any) {
|
||||
console.error('❌ Booking Automation Fehler:', error);
|
||||
errors.push(error.message);
|
||||
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bookingAutomationService = new BookingAutomationService();
|
||||
88
lib/distance-calculator.ts
Normal file
88
lib/distance-calculator.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
interface Coordinates {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
interface DistanceResult {
|
||||
distance: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class DistanceCalculator {
|
||||
private static readonly OSRM_API = 'https://router.project-osrm.org/route/v1/driving';
|
||||
|
||||
static async geocodeAddress(address: string): Promise<Coordinates | null> {
|
||||
try {
|
||||
const nominatimUrl = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`;
|
||||
|
||||
const response = await fetch(nominatimUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'SaveTheMoment-Atlas/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Geocoding failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
console.warn(`No results found for address: ${address}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
lat: parseFloat(data[0].lat),
|
||||
lon: parseFloat(data[0].lon),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Geocoding error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async calculateDistance(
|
||||
fromAddress: string,
|
||||
toAddress: string
|
||||
): Promise<DistanceResult | null> {
|
||||
try {
|
||||
const fromCoords = await this.geocodeAddress(fromAddress);
|
||||
const toCoords = await this.geocodeAddress(toAddress);
|
||||
|
||||
if (!fromCoords || !toCoords) {
|
||||
console.error('Failed to geocode one or both addresses');
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = `${this.OSRM_API}/${fromCoords.lon},${fromCoords.lat};${toCoords.lon},${toCoords.lat}?overview=false`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OSRM API failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.routes || data.routes.length === 0) {
|
||||
console.error('No route found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const route = data.routes[0];
|
||||
|
||||
return {
|
||||
distance: Math.round(route.distance / 1000 * 100) / 100,
|
||||
duration: Math.round(route.duration / 60),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Distance calculation error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static formatAddress(address: string, zip: string, city: string): string {
|
||||
return `${address}, ${zip} ${city}`.trim();
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,25 @@ import nodemailer from 'nodemailer';
|
||||
import path from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
interface LocationSmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
secure: boolean;
|
||||
from: string;
|
||||
}
|
||||
|
||||
function getTransporter() {
|
||||
if (transporter) return transporter;
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT || '587');
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
const smtpFrom = process.env.SMTP_FROM || 'noreply@savethemoment.photos';
|
||||
|
||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||
console.warn('⚠️ SMTP credentials not configured. Email sending disabled.');
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpPort === 465,
|
||||
function createTransporter(config: LocationSmtpConfig) {
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
user: config.user,
|
||||
pass: config.password,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ SMTP transporter initialized');
|
||||
return transporter;
|
||||
}
|
||||
|
||||
interface SendEmailOptions {
|
||||
@@ -37,6 +28,7 @@ interface SendEmailOptions {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
smtpConfig: LocationSmtpConfig;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
content?: Buffer;
|
||||
@@ -45,21 +37,59 @@ interface SendEmailOptions {
|
||||
}
|
||||
|
||||
export async function sendEmail(options: SendEmailOptions) {
|
||||
const emailEnabled = process.env.EMAIL_ENABLED !== 'false';
|
||||
const testMode = process.env.TEST_MODE === 'true';
|
||||
const testRecipient = process.env.TEST_EMAIL_RECIPIENT;
|
||||
|
||||
// E-Mail komplett deaktiviert
|
||||
if (!emailEnabled) {
|
||||
console.log('📧 [EMAIL DISABLED] E-Mail würde gesendet an:', options.to);
|
||||
console.log(' Betreff:', options.subject);
|
||||
console.log(' Von:', options.smtpConfig.from);
|
||||
console.log(' ⚠️ EMAIL_ENABLED=false - Kein echter Versand!');
|
||||
return { success: true, messageId: 'test-disabled', mode: 'disabled' };
|
||||
}
|
||||
|
||||
// Test-Modus: Umleitung an Test-E-Mail
|
||||
let actualRecipient = options.to;
|
||||
if (testMode && testRecipient) {
|
||||
console.log('🧪 [TEST MODE] E-Mail umgeleitet!');
|
||||
console.log(' Original-Empfänger:', options.to);
|
||||
console.log(' Test-Empfänger:', testRecipient);
|
||||
actualRecipient = testRecipient;
|
||||
|
||||
// Füge Hinweis in Betreff ein
|
||||
options.subject = `[TEST] ${options.subject}`;
|
||||
|
||||
// Füge Hinweis in E-Mail ein
|
||||
options.html = `
|
||||
<div style="background: #FEF3C7; border: 2px solid #F59E0B; padding: 15px; margin-bottom: 20px; border-radius: 8px;">
|
||||
<p style="margin: 0; color: #92400E; font-weight: bold;">🧪 TEST-MODUS AKTIV</p>
|
||||
<p style="margin: 5px 0 0 0; color: #92400E; font-size: 14px;">
|
||||
Diese E-Mail wäre ursprünglich an <strong>${options.to}</strong> gegangen.
|
||||
</p>
|
||||
</div>
|
||||
${options.html}
|
||||
`;
|
||||
}
|
||||
|
||||
try {
|
||||
const transport = getTransporter();
|
||||
const from = process.env.SMTP_FROM || 'SaveTheMoment <noreply@savethemoment.photos>';
|
||||
const transport = createTransporter(options.smtpConfig);
|
||||
|
||||
const info = await transport.sendMail({
|
||||
from,
|
||||
to: options.to,
|
||||
from: options.smtpConfig.from,
|
||||
to: actualRecipient,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html,
|
||||
attachments: options.attachments,
|
||||
});
|
||||
|
||||
console.log('✅ Email sent:', info.messageId);
|
||||
return { success: true, messageId: info.messageId };
|
||||
console.log('✅ Email sent:', info.messageId, 'from:', options.smtpConfig.from);
|
||||
if (testMode) {
|
||||
console.log(' 🧪 TEST MODE - E-Mail an:', actualRecipient, '(Original:', options.to, ')');
|
||||
}
|
||||
return { success: true, messageId: info.messageId, mode: testMode ? 'test' : 'production' };
|
||||
} catch (error: any) {
|
||||
console.error('❌ Email send error:', error);
|
||||
throw error;
|
||||
@@ -70,6 +100,21 @@ export async function sendContractEmail(
|
||||
booking: any,
|
||||
contractPdfPath: string
|
||||
) {
|
||||
const location = booking.location;
|
||||
|
||||
if (!location || !location.smtpHost || !location.smtpPassword) {
|
||||
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
|
||||
}
|
||||
|
||||
const smtpConfig: LocationSmtpConfig = {
|
||||
host: location.smtpHost,
|
||||
port: location.smtpPort || 465,
|
||||
user: location.smtpUser || location.contactEmail,
|
||||
password: location.smtpPassword,
|
||||
secure: location.smtpSecure !== false,
|
||||
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
|
||||
};
|
||||
|
||||
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
|
||||
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
|
||||
|
||||
@@ -130,14 +175,14 @@ export async function sendContractEmail(
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🎉 SaveTheMoment</h1>
|
||||
<h1>🎉 SaveTheMoment ${location.name}</h1>
|
||||
<p>Ihr Mietvertrag ist bereit!</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hallo ${booking.customerName},</p>
|
||||
|
||||
<p>vielen Dank für Ihre Buchung bei SaveTheMoment! Wir freuen uns sehr, Teil Ihres besonderen Anlasses zu sein.</p>
|
||||
<p>vielen Dank für Ihre Buchung bei SaveTheMoment ${location.name}! Wir freuen uns sehr, Teil Ihres besonderen Anlasses zu sein.</p>
|
||||
|
||||
<div class="details">
|
||||
<h3>📋 Buchungsdetails</h3>
|
||||
@@ -171,13 +216,13 @@ export async function sendContractEmail(
|
||||
<p>Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung!</p>
|
||||
|
||||
<p>Mit freundlichen Grüßen<br>
|
||||
Ihr SaveTheMoment Team</p>
|
||||
Ihr SaveTheMoment Team ${location.name}</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>SaveTheMoment Fotoboxen<br>
|
||||
E-Mail: info@savethemoment.photos<br>
|
||||
Web: www.savethemoment.photos</p>
|
||||
<p>SaveTheMoment ${location.name}<br>
|
||||
E-Mail: ${location.contactEmail}<br>
|
||||
Web: ${location.websiteUrl || 'www.savethemoment.photos'}</p>
|
||||
<p style="color: #999; font-size: 11px;">
|
||||
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht direkt auf diese E-Mail.
|
||||
</p>
|
||||
@@ -189,7 +234,7 @@ export async function sendContractEmail(
|
||||
const text = `
|
||||
Hallo ${booking.customerName},
|
||||
|
||||
vielen Dank für Ihre Buchung bei SaveTheMoment!
|
||||
vielen Dank für Ihre Buchung bei SaveTheMoment ${location.name}!
|
||||
|
||||
Buchungsdetails:
|
||||
- Buchungsnummer: ${booking.bookingNumber}
|
||||
@@ -206,12 +251,12 @@ Oder drucken Sie ihn aus und senden Sie ihn uns zurück.
|
||||
Bei Fragen stehen wir Ihnen jederzeit zur Verfügung!
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Ihr SaveTheMoment Team
|
||||
Ihr SaveTheMoment Team ${location.name}
|
||||
|
||||
---
|
||||
SaveTheMoment Fotoboxen
|
||||
E-Mail: info@savethemoment.photos
|
||||
Web: www.savethemoment.photos
|
||||
SaveTheMoment ${location.name}
|
||||
E-Mail: ${location.contactEmail}
|
||||
Web: ${location.websiteUrl || 'www.savethemoment.photos'}
|
||||
`.trim();
|
||||
|
||||
let pdfBuffer: Buffer;
|
||||
@@ -227,6 +272,7 @@ Web: www.savethemoment.photos
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
smtpConfig,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
|
||||
@@ -236,7 +282,234 @@ Web: www.savethemoment.photos
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendInitialBookingEmail(
|
||||
booking: any,
|
||||
quotationPdf: Buffer,
|
||||
contractPdf: Buffer
|
||||
) {
|
||||
const location = booking.location;
|
||||
|
||||
if (!location || !location.smtpHost || !location.smtpPassword) {
|
||||
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
|
||||
}
|
||||
|
||||
const smtpConfig: LocationSmtpConfig = {
|
||||
host: location.smtpHost,
|
||||
port: location.smtpPort || 465,
|
||||
user: location.smtpUser || location.contactEmail,
|
||||
password: location.smtpPassword,
|
||||
secure: location.smtpSecure !== false,
|
||||
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
|
||||
};
|
||||
|
||||
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
|
||||
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
|
||||
|
||||
const subject = `Ihre Anfrage bei SaveTheMoment ${location.name} - ${booking.bookingNumber}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #DC2626 0%, #EC4899 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
.content {
|
||||
background: #f9fafb;
|
||||
padding: 30px;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #DC2626 0%, #EC4899 100%);
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.details {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
border-left: 4px solid #DC2626;
|
||||
}
|
||||
.price-box {
|
||||
background: #FEF3C7;
|
||||
border: 2px solid #F59E0B;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🎉 SaveTheMoment ${location.name}</h1>
|
||||
<p>Vielen Dank für Ihre Anfrage!</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hallo ${booking.customerName},</p>
|
||||
|
||||
<p>herzlichen Dank für Ihre Anfrage! Wir freuen uns sehr, dass Sie sich für SaveTheMoment entschieden haben.</p>
|
||||
|
||||
<div class="details">
|
||||
<h3>📋 Ihre Buchungsdetails</h3>
|
||||
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
|
||||
<p><strong>Event-Datum:</strong> ${new Date(booking.eventDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}</p>
|
||||
<p><strong>Event-Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
|
||||
<p><strong>Fotobox:</strong> ${booking.photobox?.model || 'N/A'}</p>
|
||||
${booking.distance ? `<p><strong>Entfernung:</strong> ${booking.distance.toFixed(1)} km (einfach)</p>` : ''}
|
||||
</div>
|
||||
|
||||
${booking.calculatedPrice ? `
|
||||
<div class="price-box">
|
||||
<h3 style="margin: 0 0 10px 0; color: #92400E;">💰 Gesamtpreis</h3>
|
||||
<p style="font-size: 28px; font-weight: bold; margin: 0; color: #92400E;">
|
||||
${booking.calculatedPrice.toFixed(2)} €
|
||||
</p>
|
||||
<p style="font-size: 14px; margin: 5px 0 0 0; color: #92400E;">inkl. 19% MwSt.</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<p><strong>📎 Im Anhang finden Sie:</strong></p>
|
||||
<ol>
|
||||
<li><strong>Ihr persönliches Angebot</strong> mit allen Details und Positionen</li>
|
||||
<li><strong>Ihren Mietvertrag</strong> zum Durchlesen</li>
|
||||
</ol>
|
||||
|
||||
<p><strong>✅ Nächste Schritte:</strong></p>
|
||||
<ol>
|
||||
<li>Prüfen Sie bitte das Angebot und den Mietvertrag</li>
|
||||
<li>Signieren Sie den Vertrag online oder laden Sie ihn unterschrieben hoch</li>
|
||||
<li>Nach Ihrer Unterschrift wird Ihre Buchung verbindlich bestätigt</li>
|
||||
</ol>
|
||||
|
||||
<center>
|
||||
<a href="${signUrl}" class="button">
|
||||
✍️ Vertrag jetzt online signieren
|
||||
</a>
|
||||
</center>
|
||||
|
||||
<p>Alternativ können Sie den Vertrag auch ausdrucken, unterschreiben und uns per E-Mail zurücksenden.</p>
|
||||
|
||||
<p><strong>Haben Sie Fragen oder Änderungswünsche?</strong><br>
|
||||
Antworten Sie einfach auf diese E-Mail – wir sind für Sie da!</p>
|
||||
|
||||
<p>Mit freundlichen Grüßen<br>
|
||||
Ihr SaveTheMoment Team ${location.name}</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>SaveTheMoment ${location.name}<br>
|
||||
E-Mail: ${location.contactEmail}<br>
|
||||
Web: ${location.websiteUrl || 'www.savethemoment.photos'}</p>
|
||||
<p style="color: #999; font-size: 11px;">
|
||||
Diese E-Mail wurde automatisch generiert.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
const text = `
|
||||
Hallo ${booking.customerName},
|
||||
|
||||
vielen Dank für Ihre Anfrage bei SaveTheMoment ${location.name}!
|
||||
|
||||
Buchungsdetails:
|
||||
- Buchungsnummer: ${booking.bookingNumber}
|
||||
- Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
|
||||
- Location: ${booking.eventLocation || booking.eventAddress}
|
||||
- Fotobox: ${booking.photobox?.model || 'N/A'}
|
||||
${booking.distance ? `- Entfernung: ${booking.distance.toFixed(1)} km` : ''}
|
||||
${booking.calculatedPrice ? `\nGesamtpreis: ${booking.calculatedPrice.toFixed(2)} € (inkl. 19% MwSt.)` : ''}
|
||||
|
||||
Im Anhang finden Sie:
|
||||
1. Ihr persönliches Angebot
|
||||
2. Ihren Mietvertrag
|
||||
|
||||
Nächste Schritte:
|
||||
1. Prüfen Sie bitte das Angebot und den Mietvertrag
|
||||
2. Signieren Sie den Vertrag online: ${signUrl}
|
||||
3. Nach Ihrer Unterschrift wird Ihre Buchung verbindlich bestätigt
|
||||
|
||||
Bei Fragen stehen wir Ihnen jederzeit zur Verfügung!
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Ihr SaveTheMoment Team ${location.name}
|
||||
|
||||
---
|
||||
SaveTheMoment ${location.name}
|
||||
E-Mail: ${location.contactEmail}
|
||||
Web: ${location.websiteUrl || 'www.savethemoment.photos'}
|
||||
`.trim();
|
||||
|
||||
return sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
smtpConfig,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Angebot_${booking.bookingNumber}.pdf`,
|
||||
content: quotationPdf,
|
||||
},
|
||||
{
|
||||
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
|
||||
content: contractPdf,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingConfirmationEmail(booking: any) {
|
||||
const location = booking.location;
|
||||
|
||||
if (!location || !location.smtpHost || !location.smtpPassword) {
|
||||
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
|
||||
}
|
||||
|
||||
const smtpConfig: LocationSmtpConfig = {
|
||||
host: location.smtpHost,
|
||||
port: location.smtpPort || 465,
|
||||
user: location.smtpUser || location.contactEmail,
|
||||
password: location.smtpPassword,
|
||||
secure: location.smtpSecure !== false,
|
||||
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
|
||||
};
|
||||
|
||||
const subject = `Buchungsbestätigung - ${booking.bookingNumber}`;
|
||||
|
||||
const html = `
|
||||
@@ -257,7 +530,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo ${booking.customerName},</p>
|
||||
<p>Ihre Buchung wurde erfolgreich bestätigt!</p>
|
||||
<p>Ihre Buchung bei SaveTheMoment ${location.name} wurde erfolgreich bestätigt!</p>
|
||||
<div class="details">
|
||||
<h3>Buchungsdetails</h3>
|
||||
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
|
||||
@@ -265,7 +538,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
|
||||
<p><strong>Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
|
||||
</div>
|
||||
<p>Wir freuen uns auf Ihr Event!</p>
|
||||
<p>Mit freundlichen Grüßen<br>Ihr SaveTheMoment Team</p>
|
||||
<p>Mit freundlichen Grüßen<br>Ihr SaveTheMoment Team ${location.name}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -274,7 +547,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
|
||||
const text = `
|
||||
Hallo ${booking.customerName},
|
||||
|
||||
Ihre Buchung wurde erfolgreich bestätigt!
|
||||
Ihre Buchung bei SaveTheMoment ${location.name} wurde erfolgreich bestätigt!
|
||||
|
||||
Buchungsnummer: ${booking.bookingNumber}
|
||||
Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
|
||||
@@ -283,7 +556,7 @@ Location: ${booking.eventLocation || booking.eventAddress}
|
||||
Wir freuen uns auf Ihr Event!
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Ihr SaveTheMoment Team
|
||||
Ihr SaveTheMoment Team ${location.name}
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
@@ -291,5 +564,6 @@ Ihr SaveTheMoment Team
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
smtpConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Imap from 'imap';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { prisma } from './prisma';
|
||||
import { emailParser } from './email-parser';
|
||||
import { bookingAutomationService } from './booking-automation';
|
||||
|
||||
export interface ImapConfig {
|
||||
host: string;
|
||||
@@ -335,6 +336,12 @@ export class EmailSyncService {
|
||||
},
|
||||
});
|
||||
|
||||
// 🤖 Automatische Post-Booking Aktionen
|
||||
console.log('📢 Starte automatische Aktionen...');
|
||||
bookingAutomationService.runPostBookingActions(booking.id).catch(err => {
|
||||
console.error('⚠️ Automatische Aktionen fehlgeschlagen:', err);
|
||||
});
|
||||
|
||||
return booking;
|
||||
} catch (error) {
|
||||
console.error('Failed to create booking from parsed data:', error);
|
||||
|
||||
260
lib/lexoffice.ts
260
lib/lexoffice.ts
@@ -146,14 +146,22 @@ export class LexOfficeService {
|
||||
return this.request('GET', `/contacts/${contactId}`);
|
||||
}
|
||||
|
||||
async createQuotation(quotation: LexOfficeQuotation): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
||||
return this.request('POST', '/quotations', quotation);
|
||||
async createQuotation(quotation: LexOfficeQuotation, finalize: boolean = false): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
||||
const url = finalize ? '/quotations?finalize=true' : '/quotations';
|
||||
console.log(`📍 Creating quotation with URL: ${this.baseUrl}${url}, finalize=${finalize}`);
|
||||
return this.request('POST', url, quotation);
|
||||
}
|
||||
|
||||
async getQuotation(quotationId: string): Promise<LexOfficeQuotation> {
|
||||
return this.request('GET', `/quotations/${quotationId}`);
|
||||
}
|
||||
|
||||
async finalizeQuotation(quotationId: string): Promise<{ id: string; resourceUri: string }> {
|
||||
return this.request('PUT', `/quotations/${quotationId}/pursue`, {
|
||||
precedingSalesVoucherId: null,
|
||||
});
|
||||
}
|
||||
|
||||
async createInvoice(invoice: LexOfficeInvoice): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
||||
return this.request('POST', '/invoices', invoice);
|
||||
}
|
||||
@@ -169,6 +177,52 @@ export class LexOfficeService {
|
||||
});
|
||||
}
|
||||
|
||||
async getQuotationPDF(quotationId: string): Promise<Buffer> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/quotations/${quotationId}/document`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Accept': 'application/pdf',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.error('LexOffice PDF download failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getInvoicePDF(invoiceId: string): Promise<Buffer> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/invoices/${invoiceId}/document`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Accept': 'application/pdf',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.error('LexOffice PDF download failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createContactFromBooking(booking: any): Promise<string> {
|
||||
const contact: Partial<LexOfficeContact> = {
|
||||
roles: {
|
||||
@@ -191,21 +245,26 @@ export class LexOfficeService {
|
||||
};
|
||||
|
||||
if (booking.invoiceType === 'BUSINESS' && booking.companyName) {
|
||||
const nameParts = booking.customerName.trim().split(' ');
|
||||
const firstName = nameParts[0] || '';
|
||||
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Ansprechpartner';
|
||||
contact.company = {
|
||||
name: booking.companyName,
|
||||
contactPersons: [{
|
||||
firstName: booking.customerName.split(' ')[0],
|
||||
lastName: booking.customerName.split(' ').slice(1).join(' '),
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
primary: true,
|
||||
emailAddress: booking.customerEmail,
|
||||
phoneNumber: booking.customerPhone,
|
||||
}],
|
||||
};
|
||||
} else {
|
||||
const [firstName, ...lastNameParts] = booking.customerName.split(' ');
|
||||
const nameParts = booking.customerName.trim().split(' ');
|
||||
const firstName = nameParts[0] || '';
|
||||
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Unbekannt';
|
||||
contact.person = {
|
||||
firstName: firstName,
|
||||
lastName: lastNameParts.join(' '),
|
||||
lastName: lastName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,46 +273,203 @@ export class LexOfficeService {
|
||||
}
|
||||
|
||||
async createQuotationFromBooking(booking: any, contactId: string): Promise<string> {
|
||||
const quotation: LexOfficeQuotation = {
|
||||
voucherDate: new Date().toISOString().split('T')[0],
|
||||
address: {
|
||||
contactId: contactId,
|
||||
countryCode: 'DE',
|
||||
const lineItems: Array<any> = [];
|
||||
|
||||
console.log('📊 Booking Data:', {
|
||||
id: booking.id,
|
||||
bookingNumber: booking.bookingNumber,
|
||||
hasPhotobox: !!booking.photobox,
|
||||
photoboxModel: booking.photobox?.model,
|
||||
locationId: booking.locationId,
|
||||
withPrintFlat: booking.withPrintFlat,
|
||||
});
|
||||
|
||||
// WICHTIG: priceConfig separat laden, da keine direkte Relation existiert
|
||||
let priceConfig = booking.priceConfig;
|
||||
|
||||
if (!priceConfig && booking.photobox?.model && booking.locationId) {
|
||||
console.log('🔍 Lade PriceConfig nach...');
|
||||
const { prisma } = await import('./prisma');
|
||||
priceConfig = await prisma.priceConfig.findUnique({
|
||||
where: {
|
||||
locationId_model: {
|
||||
locationId: booking.locationId,
|
||||
model: booking.photobox.model,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('📊 PriceConfig geladen:', priceConfig);
|
||||
}
|
||||
|
||||
if (!priceConfig) {
|
||||
throw new Error('Keine Preiskonfiguration gefunden. Bitte konfiguriere zuerst die Preise für dieses Fotobox-Modell.');
|
||||
}
|
||||
|
||||
// 1. Fotobox als Hauptposition (IMMER als custom lineItem)
|
||||
const withPrintFlat = booking.withPrintFlat !== false; // Default: true
|
||||
const boxName = booking.photobox?.model || 'Fotobox';
|
||||
const flatSuffix = withPrintFlat ? ' mit Druckflatrate' : ' (nur digital)';
|
||||
|
||||
const photoboxItem: any = {
|
||||
type: 'custom',
|
||||
name: `${boxName}${flatSuffix}`,
|
||||
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
|
||||
quantity: 1,
|
||||
unitName: 'Stück',
|
||||
unitPrice: {
|
||||
currency: 'EUR',
|
||||
netAmount: priceConfig?.basePrice || 1,
|
||||
taxRatePercentage: 19,
|
||||
},
|
||||
lineItems: [
|
||||
{
|
||||
};
|
||||
|
||||
lineItems.push(photoboxItem);
|
||||
|
||||
console.log('📦 Photobox LineItem (custom):', photoboxItem);
|
||||
|
||||
// 2. Kilometerpauschale (falls Distanz vorhanden)
|
||||
if (booking.distance && booking.distance > 0) {
|
||||
// 2a. Kilometer-Pauschale (bis X km) - IMMER als custom lineItem
|
||||
if (priceConfig?.kmFlatRate && priceConfig.kmFlatRate > 0) {
|
||||
const kmFlatItem: any = {
|
||||
type: 'custom',
|
||||
name: 'Fotobox-Vermietung',
|
||||
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
|
||||
name: `Kilometerpauschale (bis ${priceConfig.kmFlatRateUpTo}km)`,
|
||||
description: `Entfernung: ${booking.distance.toFixed(1)}km`,
|
||||
quantity: 1,
|
||||
unitName: 'Pauschale',
|
||||
unitPrice: {
|
||||
currency: 'EUR',
|
||||
netAmount: priceConfig.kmFlatRate / 1.19,
|
||||
taxRatePercentage: 19,
|
||||
},
|
||||
};
|
||||
|
||||
lineItems.push(kmFlatItem);
|
||||
}
|
||||
|
||||
// 2b. Zusätzliche Kilometer (wenn über Flatrate) - IMMER als custom lineItem
|
||||
if (priceConfig && booking.distance > priceConfig.kmFlatRateUpTo) {
|
||||
const extraKm = booking.distance - priceConfig.kmFlatRateUpTo;
|
||||
const totalExtraKm = extraKm * priceConfig.kmMultiplier;
|
||||
|
||||
const kmExtraItem: any = {
|
||||
type: 'custom',
|
||||
name: `Zusatzkilometer`,
|
||||
description: `${extraKm.toFixed(1)}km × ${priceConfig.kmMultiplier} Strecken = ${totalExtraKm.toFixed(1)}km`,
|
||||
quantity: totalExtraKm,
|
||||
unitName: 'km',
|
||||
unitPrice: {
|
||||
currency: 'EUR',
|
||||
netAmount: priceConfig.pricePerKm,
|
||||
taxRatePercentage: 19,
|
||||
},
|
||||
};
|
||||
|
||||
lineItems.push(kmExtraItem);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Equipment/Extras (wenn vorhanden) - IMMER als custom lineItem
|
||||
if (booking.bookingEquipment && booking.bookingEquipment.length > 0) {
|
||||
for (const eq of booking.bookingEquipment) {
|
||||
const equipmentItem: any = {
|
||||
type: 'custom',
|
||||
name: eq.equipment.name,
|
||||
quantity: eq.quantity || 1,
|
||||
unitName: 'Stück',
|
||||
unitPrice: {
|
||||
currency: 'EUR',
|
||||
netAmount: booking.calculatedPrice || 0,
|
||||
netAmount: eq.equipment.price || 1,
|
||||
taxRatePercentage: 19,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
lineItems.push(equipmentItem);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
const isDST = (date: Date) => {
|
||||
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
|
||||
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
|
||||
return Math.max(jan, jul) !== date.getTimezoneOffset();
|
||||
};
|
||||
|
||||
const timezone = isDST(now) ? '+02:00' : '+01:00';
|
||||
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
|
||||
|
||||
const expirationDateObj = new Date(now);
|
||||
expirationDateObj.setDate(expirationDateObj.getDate() + 14);
|
||||
const expYear = expirationDateObj.getFullYear();
|
||||
const expMonth = String(expirationDateObj.getMonth() + 1).padStart(2, '0');
|
||||
const expDay = String(expirationDateObj.getDate()).padStart(2, '0');
|
||||
const expirationDate = `${expYear}-${expMonth}-${expDay}T00:00:00.000${timezone}`;
|
||||
|
||||
const quotation: LexOfficeQuotation = {
|
||||
voucherDate,
|
||||
expirationDate,
|
||||
address: {
|
||||
contactId: contactId,
|
||||
name: booking.customerName,
|
||||
street: booking.customerAddress || undefined,
|
||||
zip: booking.customerZip || undefined,
|
||||
city: booking.customerCity || undefined,
|
||||
countryCode: 'DE',
|
||||
},
|
||||
lineItems,
|
||||
totalPrice: {
|
||||
currency: 'EUR',
|
||||
},
|
||||
taxConditions: {
|
||||
taxType: 'net',
|
||||
},
|
||||
title: `Angebot Fotobox-Vermietung - ${booking.bookingNumber}`,
|
||||
introduction: 'Vielen Dank für Ihre Anfrage! Gerne erstellen wir Ihnen folgendes Angebot:',
|
||||
remark: 'Wir freuen uns auf Ihre Bestellung!',
|
||||
};
|
||||
|
||||
const result = await this.createQuotation(quotation);
|
||||
console.log('📤 Sending to LexOffice:', JSON.stringify(quotation, null, 2));
|
||||
|
||||
// Schritt 1: Erstelle Quotation als Draft
|
||||
const result = await this.createQuotation(quotation, false);
|
||||
console.log('✅ Quotation created (draft):', result.id);
|
||||
|
||||
// Schritt 2: Finalisiere Quotation sofort
|
||||
try {
|
||||
console.log('🔄 Finalizing quotation...');
|
||||
await this.finalizeQuotation(result.id);
|
||||
console.log('✅ Quotation finalized to OPEN status');
|
||||
} catch (error: any) {
|
||||
console.error('⚠️ Quotation finalization failed:', error.message);
|
||||
console.log('ℹ️ Quotation bleibt im DRAFT status');
|
||||
}
|
||||
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async createConfirmationFromBooking(booking: any, contactId: string): Promise<string> {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
const isDST = (date: Date) => {
|
||||
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
|
||||
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
|
||||
return Math.max(jan, jul) !== date.getTimezoneOffset();
|
||||
};
|
||||
|
||||
const timezone = isDST(now) ? '+02:00' : '+01:00';
|
||||
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
|
||||
|
||||
const invoice: LexOfficeInvoice = {
|
||||
voucherDate: new Date().toISOString().split('T')[0],
|
||||
voucherDate,
|
||||
address: {
|
||||
contactId: contactId,
|
||||
name: booking.customerName,
|
||||
countryCode: 'DE',
|
||||
},
|
||||
lineItems: [
|
||||
@@ -276,7 +492,7 @@ export class LexOfficeService {
|
||||
taxConditions: {
|
||||
taxType: 'net',
|
||||
},
|
||||
title: `Auftragsbestätigung - ${booking.bookingNumber}`,
|
||||
title: `AB ${booking.bookingNumber}`,
|
||||
introduction: 'Vielen Dank für Ihre Bestellung! Hiermit bestätigen wir Ihren Auftrag:',
|
||||
remark: 'Wir freuen uns auf Ihre Veranstaltung!',
|
||||
shippingConditions: {
|
||||
|
||||
@@ -13,19 +13,46 @@ export interface CalendarEvent {
|
||||
export class NextcloudCalendarService {
|
||||
private client: any;
|
||||
private initialized: boolean = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
// Wenn bereits am Initialisieren, warte auf Abschluss
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Wenn bereits initialisiert, fertig
|
||||
if (this.initialized && this.client) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Neue Initialisierung starten
|
||||
this.initPromise = this._doInitialize();
|
||||
|
||||
try {
|
||||
await this.initPromise;
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _doInitialize() {
|
||||
const serverUrl = process.env.NEXTCLOUD_URL;
|
||||
const username = process.env.NEXTCLOUD_USERNAME;
|
||||
const password = process.env.NEXTCLOUD_PASSWORD;
|
||||
|
||||
console.log('🔍 Nextcloud credentials check:');
|
||||
console.log(' URL:', serverUrl);
|
||||
console.log(' Username:', username);
|
||||
console.log(' Password length:', password?.length, 'chars');
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
throw new Error('Nextcloud credentials not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('⏳ Creating Nextcloud CalDAV client...');
|
||||
|
||||
this.client = await createDAVClient({
|
||||
serverUrl: `${serverUrl}/remote.php/dav`,
|
||||
credentials: {
|
||||
@@ -37,10 +64,12 @@ export class NextcloudCalendarService {
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
console.log('✅ Nextcloud CalDAV client initialized');
|
||||
} catch (error) {
|
||||
console.log('✅ Nextcloud CalDAV client initialized successfully');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Failed to initialize Nextcloud CalDAV client:', error);
|
||||
throw error;
|
||||
this.initialized = false;
|
||||
this.client = null;
|
||||
throw new Error(`Nextcloud initialization failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,22 +149,33 @@ export class NextcloudCalendarService {
|
||||
throw new Error('No calendars found in Nextcloud');
|
||||
}
|
||||
|
||||
const calendar = calendars[0];
|
||||
// Suche nach "Buchungen" Kalender, sonst verwende ersten
|
||||
let calendar = calendars.find((cal: any) =>
|
||||
cal.displayName?.toLowerCase().includes('buchung')
|
||||
);
|
||||
|
||||
if (!calendar) {
|
||||
console.warn('⚠️ Kein "Buchungen"-Kalender gefunden, verwende:', calendars[0].displayName);
|
||||
calendar = calendars[0];
|
||||
} else {
|
||||
console.log('✅ Verwende Kalender:', calendar.displayName);
|
||||
}
|
||||
|
||||
const event: CalendarEvent = {
|
||||
uid: `savethemoment-booking-${booking.id}`,
|
||||
summary: `${booking.customerName} - ${booking.location?.name || 'Unbekannt'}`,
|
||||
description: `
|
||||
Buchung #${booking.id}
|
||||
Buchung #${booking.bookingNumber || booking.id}
|
||||
Kunde: ${booking.customerName}
|
||||
E-Mail: ${booking.customerEmail}
|
||||
Telefon: ${booking.customerPhone || 'N/A'}
|
||||
Event-Typ: ${booking.eventType}
|
||||
Event-Location: ${booking.eventLocation || booking.eventAddress}
|
||||
Status: ${booking.status}
|
||||
Fotobox: ${booking.photobox?.name || 'Keine Box'}
|
||||
Fotobox: ${booking.photobox?.model || 'Keine Box'}
|
||||
Standort: ${booking.location?.name || 'Unbekannt'}
|
||||
Preis: ${booking.calculatedPrice || 0}€
|
||||
`.trim(),
|
||||
location: booking.location?.address || '',
|
||||
location: `${booking.eventAddress || ''}, ${booking.eventZip || ''} ${booking.eventCity || ''}`.trim(),
|
||||
startDate: new Date(booking.eventDate),
|
||||
endDate: new Date(new Date(booking.eventDate).getTime() + 4 * 60 * 60 * 1000),
|
||||
status: booking.status,
|
||||
@@ -143,10 +183,12 @@ Standort: ${booking.location?.name || 'Unbekannt'}
|
||||
|
||||
try {
|
||||
await this.createEvent(calendar.url, event);
|
||||
console.log('✅ Event in Nextcloud erstellt:', event.summary);
|
||||
return event;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists') || error.response?.status === 412) {
|
||||
await this.updateEvent(calendar.url, event);
|
||||
console.log('✅ Event in Nextcloud aktualisiert:', event.summary);
|
||||
return event;
|
||||
}
|
||||
throw error;
|
||||
@@ -162,11 +204,20 @@ Standort: ${booking.location?.name || 'Unbekannt'}
|
||||
throw new Error('No calendars found in Nextcloud');
|
||||
}
|
||||
|
||||
const calendar = calendars[0];
|
||||
// Suche nach "Buchungen" Kalender, sonst verwende ersten
|
||||
let calendar = calendars.find((cal: any) =>
|
||||
cal.displayName?.toLowerCase().includes('buchung')
|
||||
);
|
||||
|
||||
if (!calendar) {
|
||||
calendar = calendars[0];
|
||||
}
|
||||
|
||||
const eventUid = `savethemoment-booking-${bookingId}`;
|
||||
|
||||
try {
|
||||
await this.deleteEvent(calendar.url, eventUid);
|
||||
console.log('✅ Event aus Nextcloud gelöscht:', eventUid);
|
||||
} catch (error) {
|
||||
console.error('Error removing booking from calendar:', error);
|
||||
}
|
||||
|
||||
108
lib/price-calculator.ts
Normal file
108
lib/price-calculator.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
interface PriceConfig {
|
||||
basePrice: number;
|
||||
kmFlatRate: number;
|
||||
kmFlatRateUpTo: number;
|
||||
pricePerKm: number;
|
||||
kmMultiplier: number;
|
||||
}
|
||||
|
||||
interface PriceBreakdown {
|
||||
basePrice: number;
|
||||
kmFlatRate: number;
|
||||
kmAdditionalNet: number;
|
||||
kmAdditionalGross: number;
|
||||
kmTotalGross: number;
|
||||
totalPrice: number;
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
export class PriceCalculator {
|
||||
private static readonly VAT_RATE = 1.19;
|
||||
|
||||
static calculateKmCharge(
|
||||
distance: number,
|
||||
config: PriceConfig
|
||||
): {
|
||||
kmTotalGross: number;
|
||||
kmFlatRate: number;
|
||||
kmAdditionalNet: number;
|
||||
kmAdditionalGross: number;
|
||||
} {
|
||||
const { kmFlatRate, kmFlatRateUpTo, pricePerKm, kmMultiplier } = config;
|
||||
|
||||
if (distance <= kmFlatRateUpTo) {
|
||||
return {
|
||||
kmTotalGross: kmFlatRate,
|
||||
kmFlatRate: kmFlatRate,
|
||||
kmAdditionalNet: 0,
|
||||
kmAdditionalGross: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const extraKm = distance - kmFlatRateUpTo;
|
||||
const kmAdditionalNet = extraKm * kmMultiplier * pricePerKm;
|
||||
const kmAdditionalGross = Math.round(kmAdditionalNet * this.VAT_RATE * 100) / 100;
|
||||
const kmTotalGross = Math.round((kmFlatRate + kmAdditionalGross) * 100) / 100;
|
||||
|
||||
return {
|
||||
kmTotalGross,
|
||||
kmFlatRate,
|
||||
kmAdditionalNet: Math.round(kmAdditionalNet * 100) / 100,
|
||||
kmAdditionalGross,
|
||||
};
|
||||
}
|
||||
|
||||
static calculateTotalPrice(
|
||||
basePrice: number,
|
||||
distance: number | null | undefined,
|
||||
config: PriceConfig
|
||||
): PriceBreakdown {
|
||||
if (!distance || distance <= 0) {
|
||||
return {
|
||||
basePrice,
|
||||
kmFlatRate: 0,
|
||||
kmAdditionalNet: 0,
|
||||
kmAdditionalGross: 0,
|
||||
kmTotalGross: 0,
|
||||
totalPrice: basePrice,
|
||||
};
|
||||
}
|
||||
|
||||
const kmCharge = this.calculateKmCharge(distance, config);
|
||||
const totalPrice = Math.round((basePrice + kmCharge.kmTotalGross) * 100) / 100;
|
||||
|
||||
return {
|
||||
basePrice,
|
||||
kmFlatRate: kmCharge.kmFlatRate,
|
||||
kmAdditionalNet: kmCharge.kmAdditionalNet,
|
||||
kmAdditionalGross: kmCharge.kmAdditionalGross,
|
||||
kmTotalGross: kmCharge.kmTotalGross,
|
||||
totalPrice,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
|
||||
static formatPriceBreakdown(breakdown: PriceBreakdown): string {
|
||||
const lines = [
|
||||
`Grundpreis Fotobox: ${breakdown.basePrice.toFixed(2)}€`,
|
||||
];
|
||||
|
||||
if (breakdown.distance && breakdown.distance > 0) {
|
||||
lines.push(`\nKilometerpauschale (${breakdown.distance.toFixed(1)}km):`);
|
||||
|
||||
if (breakdown.kmFlatRate > 0) {
|
||||
lines.push(` - Pauschale: ${breakdown.kmFlatRate.toFixed(2)}€`);
|
||||
}
|
||||
|
||||
if (breakdown.kmAdditionalGross > 0) {
|
||||
lines.push(` - Zusätzlich: ${breakdown.kmAdditionalGross.toFixed(2)}€`);
|
||||
}
|
||||
|
||||
lines.push(` = Gesamt KM: ${breakdown.kmTotalGross.toFixed(2)}€`);
|
||||
}
|
||||
|
||||
lines.push(`\nGesamtpreis: ${breakdown.totalPrice.toFixed(2)}€`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user