Files
Atlas/lib/lexoffice.ts
Julia Wehden a2c95c70e7 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
2026-03-19 16:21:55 +01:00

517 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();