- 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
517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
interface LexOfficeContact {
|
||
id?: string;
|
||
organizationId: string;
|
||
version: number;
|
||
roles: {
|
||
customer?: {
|
||
number?: number;
|
||
};
|
||
};
|
||
company?: {
|
||
name: string;
|
||
taxNumber?: string;
|
||
vatRegistrationId?: string;
|
||
allowTaxFreeInvoices?: boolean;
|
||
contactPersons?: Array<{
|
||
salutation?: string;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
primary?: boolean;
|
||
emailAddress?: string;
|
||
phoneNumber?: string;
|
||
}>;
|
||
};
|
||
person?: {
|
||
salutation?: string;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
};
|
||
addresses?: {
|
||
billing?: Array<{
|
||
supplement?: string;
|
||
street?: string;
|
||
zip?: string;
|
||
city?: string;
|
||
countryCode?: string;
|
||
}>;
|
||
};
|
||
emailAddresses?: {
|
||
business?: Array<string>;
|
||
office?: Array<string>;
|
||
private?: Array<string>;
|
||
other?: Array<string>;
|
||
};
|
||
phoneNumbers?: {
|
||
business?: Array<string>;
|
||
office?: Array<string>;
|
||
mobile?: Array<string>;
|
||
private?: Array<string>;
|
||
fax?: Array<string>;
|
||
other?: Array<string>;
|
||
};
|
||
note?: string;
|
||
}
|
||
|
||
interface LexOfficeQuotation {
|
||
id?: string;
|
||
organizationId?: string;
|
||
createdDate?: string;
|
||
updatedDate?: string;
|
||
voucherNumber?: string;
|
||
voucherDate: string;
|
||
address: {
|
||
contactId?: string;
|
||
name?: string;
|
||
supplement?: string;
|
||
street?: string;
|
||
city?: string;
|
||
zip?: string;
|
||
countryCode: string;
|
||
};
|
||
lineItems: Array<{
|
||
type: 'custom' | 'text';
|
||
name?: string;
|
||
description?: string;
|
||
quantity?: number;
|
||
unitName?: string;
|
||
unitPrice?: {
|
||
currency: string;
|
||
netAmount: number;
|
||
taxRatePercentage: number;
|
||
};
|
||
}>;
|
||
totalPrice?: {
|
||
currency: string;
|
||
};
|
||
taxConditions?: {
|
||
taxType: 'net' | 'gross' | 'vatfree';
|
||
};
|
||
title?: string;
|
||
introduction?: string;
|
||
remark?: string;
|
||
}
|
||
|
||
interface LexOfficeInvoice extends Omit<LexOfficeQuotation, 'id'> {
|
||
voucherStatus?: string;
|
||
shippingConditions?: {
|
||
shippingDate?: string;
|
||
shippingType?: string;
|
||
};
|
||
paymentConditions?: {
|
||
paymentTermLabel?: string;
|
||
paymentTermDuration?: number;
|
||
};
|
||
}
|
||
|
||
export class LexOfficeService {
|
||
private apiKey: string;
|
||
private baseUrl = 'https://api.lexoffice.io/v1';
|
||
|
||
constructor() {
|
||
this.apiKey = process.env.LEXOFFICE_API_KEY || '';
|
||
if (!this.apiKey) {
|
||
console.warn('LexOffice API Key nicht konfiguriert');
|
||
}
|
||
}
|
||
|
||
private async request(method: string, endpoint: string, data?: any) {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||
method,
|
||
headers: {
|
||
'Authorization': `Bearer ${this.apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
},
|
||
body: data ? JSON.stringify(data) : undefined,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`LexOffice API Error: ${response.status} - ${errorText}`);
|
||
}
|
||
|
||
return await response.json();
|
||
} catch (error) {
|
||
console.error('LexOffice API Request failed:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async createContact(contact: Partial<LexOfficeContact>): Promise<{ id: string; resourceUri: string }> {
|
||
return this.request('POST', '/contacts', contact);
|
||
}
|
||
|
||
async getContact(contactId: string): Promise<LexOfficeContact> {
|
||
return this.request('GET', `/contacts/${contactId}`);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
async getInvoice(invoiceId: string): Promise<LexOfficeInvoice> {
|
||
return this.request('GET', `/invoices/${invoiceId}`);
|
||
}
|
||
|
||
async finalizeInvoice(invoiceId: string): Promise<{ id: string; resourceUri: string }> {
|
||
return this.request('PUT', `/invoices/${invoiceId}/pursue`, {
|
||
precedingSalesVoucherId: null,
|
||
preserveVoucherNumber: false,
|
||
});
|
||
}
|
||
|
||
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: {
|
||
customer: {},
|
||
},
|
||
addresses: {
|
||
billing: [{
|
||
street: booking.customerAddress,
|
||
zip: booking.customerZip,
|
||
city: booking.customerCity,
|
||
countryCode: 'DE',
|
||
}],
|
||
},
|
||
emailAddresses: {
|
||
business: [booking.customerEmail],
|
||
},
|
||
phoneNumbers: {
|
||
business: [booking.customerPhone],
|
||
},
|
||
};
|
||
|
||
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: firstName,
|
||
lastName: lastName,
|
||
primary: true,
|
||
emailAddress: booking.customerEmail,
|
||
phoneNumber: booking.customerPhone,
|
||
}],
|
||
};
|
||
} else {
|
||
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: lastName,
|
||
};
|
||
}
|
||
|
||
const result = await this.createContact(contact);
|
||
return result.id;
|
||
}
|
||
|
||
async createQuotationFromBooking(booking: any, contactId: string): Promise<string> {
|
||
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.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: `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: 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',
|
||
},
|
||
introduction: 'Vielen Dank für Ihre Anfrage! Gerne erstellen wir Ihnen folgendes Angebot:',
|
||
remark: 'Wir freuen uns auf Ihre Bestellung!',
|
||
};
|
||
|
||
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,
|
||
address: {
|
||
contactId: contactId,
|
||
name: booking.customerName,
|
||
countryCode: 'DE',
|
||
},
|
||
lineItems: [
|
||
{
|
||
type: 'custom',
|
||
name: 'Fotobox-Vermietung',
|
||
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}\nAufbau: ${new Date(booking.setupTimeStart).toLocaleString('de-DE')}\nOrt: ${booking.eventAddress}, ${booking.eventZip} ${booking.eventCity}`,
|
||
quantity: 1,
|
||
unitName: 'Stück',
|
||
unitPrice: {
|
||
currency: 'EUR',
|
||
netAmount: booking.calculatedPrice || 0,
|
||
taxRatePercentage: 19,
|
||
},
|
||
},
|
||
],
|
||
totalPrice: {
|
||
currency: 'EUR',
|
||
},
|
||
taxConditions: {
|
||
taxType: 'net',
|
||
},
|
||
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: {
|
||
shippingDate: new Date(booking.eventDate).toISOString().split('T')[0],
|
||
shippingType: 'service',
|
||
},
|
||
paymentConditions: {
|
||
paymentTermLabel: 'Zahlung bei Lieferung',
|
||
paymentTermDuration: 0,
|
||
},
|
||
};
|
||
|
||
const result = await this.createInvoice(invoice);
|
||
|
||
await this.finalizeInvoice(result.id);
|
||
|
||
return result.id;
|
||
}
|
||
}
|
||
|
||
export const lexofficeService = new LexOfficeService();
|