/** * Shared Lexware/lexoffice API client bits used by more than one route * (server/api/invoices/lexoffice-upload.post.ts, lexoffice-categories.get.ts). * Route-specific logic (contact lookup/creation, the debug-log collector, the plain * /v1/files upload) stays local to lexoffice-upload.post.ts — only the credential, * the global throttle and the voucher-booking calls live here. */ export const LEXOFFICE_API_URL = "https://api.lexware.io" export const LEXOFFICE_API_KEY = "EVrdrnU7rEhJp93WAJ8MhwPAP.tYOwKTj27Ep2D8fTelLnIJ" export const lexAuthHeaders = (json: boolean): Record => ({ 'Authorization': `Bearer ${LEXOFFICE_API_KEY}`, 'Accept': 'application/json', ...(json ? { 'Content-Type': 'application/json' } : {}), }) const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) /** * Global Lexware throttle — the ONLY way this process may talk to Lexware. * * Lexware allows 2 requests/second per API client (token bucket, all endpoints * combined) and answers 429 when exceeded. `lexFetch` therefore * (a) reserves call starts at least LEX_MIN_INTERVAL_MS apart (process-wide, so * concurrent uploads from several requests still share one budget), * (b) keeps at most LEX_MAX_INFLIGHT calls in flight, and * (c) retries 429/503 with backoff (honouring Retry-After when present). * It is process-local: with PM2 running N instances the effective rate is N×; the * 429 retry covers that case. */ const LEX_MIN_INTERVAL_MS = 520 const LEX_MAX_INFLIGHT = 2 const LEX_MAX_RETRIES = 3 let nextStartAt = 0 let inflight = 0 const slotWaiters: Array<() => void> = [] const acquireSlot = async (): Promise => { while (inflight >= LEX_MAX_INFLIGHT) { await new Promise(resolve => slotWaiters.push(resolve)) } inflight++ // Reserve the next start timestamp BEFORE sleeping so two concurrent callers // can never claim the same slot (each reservation advances the pacer). const startAt = Math.max(Date.now(), nextStartAt) nextStartAt = startAt + LEX_MIN_INTERVAL_MS const wait = startAt - Date.now() if (wait > 0) await sleep(wait) } const releaseSlot = (): void => { inflight-- slotWaiters.shift()?.() } /** Throttled, 429-aware `fetch` against the Lexware API. `path` starts with `/v1/...`. */ export const lexFetch = async (path: string, init: RequestInit): Promise => { for (let attempt = 0; ; attempt++) { await acquireSlot() let response: Response try { response = await fetch(`${LEXOFFICE_API_URL}${path}`, init) } finally { releaseSlot() } const retryable = response.status === 429 || response.status === 503 if (!retryable || attempt >= LEX_MAX_RETRIES) return response const retryAfter = Number(response.headers.get('Retry-After')) || 0 const delay = retryAfter > 0 ? retryAfter * 1000 : 1500 * (attempt + 1) console.warn(`[lexoffice] HTTP ${response.status} on ${path} — retrying in ${delay} ms (attempt ${attempt + 1}/${LEX_MAX_RETRIES})`) await sleep(delay) } } /** * GET /v1/posting-categories — the list of booking/income-expense categories known to * this Lexoffice account (what the UI calls "Kategorie" when confirming a voucher). */ export const getPostingCategories = async (): Promise => { const response = await lexFetch('/v1/posting-categories', { method: 'GET', headers: lexAuthHeaders(false), }) if (!response.ok) { throw new Error(`Lexoffice posting-categories error (${response.status}): ${await response.text()}`) } const data = await response.json() return Array.isArray(data) ? data : [] } /** * POST /v1/vouchers — create a correctly-typed, already-categorized voucher. * * Deliberately NOT the same path as the plain `POST /v1/files?type=voucher` upload: * that generic Files endpoint auto-creates its OWN draft voucher as a side effect, * defaulted to `type: "purchaseinvoice"` — and a voucher's `type` can never be changed * afterward (Lexoffice rejects the PUT with `vouchertype_changed`), permanently * mis-booking a sales invoice as a purchase. Creating the voucher ourselves first * (no `files` field yet) with the correct `type` avoids that trap; attach the PDF * afterward via `uploadFileToVoucher`. */ export const createVoucher = async (voucher: { type: 'salesinvoice' | 'salescreditnote' | 'purchaseinvoice' | 'purchasecreditnote' voucherStatus: 'open' | 'paid' | 'paidoff' | 'voided' | 'transferred' | 'sepadebit' voucherNumber?: string voucherDate: string contactId: string totalGrossAmount: number totalTaxAmount: number taxType: 'gross' | 'net' remark?: string voucherItems: Array<{ amount: number; taxAmount: number; taxRatePercent: number; categoryId: string }> }): Promise<{ id: string }> => { const response = await lexFetch('/v1/vouchers', { method: 'POST', headers: lexAuthHeaders(true), body: JSON.stringify(voucher), }) if (!response.ok) { throw new Error(`Lexoffice voucher creation failed (${response.status}): ${await response.text()}`) } return response.json() } /** POST /v1/vouchers/{voucherId}/files — attach a file directly to an existing voucher * (the voucher-scoped upload; see the note on createVoucher for why this — not the * generic Files endpoint — is used for the category/tax-rate-aware upload path). * Returns 202 immediately; Lexware's OCR runs asynchronously afterwards — nothing to * wait for on our side. */ export const uploadFileToVoucher = async (voucherId: string, fileBuffer: Buffer, fileName: string): Promise => { const blob = new Blob([fileBuffer], { type: 'application/pdf' }) const formData = new FormData() formData.append('file', blob, fileName) const response = await lexFetch(`/v1/vouchers/${voucherId}/files`, { method: 'POST', headers: lexAuthHeaders(false), body: formData, }) if (!response.ok) { throw new Error(`Lexoffice voucher file upload failed (${response.status}): ${await response.text()}`) } return response.json() }