buchpilot-mcp 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +198 -0
- package/dist/backends/lexoffice.d.ts +23 -0
- package/dist/backends/lexoffice.js +344 -0
- package/dist/backends/types.d.ts +120 -0
- package/dist/backends/types.js +2 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +44 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53 -0
- package/dist/tools/contacts.d.ts +75 -0
- package/dist/tools/contacts.js +63 -0
- package/dist/tools/invoices.d.ts +116 -0
- package/dist/tools/invoices.js +92 -0
- package/dist/tools/quotations.d.ts +2 -0
- package/dist/tools/quotations.js +41 -0
- package/dist/tools/sync.d.ts +2 -0
- package/dist/tools/sync.js +37 -0
- package/dist/tools/vouchers.d.ts +2 -0
- package/dist/tools/vouchers.js +43 -0
- package/docs/claude-desktop-setup.md +209 -0
- package/docs/tool-reference.md +497 -0
- package/package.json +24 -0
- package/src/backends/lexoffice.ts +392 -0
- package/src/backends/types.ts +137 -0
- package/src/config.ts +62 -0
- package/src/errors.ts +34 -0
- package/src/index.ts +73 -0
- package/src/tools/contacts.ts +92 -0
- package/src/tools/invoices.ts +128 -0
- package/src/tools/quotations.ts +58 -0
- package/src/tools/sync.ts +50 -0
- package/src/tools/vouchers.ts +65 -0
- package/tsconfig.json +15 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
|
+
Versioning: [Semantic Versioning](https://semver.org/)
|
|
6
|
+
|
|
7
|
+
## [0.1.1] - 2026-03-19 11:30
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Email-Mapping fuer Person-Kontakte: Email wird jetzt korrekt in Lexoffice gesetzt und gelesen
|
|
11
|
+
- Rate-Limiter fuer PDF-Download: `getInvoicePdf` nutzt jetzt Rate-Limiting + Fehlerbehandlung
|
|
12
|
+
- `contactId`-Filter in `listInvoices` wird jetzt korrekt an Lexoffice API uebergeben
|
|
13
|
+
- PDF-Response nutzt Standard-Text-Format statt fragwuerdigem `resource`-Typ
|
|
14
|
+
- CHANGELOG: "Sync" korrigiert zu "Ueberfaellige Rechnungen"
|
|
15
|
+
|
|
16
|
+
## [0.1.0] - 2026-03-18 10:00
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- MCP Server fuer DACH-Buchhaltung via Model Context Protocol (stdio Transport)
|
|
20
|
+
- Backend-Architektur mit austauschbaren Backends (aktuell: Lexoffice)
|
|
21
|
+
- Lexoffice-Backend: vollstaendige API-Integration mit Lazy-Initialisierung
|
|
22
|
+
- Tools: Kontakte (create, get, list, update)
|
|
23
|
+
- Tools: Rechnungen (create, get, list, get_pdf, update)
|
|
24
|
+
- Tools: Belege/Vouchers (create, get, list)
|
|
25
|
+
- Tools: Angebote/Quotations (create, get)
|
|
26
|
+
- Tools: Ueberfaellige Rechnungen (get_overdue_invoices)
|
|
27
|
+
- Konfiguration via Umgebungsvariablen/Config-Datei
|
|
28
|
+
- Strukturierte Fehlerbehandlung mit Error Codes
|
|
29
|
+
- Zod-basierte Input-Validierung fuer alle Tools
|
|
30
|
+
- TypeScript mit ES Modules
|
package/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# BuchPilot MCP Server
|
|
2
|
+
|
|
3
|
+
> MCP Server fuer DACH-Buchhaltung — Kontakte, Rechnungen, Belege und Angebote direkt aus Claude, Cursor oder jedem MCP-kompatiblen Client verwalten.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/buchpilot-mcp)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
## Was ist das?
|
|
9
|
+
|
|
10
|
+
BuchPilot MCP ist ein [Model Context Protocol](https://modelcontextprotocol.io/) Server, der dein Buchhaltungssystem (aktuell Lexoffice) mit KI-Assistenten verbindet. Du kannst per natuerlicher Sprache Rechnungen erstellen, Kontakte verwalten und ueberfaellige Zahlungen pruefen — direkt in Claude Desktop, Cursor oder jedem anderen MCP-Client.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **15 Tools** fuer vollstaendige Buchhaltungs-Automatisierung
|
|
15
|
+
- **Kontakte** erstellen, abrufen, auflisten, aktualisieren
|
|
16
|
+
- **Rechnungen** erstellen, abrufen, auflisten, aktualisieren, PDF herunterladen
|
|
17
|
+
- **Belege** erstellen, abrufen, auflisten (Eingangsrechnungen, Gutschriften)
|
|
18
|
+
- **Angebote** erstellen, abrufen
|
|
19
|
+
- **Ueberfaellige Rechnungen** mit Betraegen und Tagen ueberfaellig
|
|
20
|
+
- **Backend-Architektur** — aktuell Lexoffice, erweiterbar fuer sevDesk, Billomat etc.
|
|
21
|
+
- **Plugin-faehig** — kann E-Invoice MCP Tools integrieren (`einvoice-mcp`)
|
|
22
|
+
- Keine Datenbank, kein State — reiner API-Proxy
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
### Voraussetzungen
|
|
27
|
+
|
|
28
|
+
- Node.js >= 18
|
|
29
|
+
- Ein [Lexoffice](https://www.lexoffice.de/)-Account mit API-Key
|
|
30
|
+
|
|
31
|
+
### npm (global)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g buchpilot-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Von Source
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/makririch/buchpilot-mcp.git
|
|
41
|
+
cd buchpilot-mcp
|
|
42
|
+
npm install
|
|
43
|
+
npm run build
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Konfiguration
|
|
47
|
+
|
|
48
|
+
Erstelle eine Konfigurationsdatei `.buchpilot.json` an einem der folgenden Orte:
|
|
49
|
+
|
|
50
|
+
1. Pfad aus Umgebungsvariable `BUCHPILOT_CONFIG`
|
|
51
|
+
2. `~/.buchpilot.json` (Home-Verzeichnis)
|
|
52
|
+
3. `./.buchpilot.json` (aktuelles Verzeichnis)
|
|
53
|
+
|
|
54
|
+
### Inhalt der .buchpilot.json
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"backends": {
|
|
59
|
+
"lexoffice": {
|
|
60
|
+
"api_key": "DEIN_LEXOFFICE_API_KEY"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"default_backend": "lexoffice"
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Lexoffice API-Key erstellen
|
|
68
|
+
|
|
69
|
+
1. Gehe zu [Lexoffice Public API](https://app.lexoffice.de/addons/public-api)
|
|
70
|
+
2. Klicke auf **API-Key erstellen**
|
|
71
|
+
3. Kopiere den Key und fuege ihn in `.buchpilot.json` ein
|
|
72
|
+
|
|
73
|
+
> **Sicherheitshinweis:** Speichere den API-Key nie in Git. Fuege `.buchpilot.json` zu deiner `.gitignore` hinzu.
|
|
74
|
+
|
|
75
|
+
## Nutzung
|
|
76
|
+
|
|
77
|
+
### Server starten (standalone)
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Nach npm install -g:
|
|
81
|
+
buchpilot-mcp
|
|
82
|
+
|
|
83
|
+
# Oder von Source:
|
|
84
|
+
npm start
|
|
85
|
+
|
|
86
|
+
# Entwicklung mit Auto-Reload:
|
|
87
|
+
npm run dev
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Der Server laeuft ueber stdio und wartet auf MCP-Nachrichten.
|
|
91
|
+
|
|
92
|
+
### In Claude Desktop verwenden
|
|
93
|
+
|
|
94
|
+
Siehe [Claude Desktop Setup Guide](docs/claude-desktop-setup.md) fuer eine detaillierte Anleitung.
|
|
95
|
+
|
|
96
|
+
Kurzversion — fuege in `claude_desktop_config.json` hinzu:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"buchpilot": {
|
|
102
|
+
"command": "npx",
|
|
103
|
+
"args": ["-y", "buchpilot-mcp"],
|
|
104
|
+
"env": {
|
|
105
|
+
"BUCHPILOT_CONFIG": "/Users/DEIN_NAME/.buchpilot.json"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Beispiele (natuerliche Sprache in Claude)
|
|
113
|
+
|
|
114
|
+
Nachdem du den Server konfiguriert hast, kannst du Claude z.B. fragen:
|
|
115
|
+
|
|
116
|
+
- "Erstelle einen Kontakt fuer die Firma Beispiel GmbH mit der E-Mail info@beispiel.de"
|
|
117
|
+
- "Zeige mir alle offenen Rechnungen"
|
|
118
|
+
- "Erstelle eine Rechnung an Kontakt XYZ: 10 Stunden Beratung zu je 150 EUR"
|
|
119
|
+
- "Welche Rechnungen sind ueberfaellig?"
|
|
120
|
+
- "Lade die PDF von Rechnung ABC herunter"
|
|
121
|
+
- "Erstelle ein Angebot fuer 5 Lizenzen a 49 EUR/Monat"
|
|
122
|
+
|
|
123
|
+
## Tool-Referenz
|
|
124
|
+
|
|
125
|
+
Eine vollstaendige Referenz aller 15 Tools mit Parametern, Beispiel-Inputs und Beispiel-Outputs findest du in [docs/tool-reference.md](docs/tool-reference.md).
|
|
126
|
+
|
|
127
|
+
### Kurzuebersicht
|
|
128
|
+
|
|
129
|
+
| Tool | Beschreibung |
|
|
130
|
+
|------|-------------|
|
|
131
|
+
| `create_contact` | Neuen Kontakt anlegen (Person oder Firma) |
|
|
132
|
+
| `get_contact` | Kontakt per ID abrufen |
|
|
133
|
+
| `list_contacts` | Kontakte auflisten mit Filtern |
|
|
134
|
+
| `update_contact` | Kontakt aktualisieren |
|
|
135
|
+
| `create_invoice` | Neue Rechnung mit Positionen erstellen |
|
|
136
|
+
| `get_invoice` | Rechnung per ID abrufen |
|
|
137
|
+
| `list_invoices` | Rechnungen auflisten (nach Status filterbar) |
|
|
138
|
+
| `get_invoice_pdf` | Rechnung als PDF herunterladen (Base64) |
|
|
139
|
+
| `update_invoice` | Entwurfs-Rechnung aktualisieren |
|
|
140
|
+
| `create_voucher` | Neuen Beleg anlegen |
|
|
141
|
+
| `get_voucher` | Beleg per ID abrufen |
|
|
142
|
+
| `list_vouchers` | Belege auflisten |
|
|
143
|
+
| `create_quotation` | Neues Angebot mit Positionen erstellen |
|
|
144
|
+
| `get_quotation` | Angebot per ID abrufen |
|
|
145
|
+
| `get_overdue_invoices` | Ueberfaellige Rechnungen mit Analyse |
|
|
146
|
+
|
|
147
|
+
## E-Invoice Integration
|
|
148
|
+
|
|
149
|
+
BuchPilot MCP kann optional das [E-Invoice MCP](https://www.npmjs.com/package/einvoice-mcp) Paket integrieren, um XRechnung und ZUGFeRD direkt aus dem Buchhaltungssystem zu erzeugen:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// In deinem eigenen MCP-Server:
|
|
153
|
+
import { registerEInvoiceTools } from "einvoice-mcp";
|
|
154
|
+
registerEInvoiceTools(server);
|
|
155
|
+
// Ergebnis: 15 + 4 = 19 Tools
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## FAQ / Troubleshooting
|
|
159
|
+
|
|
160
|
+
### "No .buchpilot.json found"
|
|
161
|
+
|
|
162
|
+
Der Server findet keine Konfigurationsdatei. Erstelle eine `.buchpilot.json` in deinem Home-Verzeichnis:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
echo '{"backends":{"lexoffice":{"api_key":"DEIN_KEY"}},"default_backend":"lexoffice"}' > ~/.buchpilot.json
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### "401 Unauthorized" bei Lexoffice
|
|
169
|
+
|
|
170
|
+
- Ist der API-Key korrekt in `.dachflow.json`?
|
|
171
|
+
- Ist der Key noch aktiv? Pruefe unter [Lexoffice Public API](https://app.lexoffice.de/addons/public-api)
|
|
172
|
+
- API-Keys koennen ablaufen — erstelle ggf. einen neuen
|
|
173
|
+
|
|
174
|
+
### "429 Too Many Requests"
|
|
175
|
+
|
|
176
|
+
Lexoffice erlaubt max. 2 Requests pro Sekunde. Wenn du viele Operationen hintereinander ausfuehrst, warte kurz zwischen den Anfragen.
|
|
177
|
+
|
|
178
|
+
### Server startet, aber Claude erkennt die Tools nicht
|
|
179
|
+
|
|
180
|
+
- Pruefe ob die `claude_desktop_config.json` korrekt ist
|
|
181
|
+
- Starte Claude Desktop neu nach Konfigurationsaenderungen
|
|
182
|
+
- Pruefe die Logs: `~/Library/Logs/Claude/mcp.log` (macOS)
|
|
183
|
+
|
|
184
|
+
### Rechnung kann nicht aktualisiert werden
|
|
185
|
+
|
|
186
|
+
Nur Rechnungen im Status **draft** (Entwurf) koennen aktualisiert werden. Finalisierte Rechnungen sind unveraenderlich.
|
|
187
|
+
|
|
188
|
+
### Backend "sevDesk" nicht verfuegbar
|
|
189
|
+
|
|
190
|
+
Aktuell wird nur **Lexoffice** als Backend unterstuetzt. sevDesk-Unterstuetzung ist geplant. Du kannst die n8n-Nodes (`n8n-nodes-buchpilot`) fuer sevDesk verwenden.
|
|
191
|
+
|
|
192
|
+
## Lizenz
|
|
193
|
+
|
|
194
|
+
MIT — Frei nutzbar, auch kommerziell.
|
|
195
|
+
|
|
196
|
+
## Autor
|
|
197
|
+
|
|
198
|
+
**MaKri** — [GitHub](https://github.com/makririch/buchpilot-mcp)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IAccountingBackend, Contact, ContactInput, ContactFilter, Invoice, InvoiceInput, InvoiceFilter, Voucher, VoucherInput, VoucherFilter, Quotation, QuotationInput } from "./types.js";
|
|
2
|
+
export declare class LexofficeBackend implements IAccountingBackend {
|
|
3
|
+
name: string;
|
|
4
|
+
private apiKey;
|
|
5
|
+
constructor(apiKey: string);
|
|
6
|
+
createContact(input: ContactInput): Promise<Contact>;
|
|
7
|
+
getContact(id: string): Promise<Contact>;
|
|
8
|
+
listContacts(filter?: ContactFilter): Promise<Contact[]>;
|
|
9
|
+
updateContact(id: string, input: Partial<ContactInput>): Promise<Contact>;
|
|
10
|
+
createInvoice(input: InvoiceInput): Promise<Invoice>;
|
|
11
|
+
getInvoice(id: string): Promise<Invoice>;
|
|
12
|
+
listInvoices(filter?: InvoiceFilter): Promise<Invoice[]>;
|
|
13
|
+
getInvoicePdf(id: string): Promise<{
|
|
14
|
+
data: Buffer;
|
|
15
|
+
filename: string;
|
|
16
|
+
}>;
|
|
17
|
+
updateInvoice(id: string, input: Partial<InvoiceInput>): Promise<Invoice>;
|
|
18
|
+
createVoucher(input: VoucherInput): Promise<Voucher>;
|
|
19
|
+
getVoucher(id: string): Promise<Voucher>;
|
|
20
|
+
listVouchers(filter?: VoucherFilter): Promise<Voucher[]>;
|
|
21
|
+
createQuotation(input: QuotationInput): Promise<Quotation>;
|
|
22
|
+
getQuotation(id: string): Promise<Quotation>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { ErrorCode, createError } from "../errors.js";
|
|
2
|
+
const BASE_URL = "https://api.lexoffice.io/v1";
|
|
3
|
+
// Rate limiter: Lexoffice allows max 2 requests/second
|
|
4
|
+
let lastRequestTime = 0;
|
|
5
|
+
async function rateLimitSleep() {
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
const elapsed = now - lastRequestTime;
|
|
8
|
+
if (elapsed < 500) {
|
|
9
|
+
await new Promise((resolve) => setTimeout(resolve, 500 - elapsed));
|
|
10
|
+
}
|
|
11
|
+
lastRequestTime = Date.now();
|
|
12
|
+
}
|
|
13
|
+
async function lexofficeRequest(apiKey, method, endpoint, body, qs) {
|
|
14
|
+
await rateLimitSleep();
|
|
15
|
+
const url = new URL(`${BASE_URL}${endpoint}`);
|
|
16
|
+
if (qs) {
|
|
17
|
+
for (const [key, value] of Object.entries(qs)) {
|
|
18
|
+
url.searchParams.set(key, value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const headers = {
|
|
22
|
+
Authorization: `Bearer ${apiKey}`,
|
|
23
|
+
Accept: "application/json",
|
|
24
|
+
};
|
|
25
|
+
const options = { method, headers };
|
|
26
|
+
if (body && method !== "GET") {
|
|
27
|
+
headers["Content-Type"] = "application/json";
|
|
28
|
+
options.body = JSON.stringify(body);
|
|
29
|
+
}
|
|
30
|
+
const response = await fetch(url.toString(), options);
|
|
31
|
+
if (response.status === 401) {
|
|
32
|
+
throw createError(ErrorCode.AUTH_FAILED, "Lexoffice API key is invalid or expired");
|
|
33
|
+
}
|
|
34
|
+
if (response.status === 429) {
|
|
35
|
+
throw createError(ErrorCode.RATE_LIMITED, "Lexoffice API rate limit exceeded (max 2 req/s)");
|
|
36
|
+
}
|
|
37
|
+
if (response.status === 404) {
|
|
38
|
+
throw createError(ErrorCode.NOT_FOUND, `Resource not found: ${endpoint}`);
|
|
39
|
+
}
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const errorText = await response.text().catch(() => "Unknown error");
|
|
42
|
+
throw createError(ErrorCode.BACKEND_ERROR, `Lexoffice API error ${response.status}: ${errorText}`);
|
|
43
|
+
}
|
|
44
|
+
if (response.status === 204)
|
|
45
|
+
return {};
|
|
46
|
+
const contentType = response.headers.get("content-type") || "";
|
|
47
|
+
if (contentType.includes("application/json")) {
|
|
48
|
+
return response.json();
|
|
49
|
+
}
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
// === Mapping Helpers ===
|
|
53
|
+
function toLexofficeContact(input) {
|
|
54
|
+
const roles = {};
|
|
55
|
+
const role = input.role || "customer";
|
|
56
|
+
if (role === "customer" || role === "both")
|
|
57
|
+
roles.customer = {};
|
|
58
|
+
if (role === "vendor" || role === "both")
|
|
59
|
+
roles.vendor = {};
|
|
60
|
+
const body = { version: 0, roles };
|
|
61
|
+
if (input.type === "company") {
|
|
62
|
+
body.company = {
|
|
63
|
+
name: input.name,
|
|
64
|
+
...(input.email ? { contactPersons: [{ emailAddress: input.email }] } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
body.person = {
|
|
69
|
+
lastName: input.name,
|
|
70
|
+
...(input.firstName ? { firstName: input.firstName } : {}),
|
|
71
|
+
};
|
|
72
|
+
if (input.email) {
|
|
73
|
+
body.emailAddresses = { business: [input.email] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return body;
|
|
77
|
+
}
|
|
78
|
+
function fromLexofficeContact(raw) {
|
|
79
|
+
const isCompany = !!raw.company;
|
|
80
|
+
return {
|
|
81
|
+
id: raw.id,
|
|
82
|
+
type: isCompany ? "company" : "person",
|
|
83
|
+
name: isCompany ? raw.company?.name : raw.person?.lastName,
|
|
84
|
+
firstName: raw.person?.firstName,
|
|
85
|
+
email: isCompany
|
|
86
|
+
? raw.company?.contactPersons?.[0]?.emailAddress
|
|
87
|
+
: raw.emailAddresses?.business?.[0] || raw.emailAddresses?.private?.[0],
|
|
88
|
+
role: raw.roles?.customer && raw.roles?.vendor
|
|
89
|
+
? "both"
|
|
90
|
+
: raw.roles?.vendor ? "vendor" : "customer",
|
|
91
|
+
backend: "lexoffice",
|
|
92
|
+
raw,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function toLexofficeInvoice(input) {
|
|
96
|
+
return {
|
|
97
|
+
voucherDate: input.date,
|
|
98
|
+
address: { contactId: input.contactId },
|
|
99
|
+
lineItems: input.lineItems.map((item) => ({
|
|
100
|
+
type: "custom",
|
|
101
|
+
name: item.name,
|
|
102
|
+
quantity: item.quantity,
|
|
103
|
+
unitName: "Stueck",
|
|
104
|
+
unitPrice: {
|
|
105
|
+
currency: input.currency || "EUR",
|
|
106
|
+
netAmount: item.unitPrice,
|
|
107
|
+
taxRatePercentage: item.taxRate,
|
|
108
|
+
},
|
|
109
|
+
})),
|
|
110
|
+
totalPrice: { currency: input.currency || "EUR" },
|
|
111
|
+
taxConditions: { taxType: "net" },
|
|
112
|
+
...(input.title && { title: input.title }),
|
|
113
|
+
...(input.introduction && { introduction: input.introduction }),
|
|
114
|
+
...(input.remark && { remark: input.remark }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function fromLexofficeInvoice(raw) {
|
|
118
|
+
const lineItems = (raw.lineItems || [])
|
|
119
|
+
.filter((li) => li.type === "custom")
|
|
120
|
+
.map((li) => ({
|
|
121
|
+
name: li.name,
|
|
122
|
+
quantity: li.quantity,
|
|
123
|
+
unitPrice: li.unitPrice?.netAmount || 0,
|
|
124
|
+
taxRate: li.unitPrice?.taxRatePercentage || 0,
|
|
125
|
+
}));
|
|
126
|
+
return {
|
|
127
|
+
id: raw.id,
|
|
128
|
+
number: raw.voucherNumber || "",
|
|
129
|
+
contactId: raw.address?.contactId || "",
|
|
130
|
+
date: raw.voucherDate || "",
|
|
131
|
+
dueDate: raw.dueDate || raw.voucherDate || "",
|
|
132
|
+
status: raw.voucherStatus === "draft" ? "draft"
|
|
133
|
+
: raw.voucherStatus === "open" ? "open"
|
|
134
|
+
: raw.voucherStatus === "paid" ? "paid"
|
|
135
|
+
: raw.voucherStatus === "overdue" ? "overdue"
|
|
136
|
+
: "open",
|
|
137
|
+
totalNet: raw.totalPrice?.totalNetAmount || 0,
|
|
138
|
+
totalGross: raw.totalPrice?.totalGrossAmount || 0,
|
|
139
|
+
currency: raw.totalPrice?.currency || "EUR",
|
|
140
|
+
lineItems,
|
|
141
|
+
backend: "lexoffice",
|
|
142
|
+
raw,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function fromLexofficeVoucher(raw) {
|
|
146
|
+
return {
|
|
147
|
+
id: raw.id,
|
|
148
|
+
type: raw.voucherType || "",
|
|
149
|
+
date: raw.voucherDate || "",
|
|
150
|
+
totalGross: raw.totalGrossAmount || 0,
|
|
151
|
+
taxRate: raw.voucherItems?.[0]?.taxRatePercent || 0,
|
|
152
|
+
backend: "lexoffice",
|
|
153
|
+
raw,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function fromLexofficeQuotation(raw) {
|
|
157
|
+
const lineItems = (raw.lineItems || [])
|
|
158
|
+
.filter((li) => li.type === "custom")
|
|
159
|
+
.map((li) => ({
|
|
160
|
+
name: li.name,
|
|
161
|
+
quantity: li.quantity,
|
|
162
|
+
unitPrice: li.unitPrice?.netAmount || 0,
|
|
163
|
+
taxRate: li.unitPrice?.taxRatePercentage || 0,
|
|
164
|
+
}));
|
|
165
|
+
return {
|
|
166
|
+
id: raw.id,
|
|
167
|
+
contactId: raw.address?.contactId || "",
|
|
168
|
+
date: raw.voucherDate || "",
|
|
169
|
+
expirationDate: raw.expirationDate,
|
|
170
|
+
totalNet: raw.totalPrice?.totalNetAmount || 0,
|
|
171
|
+
totalGross: raw.totalPrice?.totalGrossAmount || 0,
|
|
172
|
+
lineItems,
|
|
173
|
+
backend: "lexoffice",
|
|
174
|
+
raw,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// === Lexoffice Backend ===
|
|
178
|
+
export class LexofficeBackend {
|
|
179
|
+
name = "lexoffice";
|
|
180
|
+
apiKey;
|
|
181
|
+
constructor(apiKey) {
|
|
182
|
+
this.apiKey = apiKey;
|
|
183
|
+
}
|
|
184
|
+
// --- Contacts ---
|
|
185
|
+
async createContact(input) {
|
|
186
|
+
const body = toLexofficeContact(input);
|
|
187
|
+
const result = await lexofficeRequest(this.apiKey, "POST", "/contacts", body);
|
|
188
|
+
return this.getContact(result.id);
|
|
189
|
+
}
|
|
190
|
+
async getContact(id) {
|
|
191
|
+
const raw = await lexofficeRequest(this.apiKey, "GET", `/contacts/${id}`);
|
|
192
|
+
return fromLexofficeContact(raw);
|
|
193
|
+
}
|
|
194
|
+
async listContacts(filter) {
|
|
195
|
+
const qs = { size: String(filter?.limit || 25) };
|
|
196
|
+
if (filter?.email)
|
|
197
|
+
qs.email = filter.email;
|
|
198
|
+
if (filter?.name)
|
|
199
|
+
qs.name = filter.name;
|
|
200
|
+
if (filter?.role === "customer")
|
|
201
|
+
qs.customer = "true";
|
|
202
|
+
if (filter?.role === "vendor")
|
|
203
|
+
qs.vendor = "true";
|
|
204
|
+
const response = await lexofficeRequest(this.apiKey, "GET", "/contacts", undefined, qs);
|
|
205
|
+
return (response.content || []).map(fromLexofficeContact);
|
|
206
|
+
}
|
|
207
|
+
async updateContact(id, input) {
|
|
208
|
+
const existing = await lexofficeRequest(this.apiKey, "GET", `/contacts/${id}`);
|
|
209
|
+
const body = { ...existing, ...input };
|
|
210
|
+
delete body.id;
|
|
211
|
+
delete body.resourceUri;
|
|
212
|
+
if (input.name && body.company) {
|
|
213
|
+
body.company.name = input.name;
|
|
214
|
+
}
|
|
215
|
+
if (input.name && body.person) {
|
|
216
|
+
body.person.lastName = input.name;
|
|
217
|
+
}
|
|
218
|
+
await lexofficeRequest(this.apiKey, "PUT", `/contacts/${id}`, body);
|
|
219
|
+
return this.getContact(id);
|
|
220
|
+
}
|
|
221
|
+
// --- Invoices ---
|
|
222
|
+
async createInvoice(input) {
|
|
223
|
+
const body = toLexofficeInvoice(input);
|
|
224
|
+
const qs = {};
|
|
225
|
+
if (input.finalize)
|
|
226
|
+
qs.finalize = "true";
|
|
227
|
+
const result = await lexofficeRequest(this.apiKey, "POST", "/invoices", body, Object.keys(qs).length ? qs : undefined);
|
|
228
|
+
return this.getInvoice(result.id);
|
|
229
|
+
}
|
|
230
|
+
async getInvoice(id) {
|
|
231
|
+
const raw = await lexofficeRequest(this.apiKey, "GET", `/invoices/${id}`);
|
|
232
|
+
return fromLexofficeInvoice(raw);
|
|
233
|
+
}
|
|
234
|
+
async listInvoices(filter) {
|
|
235
|
+
const qs = { size: String(filter?.limit || 25) };
|
|
236
|
+
if (filter?.status && filter.status !== "overdue") {
|
|
237
|
+
qs.voucherStatus = filter.status;
|
|
238
|
+
}
|
|
239
|
+
if (filter?.contactId) {
|
|
240
|
+
qs.contactId = filter.contactId;
|
|
241
|
+
}
|
|
242
|
+
const response = await lexofficeRequest(this.apiKey, "GET", "/voucherlist", undefined, {
|
|
243
|
+
...qs,
|
|
244
|
+
voucherType: "invoice",
|
|
245
|
+
});
|
|
246
|
+
const items = (response.content || []).map(fromLexofficeInvoice);
|
|
247
|
+
if (filter?.status === "overdue") {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
return items.filter((inv) => new Date(inv.dueDate).getTime() < now && inv.status !== "paid");
|
|
250
|
+
}
|
|
251
|
+
return items;
|
|
252
|
+
}
|
|
253
|
+
async getInvoicePdf(id) {
|
|
254
|
+
const docResponse = await lexofficeRequest(this.apiKey, "GET", `/invoices/${id}/document`);
|
|
255
|
+
const fileId = docResponse.documentFileId;
|
|
256
|
+
await rateLimitSleep();
|
|
257
|
+
const fileResponse = await fetch(`${BASE_URL}/files/${fileId}`, {
|
|
258
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
259
|
+
});
|
|
260
|
+
if (fileResponse.status === 429) {
|
|
261
|
+
throw createError(ErrorCode.RATE_LIMITED, "Lexoffice API rate limit exceeded during PDF download");
|
|
262
|
+
}
|
|
263
|
+
if (!fileResponse.ok) {
|
|
264
|
+
throw createError(ErrorCode.BACKEND_ERROR, `Failed to download PDF: ${fileResponse.status}`);
|
|
265
|
+
}
|
|
266
|
+
const buffer = Buffer.from(await fileResponse.arrayBuffer());
|
|
267
|
+
return { data: buffer, filename: `invoice-${id}.pdf` };
|
|
268
|
+
}
|
|
269
|
+
async updateInvoice(id, input) {
|
|
270
|
+
const existing = await lexofficeRequest(this.apiKey, "GET", `/invoices/${id}`);
|
|
271
|
+
const body = { ...existing, ...input };
|
|
272
|
+
delete body.id;
|
|
273
|
+
delete body.resourceUri;
|
|
274
|
+
delete body.organizationId;
|
|
275
|
+
await lexofficeRequest(this.apiKey, "PUT", `/invoices/${id}`, body);
|
|
276
|
+
return this.getInvoice(id);
|
|
277
|
+
}
|
|
278
|
+
// --- Vouchers ---
|
|
279
|
+
async createVoucher(input) {
|
|
280
|
+
const taxAmount = input.totalGross - (input.totalGross / (1 + input.taxRate / 100));
|
|
281
|
+
const body = {
|
|
282
|
+
type: "voucher",
|
|
283
|
+
voucherType: input.type,
|
|
284
|
+
voucherDate: input.date,
|
|
285
|
+
totalGrossAmount: input.totalGross,
|
|
286
|
+
totalTaxAmount: taxAmount,
|
|
287
|
+
taxType: "gross",
|
|
288
|
+
voucherItems: [{
|
|
289
|
+
amount: input.totalGross,
|
|
290
|
+
taxAmount,
|
|
291
|
+
taxRatePercent: input.taxRate,
|
|
292
|
+
categoryId: "8f8664a8-fd86-11e1-a21f-0800200c9a66",
|
|
293
|
+
}],
|
|
294
|
+
...(input.contactId && { contactId: input.contactId }),
|
|
295
|
+
...(input.remark && { remark: input.remark }),
|
|
296
|
+
};
|
|
297
|
+
const result = await lexofficeRequest(this.apiKey, "POST", "/vouchers", body);
|
|
298
|
+
return this.getVoucher(result.id);
|
|
299
|
+
}
|
|
300
|
+
async getVoucher(id) {
|
|
301
|
+
const raw = await lexofficeRequest(this.apiKey, "GET", `/vouchers/${id}`);
|
|
302
|
+
return fromLexofficeVoucher(raw);
|
|
303
|
+
}
|
|
304
|
+
async listVouchers(filter) {
|
|
305
|
+
const qs = { size: String(filter?.limit || 25) };
|
|
306
|
+
if (filter?.type && filter.type !== "any")
|
|
307
|
+
qs.voucherType = filter.type;
|
|
308
|
+
const response = await lexofficeRequest(this.apiKey, "GET", "/voucherlist", undefined, qs);
|
|
309
|
+
return (response.content || []).map(fromLexofficeVoucher);
|
|
310
|
+
}
|
|
311
|
+
// --- Quotations ---
|
|
312
|
+
async createQuotation(input) {
|
|
313
|
+
const body = {
|
|
314
|
+
voucherDate: input.date,
|
|
315
|
+
address: { contactId: input.contactId },
|
|
316
|
+
lineItems: input.lineItems.map((item) => ({
|
|
317
|
+
type: "custom",
|
|
318
|
+
name: item.name,
|
|
319
|
+
quantity: item.quantity,
|
|
320
|
+
unitName: "Stueck",
|
|
321
|
+
unitPrice: {
|
|
322
|
+
currency: "EUR",
|
|
323
|
+
netAmount: item.unitPrice,
|
|
324
|
+
taxRatePercentage: item.taxRate,
|
|
325
|
+
},
|
|
326
|
+
})),
|
|
327
|
+
totalPrice: { currency: "EUR" },
|
|
328
|
+
taxConditions: { taxType: "net" },
|
|
329
|
+
...(input.expirationDate && { expirationDate: input.expirationDate }),
|
|
330
|
+
...(input.title && { title: input.title }),
|
|
331
|
+
...(input.introduction && { introduction: input.introduction }),
|
|
332
|
+
...(input.remark && { remark: input.remark }),
|
|
333
|
+
};
|
|
334
|
+
const qs = {};
|
|
335
|
+
if (input.finalize)
|
|
336
|
+
qs.finalize = "true";
|
|
337
|
+
const result = await lexofficeRequest(this.apiKey, "POST", "/quotations", body, Object.keys(qs).length ? qs : undefined);
|
|
338
|
+
return this.getQuotation(result.id);
|
|
339
|
+
}
|
|
340
|
+
async getQuotation(id) {
|
|
341
|
+
const raw = await lexofficeRequest(this.apiKey, "GET", `/quotations/${id}`);
|
|
342
|
+
return fromLexofficeQuotation(raw);
|
|
343
|
+
}
|
|
344
|
+
}
|