feat(crm): integrate Lexware Office for vouchers and contact sync

Adds complete Lexware Office integration to CRM service:
- Rate-limited HTTP client (Token Bucket, 2 req/s)
- Bidirectional contact sync (manual import + ERP-push)
- Voucher caching (quotes, orders, invoices, credit notes)
- Deal-voucher linking (m:n join table with audit)
- Cron jobs: voucher refresh (4h), ERP push (30min)
- Distributed locks via Redis for job deduplication
- Health check extended with Lexware status
- Prisma schema: LexwareVoucher, DealVoucher, VoucherType enum
- Companies/Contacts/Deals services extended with Lexware data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 20:28:41 +01:00
parent 411a6bbbcb
commit 9d496d2e53
31 changed files with 3173 additions and 99 deletions

View file

@ -24,6 +24,9 @@ services:
- JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem
- JWT_ISSUER=${JWT_ISSUER:-insight-platform}
- CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59}
# Lexware Office Integration (optional)
- LEXWARE_API_KEY=${LEXWARE_API_KEY:-}
- LEXWARE_API_URL=${LEXWARE_API_URL:-https://api.lexware.io}
volumes:
- ./packages/crm-service:/app
- /app/node_modules

View file

@ -428,4 +428,150 @@ packages/frontend/src/crm/companies/
---
## 2026-03-10 | Backend: Lexware Office Integration
### Ueberblick
Der CRM-Service ist jetzt mit Lexware Office (Buchhaltung/ERP) integriert. Drei Hauptfunktionen:
1. **Kontakt-Verknuepfung**: Lexware-Kontakte suchen und mit CRM Companies/Contacts verknuepfen oder importieren
2. **Beleg-Anzeige**: Angebote, Auftragsbestaetigungen, Rechnungen und Gutschriften aus Lexware — anzeigbar am Unternehmen, Kontakt UND am Vorgang
3. **ERP-Push**: CRM-Entitaeten mit Tag "ERP" werden automatisch nach Lexware synchronisiert
### Neue API-Endpoints: Lexware Kontakte
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| GET | `/crm/lexware/contacts/search?name=&email=` | Lexware-Kontakte suchen (Proxy zur Lexware API) |
| POST | `/crm/lexware/contacts/link-company` | Lexware-Kontakt mit CRM Company verknuepfen |
| POST | `/crm/lexware/contacts/link-contact` | Lexware-Kontakt mit CRM Contact verknuepfen |
| DELETE | `/crm/lexware/contacts/unlink-company/:companyId` | Verknuepfung Company <-> Lexware loesen |
| DELETE | `/crm/lexware/contacts/unlink-contact/:contactId` | Verknuepfung Contact <-> Lexware loesen |
| POST | `/crm/lexware/contacts/import-company` | Neue CRM Company aus Lexware-Daten erstellen |
| POST | `/crm/lexware/contacts/import-contact` | Neuen CRM Contact aus Lexware-Daten erstellen |
| POST | `/crm/lexware/contacts/push/:entityType/:entityId` | CRM-Entitaet nach Lexware pushen (company/contact) |
| POST | `/crm/lexware/contacts/sync/:entityType/:entityId` | Lexware-Daten in CRM aktualisieren |
### Neue API-Endpoints: Lexware Belege (Vouchers)
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| GET | `/crm/lexware/vouchers/company/:companyId` | Belege fuer Unternehmen (gecacht) |
| GET | `/crm/lexware/vouchers/contact/:contactId` | Belege fuer Kontakt (gecacht) |
| GET | `/crm/lexware/vouchers/deal/:dealId` | Belege fuer Vorgang (via DealVoucher) |
| POST | `/crm/lexware/vouchers/deal/:dealId/link` | Beleg mit Vorgang verknuepfen |
| DELETE | `/crm/lexware/vouchers/deal/:dealId/unlink/:voucherId` | Beleg-Verknuepfung loesen |
| POST | `/crm/lexware/vouchers/refresh/company/:companyId` | Beleg-Cache manuell aktualisieren |
| POST | `/crm/lexware/vouchers/refresh/contact/:contactId` | Beleg-Cache manuell aktualisieren |
### Beleg-Filter Query-Parameter
| Parameter | Typ | Beschreibung |
|-----------|-----|-------------|
| `voucherType` | string | `QUOTATION`, `ORDER_CONFIRMATION`, `INVOICE`, `CREDIT_NOTE` |
| `voucherStatus` | string | Freitext-Filter nach Beleg-Status |
| `page` | number | Seite (default: 1) |
| `pageSize` | number | Eintraege pro Seite (default: 20) |
### LexwareVoucher-Objekt
```typescript
interface LexwareVoucher {
id: string; // CRM-interne UUID
voucherType: 'QUOTATION' | 'ORDER_CONFIRMATION' | 'INVOICE' | 'CREDIT_NOTE';
voucherNumber?: string; // z.B. "RE-2025-001"
voucherDate?: string; // ISO DateTime
voucherStatus?: string; // z.B. "open", "paid", "overdue"
totalGrossAmount?: string; // Decimal als String, z.B. "1190.00"
totalNetAmount?: string;
totalTaxAmount?: string;
currency: string; // Default "EUR"
title?: string;
lineItemsCount?: number;
lineItemsJson?: string; // JSON-String mit Positionen
lexwareDeepLink?: string; // Link direkt zu Lexware Office
fetchedAt: string; // Wann zuletzt aus Lexware geholt
}
```
### Aenderungen an bestehenden Responses
**Company-Objekt** hat jetzt zusaetzliche Felder:
```json
{
"lexwareContactId": "abc-123", // null wenn nicht verknuepft
"lexwareContactVersion": 3, // Optimistic Locking
"lexwareSyncedAt": "2026-03-10...", // Letzter Sync
"_count": { "contacts": 5, "deals": 2, "lexwareVouchers": 12 }
}
```
**Contact-Objekt**: Identische neue Felder wie Company.
**Deal-Detail** liefert jetzt zusaetzlich `dealVouchers[]`:
```json
{
"dealVouchers": [
{
"id": "...",
"linkedAt": "2026-03-10...",
"voucher": {
"id": "...",
"voucherType": "INVOICE",
"voucherNumber": "RE-2025-001",
"voucherDate": "2025-12-15...",
"voucherStatus": "paid",
"totalGrossAmount": "1190.00",
"currency": "EUR",
"title": "Lizenzgebuehr Q4",
"lexwareDeepLink": "https://app.lexware.de/permalink/..."
}
}
]
}
```
### Vorschlaege fuer das Frontend
1. **Company/Contact-Detail**: Tab oder Sektion "Lexware" mit:
- Status-Badge: "Verknuepft" (gruen) / "Nicht verknuepft" (grau)
- Button "Lexware-Kontakt suchen & verknuepfen" → Modal mit Suchfeld
- Button "Verknuepfung loesen"
- Button "Belege aktualisieren" (Refresh-Icon)
- Beleg-Tabelle: Typ, Nummer, Datum, Status, Brutto-Betrag, Link zu Lexware
2. **Deal-Detail**: Sektion "Belege" mit:
- Verknuepfte Belege als Tabelle (aus `dealVouchers`)
- Button "Beleg verknuepfen" → Dropdown/Modal mit verfuegbaren Belegen des Unternehmens/Kontakts
- Jeder Beleg hat einen externen Link zu Lexware Office
3. **Tags-Integration**: "ERP"-Tag in Company/Contact-Formularen hervorheben (z.B. besondere Farbe), da es den automatischen Push nach Lexware aktiviert
4. **VoucherType Labels** fuer die UI:
- `QUOTATION` → "Angebot"
- `ORDER_CONFIRMATION` → "Auftragsbestaetigung"
- `INVOICE` → "Rechnung"
- `CREDIT_NOTE` → "Gutschrift"
### Cron-Jobs (automatisch im Hintergrund)
- **Beleg-Sync**: Alle 4 Stunden werden Belege fuer alle verknuepften Entitaeten aus Lexware geholt
- **ERP-Push**: Alle 30 Minuten werden Companies/Contacts mit "ERP"-Tag nach Lexware gepusht
### Health Check
`GET /health` zeigt jetzt `"lexware": "up"|"down"|"unconfigured"`:
- `up`: Lexware API erreichbar
- `down`: API-Key konfiguriert aber API nicht erreichbar
- `unconfigured`: Kein API-Key gesetzt (kein Fehler, Modul einfach deaktiviert)
### Deployment-Hinweise
- Neue Env-Variable auf Server: `LEXWARE_API_KEY` (in `.env`)
- DB-Migration noetig: `migration.sql` in `prisma/migrations/20260310_add_lexware_integration/`
- Neue Tabellen: `lexware_vouchers`, `deal_vouchers`
- Neue Felder: `lexware_contact_id`, `lexware_contact_version`, `lexware_synced_at` auf `companies` und `contacts`
---
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*

View file

@ -10,47 +10,68 @@ Der CRM-Service als eigenstaendiges NestJS-Package unter `packages/crm-service/`
```
packages/crm-service/
package.json — Dependencies (NestJS 10, Prisma, Passport, ioredis)
package.json — Dependencies (NestJS 10, Prisma, Passport, ioredis, @nestjs/axios, @nestjs/schedule)
tsconfig.json — Strict TypeScript
nest-cli.json — NestJS CLI Config
Dockerfile — Multi-Stage (base, deps, development, build, production)
.dockerignore — Excludes
prisma/
crm.schema.prisma — Eigenes Schema (app_crm) mit eigenem Client-Output
migrations/ — SQL-Migrationen
src/
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter
config/ — Umgebungsvariablen-Validierung
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule
config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*)
prisma/ — CrmPrismaService (eigener Client)
redis/ — RedisService (Token-Blocklist, Cache)
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
companies/ — CRUD: Unternehmen (uebergeordnete Entity)
contacts/ — CRUD: Kontakte (PERSON, ORGANIZATION) mit Company-Verknuepfung
companies/ — CRUD: Unternehmen (mit Lexware ERP-Push bei Update)
contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update)
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK)
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company-Zuordnung
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers
health/ — Health-Check (DB, Redis, Lexware)
lexware/ — Lexware Office Integration (NEU)
lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s)
lexware-contacts.service.ts — Kontakt-Suche, Link, Import, Push, Sync
lexware-vouchers.service.ts — Beleg-Abruf, Cache, Deal-Verknuepfung
lexware-sync.service.ts — Cron-Jobs (Beleg-Sync 4h, ERP-Push 30min)
lexware-contacts.controller.ts — REST Endpoints Kontakt-Operationen
lexware-vouchers.controller.ts — REST Endpoints Beleg-Operationen
dto/ — Validierungs-DTOs
interfaces/ — TypeScript Interfaces fuer Lexware API
utils/
rate-limiter.ts — Token Bucket (max 2, 2/s Refill)
lexware-mapper.ts — Bidirektionales Mapping CRM <-> Lexware
```
### Datenbank-Modelle (app_crm Schema)
- **Company** — Unternehmen mit Branche, Adresse, Tags, Audit-Trail. Eltern-Entity fuer Contacts und Deals.
- **Contact** — Kontakte (Person/Organisation) mit optionaler Company-Zuordnung (companyId, position)
- **Company** — Unternehmen mit Lexware-Verknuepfung (lexwareContactId, lexwareContactVersion, lexwareSyncedAt)
- **Contact** — Kontakte mit optionaler Lexware-Verknuepfung
- **Activity** — Aktivitaeten verknuepft mit Kontakten
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
- **PipelineStage** — Stufen innerhalb einer Pipeline (Name, Farbe, Reihenfolge editierbar)
- **Deal** — Vorgaenge mit Wert, Status, Pipeline/Stage/Contact/Company-Zuordnung
- **PipelineStage** — Stufen innerhalb einer Pipeline
- **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen
- **LexwareVoucher** (NEU) — Gecachte Belege aus Lexware Office (Angebote, Auftraege, Rechnungen, Gutschriften)
- **DealVoucher** (NEU) — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
### Entity-Beziehungen
```
Company (1) --< (n) Contact — companyId (optional, SetNull bei Loeschung)
Company (1) --< (n) Deal — companyId (optional, SetNull bei Loeschung)
Contact (1) --< (n) Activity — contactId (Cascade bei Loeschung)
Contact (1) --< (n) Deal — contactId (optional, SetNull bei Loeschung)
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade bei Loeschung)
Pipeline (1) --< (n) Deal — pipelineId (Cascade bei Loeschung)
PipelineStage (1) --< (n) Deal — stageId
Company (1) --< (n) Contact — companyId (optional, SetNull)
Company (1) --< (n) Deal — companyId (optional, SetNull)
Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull)
Contact (1) --< (n) Activity — contactId (Cascade)
Contact (1) --< (n) Deal — contactId (optional, SetNull)
Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull)
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade)
Pipeline (1) --< (n) Deal — pipelineId (Cascade)
PipelineStage (1) --< (n) Deal — stageId
Deal (1) --< (n) DealVoucher — dealId (Cascade)
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
```
### API-Endpunkte
@ -66,73 +87,68 @@ PipelineStage (1) --< (n) Deal — stageId
| GET/POST | /api/v1/crm/pipelines | Liste / Erstellen |
| GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete |
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten (Name, Farbe, Reihenfolge) |
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten |
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
| GET | /health | Health-Check (public) |
| GET | /health | Health-Check (DB, Redis, Lexware) |
| **Lexware Kontakte** | | |
| GET | /api/v1/crm/lexware/contacts/search | Lexware-Kontakte suchen |
| POST | /api/v1/crm/lexware/contacts/link-company | Company verknuepfen |
| POST | /api/v1/crm/lexware/contacts/link-contact | Contact verknuepfen |
| DELETE | /api/v1/crm/lexware/contacts/unlink-company/:id | Verknuepfung loesen |
| DELETE | /api/v1/crm/lexware/contacts/unlink-contact/:id | Verknuepfung loesen |
| POST | /api/v1/crm/lexware/contacts/import-company | Company aus Lexware importieren |
| POST | /api/v1/crm/lexware/contacts/import-contact | Contact aus Lexware importieren |
| POST | /api/v1/crm/lexware/contacts/push/:type/:id | CRM -> Lexware pushen |
| POST | /api/v1/crm/lexware/contacts/sync/:type/:id | Lexware -> CRM synchronisieren |
| **Lexware Belege** | | |
| GET | /api/v1/crm/lexware/vouchers/company/:id | Belege fuer Unternehmen |
| GET | /api/v1/crm/lexware/vouchers/contact/:id | Belege fuer Kontakt |
| GET | /api/v1/crm/lexware/vouchers/deal/:id | Belege fuer Vorgang |
| POST | /api/v1/crm/lexware/vouchers/deal/:id/link | Beleg mit Vorgang verknuepfen |
| DELETE | /api/v1/crm/lexware/vouchers/deal/:id/unlink/:vid | Verknuepfung loesen |
| POST | /api/v1/crm/lexware/vouchers/refresh/company/:id | Cache aktualisieren |
| POST | /api/v1/crm/lexware/vouchers/refresh/contact/:id | Cache aktualisieren |
### Lexware Office Integration — Details
- **Rate Limiter**: In-Memory Token Bucket, 2 Requests/Sekunde (Lexware API Limit)
- **Beleg-Caching**: PostgreSQL-Tabelle `lexware_vouchers`, alle 4h Cron-Refresh + manueller Refresh
- **ERP-Push**: Companies/Contacts mit Tag "ERP" werden automatisch (30min Cron) + bei Update nach Lexware gepusht
- **Distributed Locks**: Redis SET NX EX verhindert Doppelausfuehrung von Cron-Jobs
- **Optimistic Locking**: lexwareContactVersion fuer sichere Updates
- **Graceful Degradation**: Ohne LEXWARE_API_KEY → Modul deaktiviert, Health = "unconfigured"
### Docker-Integration
- `docker-compose.crm.yml` im Projekt-Root
- Port: 3100
- Netzwerke: insight-web, insight-db, insight-cache
- Traefik HTTP-Route: `Host(172.20.10.59) && PathPrefix(/api/v1/crm)` mit Priority 100
- Traefik HTTPS-Route: `crm-secure` mit `entrypoints=websecure`, `tls=true`, Priority 100
- JWT Public Key als Read-Only Volume (.keys/jwt-public.pem)
- Direkte PostgreSQL-Verbindung (PgBouncer unterstuetzt kein search_path fuer Schema-Auswahl)
- Neue Env-Variablen: `LEXWARE_API_KEY`, `LEXWARE_API_URL`
- Traefik HTTP + HTTPS Routing: `/api/v1/crm/*`
### Sicherheit
- JWT RS256 Validierung mit shared Public Key
- Token-Revocation via Redis (blocked:{jti})
- Token-Revocation via Redis
- Multi-Tenancy: Alle Queries filtern nach tenantId
- TenantGuard sichert mandantenbezogenen Zugriff
- Globaler ValidationPipe (whitelist + forbidNonWhitelisted)
- Strict TypeScript, kein `any`
- 401 bei fehlendem/ungueltigem Token
### Deployment-Status
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
- Container: insight-crm (Development-Mode)
- Prisma Migrationen angewendet:
- `20260310163211_init` — Initiales Schema (Contact, Activity, Pipeline, PipelineStage, Deal)
- `20260310183117_add_companies` — Company-Entity, Contact.companyId/position, Deal.companyId
- Alle API-Endpunkte getestet und funktionsfaehig
- Traefik-Routing aktiv (HTTP + HTTPS): http(s)://172.20.10.59/api/v1/crm/*
- Swagger-Docs: http://172.20.10.59/api/v1/crm/docs/
### Getestete Endpunkte
| Test | Ergebnis |
|------|----------|
| POST /companies (Erstellen mit allen Feldern) | 200 OK, UUID + _count korrekt |
| GET /companies (Liste) | 200 OK, pagination + _count korrekt |
| GET /companies/:id (Detail) | 200 OK, contacts[] + deals[] + _count |
| PATCH /companies/:id (Update) | 200 OK, updatedBy + tags korrekt |
| GET /companies?search=Xinion | 200 OK, Suche funktioniert |
| POST /contacts (mit companyId + position) | 201 Created, Company-Verknuepfung korrekt |
| GET /contacts/:id (mit Company) | 200 OK, company-Objekt enthalten |
| POST /activities (Notiz) | 201 Created, contactId verknuepft |
| POST /pipelines (mit 4 Stages) | 201 Created, Stages korrekt |
| PATCH /pipelines/:id/stages/:stageId | 200 OK, Stage-Update korrekt |
| POST /deals (mit companyId + contactId) | 200 OK, Company + Contact verknuepft |
| GET /deals/:id (mit Company) | 200 OK, company + pipeline.stages enthalten |
| GET /deals?companyId=... | 200 OK, Filter nach Company funktioniert |
| PATCH /deals/:id (WON) | 200 OK, closedAt automatisch gesetzt |
| GET /contacts ohne Token | 401 Unauthorized |
| Validierung (falsche Felder) | 400 Bad Request, Details korrekt |
### Bekannte Einschraenkungen
- PgBouncer kann nicht genutzt werden (search_path nicht kompatibel mit transaction pooling)
- Prisma Migrationen:
- `20260310163211_init` — Initiales Schema
- `20260310183117_add_companies` — Company-Entity
- `20260310_add_lexware_integration` — Lexware Office Integration (AUSSTEHEND)
### Naechste Schritte
1. Frontend: Company-Modul (Seiten, Formulare, Sidebar-Link)
2. Frontend: Contact/Deal-Formulare um Company-Selektor erweitern
3. Activity-Liste komplett laden (UI-Button "Alle anzeigen")
4. Kanban-Board fuer Vorgaenge (Drag & Drop Stage-Wechsel)
5. E2E-Tests schreiben
6. Production-Build testen (multi-stage Dockerfile)
1. Migration `20260310_add_lexware_integration` auf Server anwenden
2. `LEXWARE_API_KEY` in `.env` auf Server setzen
3. Container neu bauen und deployen
4. Lexware-Endpunkte auf Server testen
5. Frontend: Lexware-Integration in Company/Contact/Deal-Detail-Seiten
6. Activity-Liste komplett laden (UI-Button "Alle anzeigen")
7. Kanban-Board fuer Vorgaenge

View file

@ -9,13 +9,16 @@
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^6.4.0",
"axios": "^1.13.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
@ -244,6 +247,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1726,6 +1730,17 @@
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
"license": "MIT"
},
"node_modules/@nestjs/axios": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz",
"integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
"axios": "^1.3.1",
"rxjs": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
@ -2001,6 +2016,33 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
"license": "MIT",
"dependencies": {
"cron": "3.2.1",
"uuid": "11.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/schedule/node_modules/uuid": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@ -2504,6 +2546,12 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -3257,6 +3305,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -4063,6 +4129,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -4257,6 +4335,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.4.0",
"luxon": "~3.5.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4369,6 +4457,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -4609,6 +4706,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -5353,6 +5465,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -5430,6 +5562,22 @@
"node": "*"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -5792,6 +5940,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -7314,6 +7477,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
@ -8277,6 +8449,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View file

@ -25,13 +25,16 @@
"prisma:studio": "prisma studio --schema=prisma/crm.schema.prisma"
},
"dependencies": {
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^6.4.0",
"axios": "^1.13.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
@ -69,13 +72,19 @@
"typescript": "^5.6.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {

View file

@ -45,6 +45,11 @@ model Company {
isActive Boolean @default(true) @map("is_active")
// Lexware Office Integration
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
lexwareContactVersion Int? @map("lexware_contact_version")
lexwareSyncedAt DateTime? @map("lexware_synced_at")
// Audit-Trail
createdBy String @map("created_by") @db.Uuid
updatedBy String? @map("updated_by") @db.Uuid
@ -53,9 +58,11 @@ model Company {
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
contacts Contact[]
deals Deal[]
contacts Contact[]
deals Deal[]
lexwareVouchers LexwareVoucher[]
@@unique([tenantId, lexwareContactId])
@@index([tenantId])
@@index([tenantId, name])
@@index([tenantId, industry])
@ -100,6 +107,11 @@ model Contact {
isActive Boolean @default(true) @map("is_active")
// Lexware Office Integration
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
lexwareContactVersion Int? @map("lexware_contact_version")
lexwareSyncedAt DateTime? @map("lexware_synced_at")
// Audit-Trail (User-IDs aus platform_core)
createdBy String @map("created_by") @db.Uuid
updatedBy String? @map("updated_by") @db.Uuid
@ -108,10 +120,12 @@ model Contact {
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
activities Activity[]
deals Deal[]
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
activities Activity[]
deals Deal[]
lexwareVouchers LexwareVoucher[]
@@unique([tenantId, lexwareContactId])
@@index([tenantId])
@@index([tenantId, email])
@@index([tenantId, companyId])
@ -250,10 +264,11 @@ model Deal {
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
stage PipelineStage @relation(fields: [stageId], references: [id])
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
stage PipelineStage @relation(fields: [stageId], references: [id])
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
dealVouchers DealVoucher[]
@@index([tenantId])
@@index([tenantId, pipelineId])
@ -272,3 +287,86 @@ enum DealStatus {
@@schema("app_crm")
}
// --------------------------------------------------------
// Lexware Office Integration - Voucher Types
// --------------------------------------------------------
enum VoucherType {
QUOTATION
ORDER_CONFIRMATION
INVOICE
CREDIT_NOTE
@@schema("app_crm")
}
// --------------------------------------------------------
// LexwareVoucher - Gecachte Belege aus Lexware Office
// --------------------------------------------------------
model LexwareVoucher {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
lexwareVoucherId String @map("lexware_voucher_id") @db.VarChar(36)
voucherType VoucherType @map("voucher_type")
voucherNumber String? @map("voucher_number") @db.VarChar(100)
voucherDate DateTime? @map("voucher_date")
voucherStatus String? @map("voucher_status") @db.VarChar(50)
totalGrossAmount Decimal? @map("total_gross_amount") @db.Decimal(15, 2)
totalNetAmount Decimal? @map("total_net_amount") @db.Decimal(15, 2)
totalTaxAmount Decimal? @map("total_tax_amount") @db.Decimal(15, 2)
currency String @default("EUR") @db.VarChar(3)
title String? @db.VarChar(500)
lineItemsCount Int? @map("line_items_count")
lineItemsJson String? @map("line_items_json") @db.Text
// Verknuepfung zu Lexware-Kontakt und CRM-Entitaeten
lexwareContactId String @map("lexware_contact_id") @db.VarChar(36)
companyId String? @map("company_id") @db.Uuid
contactId String? @map("contact_id") @db.Uuid
lexwareDeepLink String? @map("lexware_deep_link") @db.VarChar(500)
fetchedAt DateTime @default(now()) @map("fetched_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
deals DealVoucher[]
@@unique([tenantId, lexwareVoucherId])
@@index([tenantId])
@@index([tenantId, companyId])
@@index([tenantId, contactId])
@@index([tenantId, lexwareContactId])
@@index([tenantId, voucherType])
@@map("lexware_vouchers")
@@schema("app_crm")
}
// --------------------------------------------------------
// DealVoucher - Verknuepfung Deal <-> Lexware-Beleg (m:n)
// --------------------------------------------------------
model DealVoucher {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
dealId String @map("deal_id") @db.Uuid
voucherId String @map("voucher_id") @db.Uuid
linkedBy String @map("linked_by") @db.Uuid
linkedAt DateTime @default(now()) @map("linked_at")
// Relationen
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
voucher LexwareVoucher @relation(fields: [voucherId], references: [id], onDelete: Cascade)
@@unique([dealId, voucherId])
@@index([tenantId])
@@index([tenantId, dealId])
@@index([tenantId, voucherId])
@@map("deal_vouchers")
@@schema("app_crm")
}

View file

@ -0,0 +1,118 @@
-- ============================================================
-- Lexware Office Integration - Schema Migration
-- ============================================================
-- Neue Felder auf Company, Contact
-- Neue Tabellen: lexware_vouchers, deal_vouchers
-- Neuer Enum: VoucherType
-- ============================================================
-- VoucherType Enum
CREATE TYPE "app_crm"."VoucherType" AS ENUM ('QUOTATION', 'ORDER_CONFIRMATION', 'INVOICE', 'CREDIT_NOTE');
-- Company: Lexware-Felder hinzufuegen
ALTER TABLE "app_crm"."companies"
ADD COLUMN "lexware_contact_id" VARCHAR(36),
ADD COLUMN "lexware_contact_version" INTEGER,
ADD COLUMN "lexware_synced_at" TIMESTAMP(3);
-- Contact: Lexware-Felder hinzufuegen
ALTER TABLE "app_crm"."contacts"
ADD COLUMN "lexware_contact_id" VARCHAR(36),
ADD COLUMN "lexware_contact_version" INTEGER,
ADD COLUMN "lexware_synced_at" TIMESTAMP(3);
-- Unique Constraints (tenantId + lexwareContactId)
CREATE UNIQUE INDEX "companies_tenant_id_lexware_contact_id_key"
ON "app_crm"."companies"("tenant_id", "lexware_contact_id");
CREATE UNIQUE INDEX "contacts_tenant_id_lexware_contact_id_key"
ON "app_crm"."contacts"("tenant_id", "lexware_contact_id");
-- LexwareVoucher Tabelle
CREATE TABLE "app_crm"."lexware_vouchers" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"lexware_voucher_id" VARCHAR(36) NOT NULL,
"voucher_type" "app_crm"."VoucherType" NOT NULL,
"voucher_number" VARCHAR(100),
"voucher_date" TIMESTAMP(3),
"voucher_status" VARCHAR(50),
"total_gross_amount" DECIMAL(15,2),
"total_net_amount" DECIMAL(15,2),
"total_tax_amount" DECIMAL(15,2),
"currency" VARCHAR(3) NOT NULL DEFAULT 'EUR',
"title" VARCHAR(500),
"line_items_count" INTEGER,
"line_items_json" TEXT,
"lexware_contact_id" VARCHAR(36) NOT NULL,
"company_id" UUID,
"contact_id" UUID,
"lexware_deep_link" VARCHAR(500),
"fetched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lexware_vouchers_pkey" PRIMARY KEY ("id")
);
-- LexwareVoucher Indizes
CREATE UNIQUE INDEX "lexware_vouchers_tenant_id_lexware_voucher_id_key"
ON "app_crm"."lexware_vouchers"("tenant_id", "lexware_voucher_id");
CREATE INDEX "lexware_vouchers_tenant_id_idx"
ON "app_crm"."lexware_vouchers"("tenant_id");
CREATE INDEX "lexware_vouchers_tenant_id_company_id_idx"
ON "app_crm"."lexware_vouchers"("tenant_id", "company_id");
CREATE INDEX "lexware_vouchers_tenant_id_contact_id_idx"
ON "app_crm"."lexware_vouchers"("tenant_id", "contact_id");
CREATE INDEX "lexware_vouchers_tenant_id_lexware_contact_id_idx"
ON "app_crm"."lexware_vouchers"("tenant_id", "lexware_contact_id");
CREATE INDEX "lexware_vouchers_tenant_id_voucher_type_idx"
ON "app_crm"."lexware_vouchers"("tenant_id", "voucher_type");
-- LexwareVoucher Foreign Keys
ALTER TABLE "app_crm"."lexware_vouchers"
ADD CONSTRAINT "lexware_vouchers_company_id_fkey"
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "app_crm"."lexware_vouchers"
ADD CONSTRAINT "lexware_vouchers_contact_id_fkey"
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- DealVoucher Join-Tabelle
CREATE TABLE "app_crm"."deal_vouchers" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"deal_id" UUID NOT NULL,
"voucher_id" UUID NOT NULL,
"linked_by" UUID NOT NULL,
"linked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "deal_vouchers_pkey" PRIMARY KEY ("id")
);
-- DealVoucher Indizes
CREATE UNIQUE INDEX "deal_vouchers_deal_id_voucher_id_key"
ON "app_crm"."deal_vouchers"("deal_id", "voucher_id");
CREATE INDEX "deal_vouchers_tenant_id_idx"
ON "app_crm"."deal_vouchers"("tenant_id");
CREATE INDEX "deal_vouchers_tenant_id_deal_id_idx"
ON "app_crm"."deal_vouchers"("tenant_id", "deal_id");
CREATE INDEX "deal_vouchers_tenant_id_voucher_id_idx"
ON "app_crm"."deal_vouchers"("tenant_id", "voucher_id");
-- DealVoucher Foreign Keys
ALTER TABLE "app_crm"."deal_vouchers"
ADD CONSTRAINT "deal_vouchers_deal_id_fkey"
FOREIGN KEY ("deal_id") REFERENCES "app_crm"."deals"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "app_crm"."deal_vouchers"
ADD CONSTRAINT "deal_vouchers_voucher_id_fkey"
FOREIGN KEY ("voucher_id") REFERENCES "app_crm"."lexware_vouchers"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
import { validate } from './config/env.validation';
import { CrmPrismaModule } from './prisma/crm-prisma.module';
@ -13,6 +14,7 @@ import { ActivitiesModule } from './activities/activities.module';
import { PipelinesModule } from './pipelines/pipelines.module';
import { DealsModule } from './deals/deals.module';
import { CompaniesModule } from './companies/companies.module';
import { LexwareModule } from './lexware/lexware.module';
@Module({
imports: [
@ -20,6 +22,7 @@ import { CompaniesModule } from './companies/companies.module';
isGlobal: true,
validate,
}),
ScheduleModule.forRoot(),
CrmPrismaModule,
RedisModule,
AuthModule,
@ -29,6 +32,7 @@ import { CompaniesModule } from './companies/companies.module';
PipelinesModule,
DealsModule,
CompaniesModule,
LexwareModule,
],
providers: [
{

View file

@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
import { LexwareModule } from '../lexware/lexware.module';
@Module({
imports: [CrmPrismaModule],
imports: [CrmPrismaModule, LexwareModule],
controllers: [CompaniesController],
providers: [CompaniesService],
exports: [CompaniesService],

View file

@ -1,13 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { QueryCompaniesDto } from './dto/query-companies.dto';
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
import { Prisma } from '.prisma/crm-client';
@Injectable()
export class CompaniesService {
constructor(private readonly prisma: CrmPrismaService) {}
private readonly logger = new Logger(CompaniesService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly lexwareContacts: LexwareContactsService,
) {}
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
return this.prisma.company.create({
@ -106,7 +112,9 @@ export class CompaniesService {
stage: { select: { id: true, name: true, color: true } },
},
},
_count: { select: { contacts: true, deals: true } },
_count: {
select: { contacts: true, deals: true, lexwareVouchers: true },
},
},
});
@ -125,7 +133,7 @@ export class CompaniesService {
) {
await this.findOne(tenantId, id);
return this.prisma.company.update({
const updated = await this.prisma.company.update({
where: { id },
data: {
...dto,
@ -135,6 +143,19 @@ export class CompaniesService {
_count: { select: { contacts: true, deals: true } },
},
});
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
this.lexwareContacts
.pushCompanyToLexware(tenantId, id)
.catch((err: Error) =>
this.logger.warn(
`ERP-Push nach Update fehlgeschlagen fuer Company "${updated.name}": ${err.message}`,
),
);
}
return updated;
}
async remove(tenantId: string, id: string) {

View file

@ -39,6 +39,15 @@ export class EnvironmentVariables {
@IsString()
@IsOptional()
CORS_ORIGINS?: string;
// Lexware Office Integration (optional)
@IsString()
@IsOptional()
LEXWARE_API_KEY?: string;
@IsString()
@IsOptional()
LEXWARE_API_URL?: string;
}
export function validate(

View file

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ContactsController } from './contacts.controller';
import { ContactsService } from './contacts.service';
import { LexwareModule } from '../lexware/lexware.module';
@Module({
imports: [LexwareModule],
controllers: [ContactsController],
providers: [ContactsService],
exports: [ContactsService],

View file

@ -1,13 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateContactDto } from './dto/create-contact.dto';
import { UpdateContactDto } from './dto/update-contact.dto';
import { QueryContactsDto } from './dto/query-contacts.dto';
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
import { Prisma } from '.prisma/crm-client';
@Injectable()
export class ContactsService {
constructor(private readonly prisma: CrmPrismaService) {}
private readonly logger = new Logger(ContactsService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly lexwareContacts: LexwareContactsService,
) {}
async create(tenantId: string, userId: string, dto: CreateContactDto) {
return this.prisma.contact.create({
@ -107,13 +113,26 @@ export class ContactsService {
) {
await this.findOne(tenantId, id);
return this.prisma.contact.update({
const updated = await this.prisma.contact.update({
where: { id },
data: {
...dto,
updatedBy: userId,
},
});
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
this.lexwareContacts
.pushContactToLexware(tenantId, id)
.catch((err: Error) =>
this.logger.warn(
`ERP-Push nach Update fehlgeschlagen fuer Contact "${updated.firstName} ${updated.lastName}": ${err.message}`,
),
);
}
return updated;
}
async remove(tenantId: string, id: string) {

View file

@ -158,6 +158,24 @@ export class DealsService {
stage: true,
contact: true,
company: true,
dealVouchers: {
include: {
voucher: {
select: {
id: true,
voucherType: true,
voucherNumber: true,
voucherDate: true,
voucherStatus: true,
totalGrossAmount: true,
currency: true,
title: true,
lexwareDeepLink: true,
},
},
},
orderBy: { linkedAt: 'desc' },
},
},
});

View file

@ -1,8 +1,9 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Inject, Optional } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { RedisService } from '../redis/redis.service';
import { LexwareClientService } from '../lexware/lexware-client.service';
interface HealthResponse {
status: 'ok' | 'error';
@ -12,6 +13,7 @@ interface HealthResponse {
services: {
database: 'up' | 'down';
redis: 'up' | 'down';
lexware: 'up' | 'down' | 'unconfigured';
};
}
@ -21,35 +23,39 @@ export class HealthController {
constructor(
private readonly prisma: CrmPrismaService,
private readonly redis: RedisService,
@Optional() @Inject(LexwareClientService)
private readonly lexwareClient?: LexwareClientService,
) {}
@Get()
@Public()
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
async check(): Promise<HealthResponse> {
const [dbStatus, redisStatus] = await Promise.allSettled([
const [dbStatus, redisStatus, lexwareStatus] = await Promise.allSettled([
this.checkDatabase(),
this.checkRedis(),
this.checkLexware(),
]);
const allUp =
dbStatus.status === 'fulfilled' &&
dbStatus.value &&
redisStatus.status === 'fulfilled' &&
redisStatus.value;
const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value;
const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value;
const lexwareResult: 'up' | 'down' | 'unconfigured' =
lexwareStatus.status === 'fulfilled'
? lexwareStatus.value
: 'down';
// Lexware "unconfigured" ist kein Fehler
const allUp = dbOk && redisOk && lexwareResult !== 'down';
return {
status: allUp ? 'ok' : 'error',
service: 'crm-service',
timestamp: new Date().toISOString(),
version: '0.1.0',
version: '0.2.0',
services: {
database:
dbStatus.status === 'fulfilled' && dbStatus.value ? 'up' : 'down',
redis:
redisStatus.status === 'fulfilled' && redisStatus.value
? 'up'
: 'down',
database: dbOk ? 'up' : 'down',
redis: redisOk ? 'up' : 'down',
lexware: lexwareResult,
},
};
}
@ -71,4 +77,9 @@ export class HealthController {
return false;
}
}
private async checkLexware(): Promise<'up' | 'down' | 'unconfigured'> {
if (!this.lexwareClient) return 'unconfigured';
return this.lexwareClient.isHealthy();
}
}

View file

@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { LexwareModule } from '../lexware/lexware.module';
@Module({
imports: [LexwareModule],
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class LinkDealVoucherDto {
@ApiProperty({ description: 'CRM LexwareVoucher UUID (gecachter Beleg)' })
@IsUUID()
voucherId!: string;
}

View file

@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsUUID } from 'class-validator';
export class LinkLexwareToCompanyDto {
@ApiProperty({ description: 'CRM Company UUID' })
@IsUUID()
companyId!: string;
@ApiProperty({ description: 'Lexware Office Contact ID' })
@IsString()
lexwareContactId!: string;
}
export class LinkLexwareToContactDto {
@ApiProperty({ description: 'CRM Contact UUID' })
@IsUUID()
contactId!: string;
@ApiProperty({ description: 'Lexware Office Contact ID' })
@IsString()
lexwareContactId!: string;
}
export class ImportLexwareContactDto {
@ApiProperty({ description: 'Lexware Office Contact ID' })
@IsString()
lexwareContactId!: string;
}

View file

@ -0,0 +1,36 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsNumber, IsEnum, Min, Max } from 'class-validator';
export enum VoucherTypeFilter {
QUOTATION = 'QUOTATION',
ORDER_CONFIRMATION = 'ORDER_CONFIRMATION',
INVOICE = 'INVOICE',
CREDIT_NOTE = 'CREDIT_NOTE',
}
export class QueryLexwareVouchersDto {
@ApiPropertyOptional({ enum: VoucherTypeFilter })
@IsOptional()
@IsEnum(VoucherTypeFilter)
voucherType?: VoucherTypeFilter;
@ApiPropertyOptional({
description: 'Beleg-Status (draft, open, paidoff, voided)',
})
@IsOptional()
@IsString()
voucherStatus?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsNumber()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 25, maximum: 100 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
pageSize?: number;
}

View file

@ -0,0 +1,50 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsString,
MinLength,
IsBoolean,
IsNumber,
} from 'class-validator';
export class SearchLexwareContactsDto {
@ApiPropertyOptional({ description: 'Suche nach Name (min 3 Zeichen)' })
@IsOptional()
@IsString()
@MinLength(3)
name?: string;
@ApiPropertyOptional({ description: 'Suche nach E-Mail (min 3 Zeichen)' })
@IsOptional()
@IsString()
@MinLength(3)
email?: string;
@ApiPropertyOptional({ description: 'Exakte Kunden-/Lieferantennummer' })
@IsOptional()
@IsNumber()
number?: number;
@ApiPropertyOptional({
description: 'Nur Kunden anzeigen',
default: true,
})
@IsOptional()
@IsBoolean()
customer?: boolean;
@ApiPropertyOptional({ description: 'Nur Lieferanten anzeigen' })
@IsOptional()
@IsBoolean()
vendor?: boolean;
@ApiPropertyOptional({ description: 'Seite (0-basiert)', default: 0 })
@IsOptional()
@IsNumber()
page?: number;
@ApiPropertyOptional({ description: 'Seitengroesse', default: 25 })
@IsOptional()
@IsNumber()
size?: number;
}

View file

@ -0,0 +1,214 @@
// ============================================================
// Lexware Office API - TypeScript Interfaces
// ============================================================
// Basiert auf: https://developers.lexware.io/docs/
// ============================================================
// --------------------------------------------------------
// Contact Interfaces
// --------------------------------------------------------
export interface LexwareContactPerson {
salutation?: string;
firstName?: string;
lastName?: string;
}
export interface LexwareContactCompany {
name?: string;
taxNumber?: string;
vatRegistrationId?: string;
allowTaxFreeInvoices?: boolean;
contactPersons?: LexwareContactPersonEntry[];
}
export interface LexwareContactPersonEntry {
salutation?: string;
firstName?: string;
lastName?: string;
primary?: boolean;
emailAddress?: string;
phoneNumber?: string;
}
export interface LexwareAddress {
supplement?: string;
street?: string;
zip?: string;
city?: string;
countryCode?: string;
}
export interface LexwareEmailAddresses {
business?: string[];
office?: string[];
private?: string[];
other?: string[];
}
export interface LexwarePhoneNumbers {
business?: string[];
office?: string[];
mobile?: string[];
private?: string[];
fax?: string[];
other?: string[];
}
export interface LexwareContactRoles {
customer?: { number?: number };
vendor?: { number?: number };
}
export interface LexwareContact {
id: string;
organizationId?: string;
version: number;
roles: LexwareContactRoles;
company?: LexwareContactCompany;
person?: LexwareContactPerson;
addresses?: {
billing?: LexwareAddress[];
shipping?: LexwareAddress[];
};
emailAddresses?: LexwareEmailAddresses;
phoneNumbers?: LexwarePhoneNumbers;
note?: string;
archived?: boolean;
}
export interface LexwareContactListResponse {
content: LexwareContact[];
first: boolean;
last: boolean;
totalPages: number;
totalElements: number;
numberOfElements: number;
size: number;
number: number;
}
export interface LexwareContactCreateResponse {
id: string;
resourceUri: string;
createdDate: string;
updatedDate: string;
version: number;
}
// --------------------------------------------------------
// Voucher / Beleg Interfaces
// --------------------------------------------------------
export interface LexwareVoucherListItem {
voucherId: string;
voucherType: string;
voucherNumber: string;
voucherDate: string;
voucherStatus: string;
totalAmount: number;
currency: string;
contactId?: string;
contactName?: string;
}
export interface LexwareVoucherListResponse {
content: LexwareVoucherListItem[];
first: boolean;
last: boolean;
totalPages: number;
totalElements: number;
size: number;
number: number;
}
export interface LexwareLineItem {
id?: string;
type: string;
name: string;
description?: string;
quantity: number;
unitName?: string;
unitPrice?: {
currency: string;
netAmount: number;
grossAmount: number;
taxRatePercentage: number;
};
lineItemAmount?: number;
}
export interface LexwareTotalPrice {
currency: string;
totalNetAmount: number;
totalGrossAmount: number;
totalTaxAmount: number;
totalDiscountAbsolute?: number;
totalDiscountPercentage?: number;
}
export interface LexwareVoucherAddress {
contactId?: string;
name?: string;
supplement?: string;
street?: string;
city?: string;
zip?: string;
countryCode?: string;
contactPerson?: string;
}
export interface LexwareVoucherDetail {
id: string;
organizationId?: string;
voucherNumber?: string;
voucherDate?: string;
voucherStatus?: string;
createdDate?: string;
updatedDate?: string;
version?: number;
archived?: boolean;
language?: string;
address?: LexwareVoucherAddress;
lineItems?: LexwareLineItem[];
totalPrice?: LexwareTotalPrice;
taxConditions?: {
taxType?: string;
};
title?: string;
introduction?: string;
remark?: string;
relatedVouchers?: Array<{
id: string;
voucherNumber: string;
voucherType: string;
}>;
}
// Type alias fuer spezifische Voucher-Typen (gleiche Struktur)
export type LexwareInvoiceDetail = LexwareVoucherDetail & {
paymentConditions?: {
paymentTermLabel?: string;
dueDate?: string;
};
shippingConditions?: {
shippingConditionsLabel?: string;
deliveryTerms?: string;
};
};
export type LexwareQuotationDetail = LexwareVoucherDetail;
export type LexwareOrderConfirmationDetail = LexwareVoucherDetail;
export type LexwareCreditNoteDetail = LexwareVoucherDetail;
// --------------------------------------------------------
// Event Subscription Interfaces
// --------------------------------------------------------
export interface LexwareEventSubscription {
subscriptionId: string;
organizationId: string;
createdDate: string;
eventType: string;
callbackUrl: string;
}

View file

@ -0,0 +1,183 @@
// ============================================================
// Lexware Office API - Rate-limited HTTP Client
// ============================================================
import {
Injectable,
Logger,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosError } from 'axios';
import { firstValueFrom } from 'rxjs';
import { RateLimiter } from './utils/rate-limiter';
@Injectable()
export class LexwareClientService implements OnModuleInit {
private readonly logger = new Logger(LexwareClientService.name);
private readonly rateLimiter: RateLimiter;
private enabled = false;
constructor(
private readonly httpService: HttpService,
private readonly config: ConfigService,
) {
const rateLimit = this.config.get<number>('LEXWARE_RATE_LIMIT', 2);
this.rateLimiter = new RateLimiter(rateLimit, rateLimit);
}
async onModuleInit(): Promise<void> {
const apiKey = this.config.get<string>('LEXWARE_API_KEY');
if (!apiKey) {
this.logger.warn(
'LEXWARE_API_KEY nicht konfiguriert. Lexware-Integration deaktiviert.',
);
this.enabled = false;
return;
}
this.enabled = true;
this.logger.log('Lexware Office Integration aktiviert.');
}
/**
* Prueft ob die Lexware-Integration konfiguriert und erreichbar ist.
*/
isEnabled(): boolean {
return this.enabled;
}
/**
* Health-Check: Prueft Lexware API Erreichbarkeit.
*/
async isHealthy(): Promise<'up' | 'down' | 'unconfigured'> {
if (!this.enabled) return 'unconfigured';
try {
await this.get<unknown>('/v1/profile');
return 'up';
} catch {
return 'down';
}
}
/**
* GET Request an Lexware API (rate-limited).
*/
async get<T>(
path: string,
params?: Record<string, string | number | boolean>,
): Promise<T> {
this.ensureEnabled();
return this.executeWithRetry<T>('GET', path, undefined, params);
}
/**
* POST Request an Lexware API (rate-limited).
*/
async post<T>(path: string, body: unknown): Promise<T> {
this.ensureEnabled();
return this.executeWithRetry<T>('POST', path, body);
}
/**
* PUT Request an Lexware API (rate-limited).
*/
async put<T>(path: string, body: unknown): Promise<T> {
this.ensureEnabled();
return this.executeWithRetry<T>('PUT', path, body);
}
/**
* DELETE Request an Lexware API (rate-limited).
*/
async delete(path: string): Promise<void> {
this.ensureEnabled();
await this.executeWithRetry<void>('DELETE', path);
}
// --------------------------------------------------------
// Internes: Retry-Logik mit Rate Limiting
// --------------------------------------------------------
private async executeWithRetry<T>(
method: string,
path: string,
body?: unknown,
params?: Record<string, string | number | boolean>,
retries = 3,
): Promise<T> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await this.rateLimiter.acquire();
const response = await firstValueFrom(
method === 'GET'
? this.httpService.get<T>(path, { params })
: method === 'POST'
? this.httpService.post<T>(path, body)
: method === 'PUT'
? this.httpService.put<T>(path, body)
: this.httpService.delete<T>(path),
);
return response.data;
} catch (error) {
const axiosError = error as AxiosError;
const status = axiosError.response?.status;
// 429 Too Many Requests -> Backoff und Retry
if (status === 429 && attempt < retries) {
const backoffMs = Math.pow(2, attempt) * 1000; // 2s, 4s
this.logger.warn(
`Lexware Rate Limit erreicht (429). Warte ${backoffMs}ms (Versuch ${attempt}/${retries})`,
);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
continue;
}
// 401/403 -> API Key Problem
if (status === 401 || status === 403) {
this.logger.error(
`Lexware API Authentifizierungsfehler (${status}). API Key pruefen.`,
);
throw new ServiceUnavailableException(
'Lexware Office API nicht erreichbar (Authentifizierung fehlgeschlagen)',
);
}
// Andere Fehler oder letzter Retry
if (attempt === retries) {
const msg =
axiosError.response?.statusText ||
axiosError.message ||
'Unbekannter Fehler';
this.logger.error(
`Lexware API Fehler: ${method} ${path} -> ${status || 'NETWORK'}: ${msg}`,
);
throw error;
}
// Netzwerk-Fehler -> kurzer Backoff
const backoffMs = attempt * 1000;
this.logger.warn(
`Lexware API Fehler (${status || 'NETWORK'}). Retry in ${backoffMs}ms (${attempt}/${retries})`,
);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
// Sollte nicht erreicht werden
throw new ServiceUnavailableException('Lexware API nicht erreichbar');
}
private ensureEnabled(): void {
if (!this.enabled) {
throw new ServiceUnavailableException(
'Lexware Office Integration ist nicht konfiguriert. LEXWARE_API_KEY fehlt.',
);
}
}
}

View file

@ -0,0 +1,214 @@
// ============================================================
// Lexware Office - Contacts REST Controller
// ============================================================
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { CurrentUser, JwtPayload } from '../common/decorators';
import { singleResponse } from '../common/dto/pagination.dto';
import { LexwareContactsService } from './lexware-contacts.service';
import { SearchLexwareContactsDto } from './dto/search-lexware-contacts.dto';
import {
LinkLexwareToCompanyDto,
LinkLexwareToContactDto,
ImportLexwareContactDto,
} from './dto/link-lexware-contact.dto';
@ApiTags('Lexware Office - Kontakte')
@ApiBearerAuth('access-token')
@UseGuards(TenantGuard)
@Controller('lexware/contacts')
export class LexwareContactsController {
constructor(private readonly service: LexwareContactsService) {}
@Get('search')
@ApiOperation({
summary: 'Lexware-Kontakte suchen (Proxy zu Lexware Office API)',
})
async search(@Query() query: SearchLexwareContactsDto) {
const result = await this.service.searchContacts(query);
return {
success: true,
data: result.content,
pagination: {
page: result.number + 1, // Lexware ist 0-basiert, CRM 1-basiert
pageSize: result.size,
total: result.totalElements,
totalPages: result.totalPages,
},
meta: { timestamp: new Date().toISOString() },
};
}
@Post('link-company')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Lexware-Kontakt mit CRM-Unternehmen verknuepfen',
})
async linkCompany(
@CurrentUser() user: JwtPayload,
@Body() dto: LinkLexwareToCompanyDto,
) {
const result = await this.service.linkToCompany(
user.tenantId!,
dto.companyId,
dto.lexwareContactId,
user.sub,
);
return singleResponse(result);
}
@Post('link-contact')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Lexware-Kontakt mit CRM-Kontakt verknuepfen',
})
async linkContact(
@CurrentUser() user: JwtPayload,
@Body() dto: LinkLexwareToContactDto,
) {
const result = await this.service.linkToContact(
user.tenantId!,
dto.contactId,
dto.lexwareContactId,
user.sub,
);
return singleResponse(result);
}
@Delete('unlink-company/:companyId')
@ApiOperation({ summary: 'Lexware-Verknuepfung von Unternehmen loesen' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
async unlinkCompany(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
) {
const result = await this.service.unlinkCompany(
user.tenantId!,
companyId,
user.sub,
);
return singleResponse(result);
}
@Delete('unlink-contact/:contactId')
@ApiOperation({ summary: 'Lexware-Verknuepfung von Kontakt loesen' })
@ApiParam({ name: 'contactId', type: 'string', format: 'uuid' })
async unlinkContact(
@CurrentUser() user: JwtPayload,
@Param('contactId', ParseUUIDPipe) contactId: string,
) {
const result = await this.service.unlinkContact(
user.tenantId!,
contactId,
user.sub,
);
return singleResponse(result);
}
@Post('import-company')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Neues CRM-Unternehmen aus Lexware-Kontakt erstellen',
})
async importCompany(
@CurrentUser() user: JwtPayload,
@Body() dto: ImportLexwareContactDto,
) {
const result = await this.service.importAsCompany(
user.tenantId!,
dto.lexwareContactId,
user.sub,
);
return singleResponse(result);
}
@Post('import-contact')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Neuen CRM-Kontakt aus Lexware-Kontakt erstellen',
})
async importContact(
@CurrentUser() user: JwtPayload,
@Body() dto: ImportLexwareContactDto,
) {
const result = await this.service.importAsContact(
user.tenantId!,
dto.lexwareContactId,
user.sub,
);
return singleResponse(result);
}
@Post('push/:entityType/:entityId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'CRM-Entity nach Lexware pushen (ERP)' })
@ApiParam({ name: 'entityType', enum: ['company', 'contact'] })
@ApiParam({ name: 'entityId', type: 'string', format: 'uuid' })
async push(
@CurrentUser() user: JwtPayload,
@Param('entityType') entityType: string,
@Param('entityId', ParseUUIDPipe) entityId: string,
) {
if (entityType === 'company') {
const result = await this.service.pushCompanyToLexware(
user.tenantId!,
entityId,
);
return singleResponse(result);
} else {
const result = await this.service.pushContactToLexware(
user.tenantId!,
entityId,
);
return singleResponse(result);
}
}
@Post('sync/:entityType/:entityId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Daten aus Lexware in CRM-Entity aktualisieren',
})
@ApiParam({ name: 'entityType', enum: ['company', 'contact'] })
@ApiParam({ name: 'entityId', type: 'string', format: 'uuid' })
async sync(
@CurrentUser() user: JwtPayload,
@Param('entityType') entityType: string,
@Param('entityId', ParseUUIDPipe) entityId: string,
) {
if (entityType === 'company') {
const result = await this.service.syncCompanyFromLexware(
user.tenantId!,
entityId,
user.sub,
);
return singleResponse(result);
} else {
const result = await this.service.syncContactFromLexware(
user.tenantId!,
entityId,
user.sub,
);
return singleResponse(result);
}
}
}

View file

@ -0,0 +1,452 @@
// ============================================================
// Lexware Office - Contact Operations Service
// ============================================================
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { LexwareClientService } from './lexware-client.service';
import { SearchLexwareContactsDto } from './dto/search-lexware-contacts.dto';
import {
LexwareContact,
LexwareContactListResponse,
LexwareContactCreateResponse,
} from './interfaces/lexware-api.interfaces';
import {
lexwareContactToCompanyData,
lexwareContactToContactData,
companyToLexwareContactBody,
contactToLexwareContactBody,
} from './utils/lexware-mapper';
@Injectable()
export class LexwareContactsService {
private readonly logger = new Logger(LexwareContactsService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly lexwareClient: LexwareClientService,
) {}
// --------------------------------------------------------
// Suche in Lexware Office
// --------------------------------------------------------
async searchContacts(
query: SearchLexwareContactsDto,
): Promise<LexwareContactListResponse> {
const params: Record<string, string | number | boolean> = {};
if (query.name) params.name = query.name;
if (query.email) params.email = query.email;
if (query.number !== undefined) params.number = query.number;
if (query.customer !== undefined) params.customer = query.customer;
if (query.vendor !== undefined) params.vendor = query.vendor;
if (query.page !== undefined) params.page = query.page;
if (query.size !== undefined) params.size = query.size;
return this.lexwareClient.get<LexwareContactListResponse>(
'/v1/contacts',
params,
);
}
async getContact(lexwareContactId: string): Promise<LexwareContact> {
return this.lexwareClient.get<LexwareContact>(
`/v1/contacts/${lexwareContactId}`,
);
}
// --------------------------------------------------------
// Link Lexware Contact -> CRM Company
// --------------------------------------------------------
async linkToCompany(
tenantId: string,
companyId: string,
lexwareContactId: string,
userId: string,
) {
// Pruefe ob Company existiert
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
// Pruefe ob Lexware-Kontakt bereits verknuepft
const existing = await this.prisma.company.findFirst({
where: { tenantId, lexwareContactId },
});
if (existing && existing.id !== companyId) {
throw new ConflictException(
`Lexware-Kontakt ist bereits mit Unternehmen "${existing.name}" verknuepft`,
);
}
// Auch bei Contacts pruefen
const existingContact = await this.prisma.contact.findFirst({
where: { tenantId, lexwareContactId },
});
if (existingContact) {
throw new ConflictException(
`Lexware-Kontakt ist bereits mit Kontakt "${existingContact.firstName} ${existingContact.lastName}" verknuepft`,
);
}
// Lexware-Kontakt laden und Version speichern
const lexwareContact = await this.getContact(lexwareContactId);
return this.prisma.company.update({
where: { id: companyId },
data: {
lexwareContactId,
lexwareContactVersion: lexwareContact.version,
lexwareSyncedAt: new Date(),
updatedBy: userId,
},
});
}
// --------------------------------------------------------
// Link Lexware Contact -> CRM Contact
// --------------------------------------------------------
async linkToContact(
tenantId: string,
contactId: string,
lexwareContactId: string,
userId: string,
) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
// Pruefe ob Lexware-Kontakt bereits verknuepft
const existingContact = await this.prisma.contact.findFirst({
where: { tenantId, lexwareContactId },
});
if (existingContact && existingContact.id !== contactId) {
throw new ConflictException(
`Lexware-Kontakt ist bereits mit Kontakt "${existingContact.firstName} ${existingContact.lastName}" verknuepft`,
);
}
const existingCompany = await this.prisma.company.findFirst({
where: { tenantId, lexwareContactId },
});
if (existingCompany) {
throw new ConflictException(
`Lexware-Kontakt ist bereits mit Unternehmen "${existingCompany.name}" verknuepft`,
);
}
const lexwareContact = await this.getContact(lexwareContactId);
return this.prisma.contact.update({
where: { id: contactId },
data: {
lexwareContactId,
lexwareContactVersion: lexwareContact.version,
lexwareSyncedAt: new Date(),
updatedBy: userId,
},
});
}
// --------------------------------------------------------
// Unlink
// --------------------------------------------------------
async unlinkCompany(tenantId: string, companyId: string, userId: string) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
return this.prisma.company.update({
where: { id: companyId },
data: {
lexwareContactId: null,
lexwareContactVersion: null,
lexwareSyncedAt: null,
updatedBy: userId,
},
});
}
async unlinkContact(tenantId: string, contactId: string, userId: string) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
return this.prisma.contact.update({
where: { id: contactId },
data: {
lexwareContactId: null,
lexwareContactVersion: null,
lexwareSyncedAt: null,
updatedBy: userId,
},
});
}
// --------------------------------------------------------
// Import: Lexware -> Neue CRM Entity
// --------------------------------------------------------
async importAsCompany(
tenantId: string,
lexwareContactId: string,
userId: string,
) {
// Pruefe ob bereits verknuepft
const existing = await this.prisma.company.findFirst({
where: { tenantId, lexwareContactId },
});
if (existing) {
throw new ConflictException(
`Lexware-Kontakt ist bereits mit Unternehmen "${existing.name}" verknuepft`,
);
}
const lexwareContact = await this.getContact(lexwareContactId);
const companyData = lexwareContactToCompanyData(lexwareContact);
return this.prisma.company.create({
data: {
tenantId,
...companyData,
lexwareContactId,
lexwareContactVersion: lexwareContact.version,
lexwareSyncedAt: new Date(),
createdBy: userId,
},
include: {
_count: { select: { contacts: true, deals: true } },
},
});
}
async importAsContact(
tenantId: string,
lexwareContactId: string,
userId: string,
) {
const existing = await this.prisma.contact.findFirst({
where: { tenantId, lexwareContactId },
});
if (existing) {
throw new ConflictException(
`Lexware-Kontakt ist bereits mit Kontakt "${existing.firstName} ${existing.lastName}" verknuepft`,
);
}
const lexwareContact = await this.getContact(lexwareContactId);
const contactData = lexwareContactToContactData(lexwareContact);
return this.prisma.contact.create({
data: {
tenantId,
...contactData,
lexwareContactId,
lexwareContactVersion: lexwareContact.version,
lexwareSyncedAt: new Date(),
createdBy: userId,
},
});
}
// --------------------------------------------------------
// Push: CRM -> Lexware (ERP-Tag)
// --------------------------------------------------------
async pushCompanyToLexware(tenantId: string, companyId: string) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
const body = companyToLexwareContactBody(company);
if (company.lexwareContactId) {
// Update: Hole aktuelle Version von Lexware
const current = await this.getContact(company.lexwareContactId);
const updateBody = { ...body, version: current.version };
try {
await this.lexwareClient.put(
`/v1/contacts/${company.lexwareContactId}`,
updateBody,
);
} catch (error: unknown) {
const axiosError = error as { response?: { status?: number } };
if (axiosError.response?.status === 409) {
this.logger.warn(
`Optimistic Locking Konflikt beim Update von Lexware-Kontakt ${company.lexwareContactId}. Ueberspringe.`,
);
return company;
}
throw error;
}
return this.prisma.company.update({
where: { id: companyId },
data: {
lexwareContactVersion: current.version + 1,
lexwareSyncedAt: new Date(),
},
});
} else {
// Create: Neuen Lexware-Kontakt erstellen
const result =
await this.lexwareClient.post<LexwareContactCreateResponse>(
'/v1/contacts',
body,
);
return this.prisma.company.update({
where: { id: companyId },
data: {
lexwareContactId: result.id,
lexwareContactVersion: result.version,
lexwareSyncedAt: new Date(),
},
});
}
}
async pushContactToLexware(tenantId: string, contactId: string) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
const body = contactToLexwareContactBody(contact);
if (contact.lexwareContactId) {
const current = await this.getContact(contact.lexwareContactId);
const updateBody = { ...body, version: current.version };
try {
await this.lexwareClient.put(
`/v1/contacts/${contact.lexwareContactId}`,
updateBody,
);
} catch (error: unknown) {
const axiosError = error as { response?: { status?: number } };
if (axiosError.response?.status === 409) {
this.logger.warn(
`Optimistic Locking Konflikt beim Update von Lexware-Kontakt ${contact.lexwareContactId}. Ueberspringe.`,
);
return contact;
}
throw error;
}
return this.prisma.contact.update({
where: { id: contactId },
data: {
lexwareContactVersion: current.version + 1,
lexwareSyncedAt: new Date(),
},
});
} else {
const result =
await this.lexwareClient.post<LexwareContactCreateResponse>(
'/v1/contacts',
body,
);
return this.prisma.contact.update({
where: { id: contactId },
data: {
lexwareContactId: result.id,
lexwareContactVersion: result.version,
lexwareSyncedAt: new Date(),
},
});
}
}
// --------------------------------------------------------
// Sync: Lexware -> CRM (Daten aktualisieren)
// --------------------------------------------------------
async syncCompanyFromLexware(
tenantId: string,
companyId: string,
userId: string,
) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
if (!company.lexwareContactId) {
throw new NotFoundException(
'Unternehmen ist nicht mit Lexware verknuepft',
);
}
const lexwareContact = await this.getContact(company.lexwareContactId);
const companyData = lexwareContactToCompanyData(lexwareContact);
return this.prisma.company.update({
where: { id: companyId },
data: {
...companyData,
lexwareContactVersion: lexwareContact.version,
lexwareSyncedAt: new Date(),
updatedBy: userId,
},
});
}
async syncContactFromLexware(
tenantId: string,
contactId: string,
userId: string,
) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
if (!contact.lexwareContactId) {
throw new NotFoundException('Kontakt ist nicht mit Lexware verknuepft');
}
const lexwareContact = await this.getContact(contact.lexwareContactId);
const contactData = lexwareContactToContactData(lexwareContact);
return this.prisma.contact.update({
where: { id: contactId },
data: {
...contactData,
lexwareContactVersion: lexwareContact.version,
lexwareSyncedAt: new Date(),
updatedBy: userId,
},
});
}
}

View file

@ -0,0 +1,210 @@
// ============================================================
// Lexware Office - Sync Jobs (Cron)
// ============================================================
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { RedisService } from '../redis/redis.service';
import { LexwareClientService } from './lexware-client.service';
import { LexwareContactsService } from './lexware-contacts.service';
import { LexwareVouchersService } from './lexware-vouchers.service';
@Injectable()
export class LexwareSyncService {
private readonly logger = new Logger(LexwareSyncService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly redis: RedisService,
private readonly lexwareClient: LexwareClientService,
private readonly contactsService: LexwareContactsService,
private readonly vouchersService: LexwareVouchersService,
) {}
// --------------------------------------------------------
// Job 1: Beleg-Cache aktualisieren (alle 4 Stunden)
// --------------------------------------------------------
@Cron(CronExpression.EVERY_4_HOURS)
async syncVouchers(): Promise<void> {
if (!this.lexwareClient.isEnabled()) return;
// Distributed Lock: Nur eine Instanz gleichzeitig
const lockKey = 'lexware:sync:vouchers:lock';
const acquired = await this.acquireLock(lockKey, 1800); // 30 Min Lock
if (!acquired) {
this.logger.log('Beleg-Sync: Lock nicht erhalten. Ueberspringe.');
return;
}
try {
this.logger.log('Starte Beleg-Sync fuer alle verknuepften Entitaeten...');
// Alle Companies mit Lexware-Verknuepfung
const companies = await this.prisma.company.findMany({
where: { lexwareContactId: { not: null } },
select: {
id: true,
tenantId: true,
lexwareContactId: true,
name: true,
},
});
for (const company of companies) {
try {
await this.vouchersService.fetchAndCacheVouchers(
company.tenantId,
company.lexwareContactId!,
company.id,
null,
);
} catch (error) {
this.logger.warn(
`Beleg-Sync fehlgeschlagen fuer Company "${company.name}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
);
}
}
// Alle Contacts mit Lexware-Verknuepfung
const contacts = await this.prisma.contact.findMany({
where: { lexwareContactId: { not: null } },
select: {
id: true,
tenantId: true,
lexwareContactId: true,
firstName: true,
lastName: true,
},
});
for (const contact of contacts) {
try {
await this.vouchersService.fetchAndCacheVouchers(
contact.tenantId,
contact.lexwareContactId!,
null,
contact.id,
);
} catch (error) {
this.logger.warn(
`Beleg-Sync fehlgeschlagen fuer Contact "${contact.firstName} ${contact.lastName}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
);
}
}
this.logger.log(
`Beleg-Sync abgeschlossen. ${companies.length} Companies, ${contacts.length} Contacts verarbeitet.`,
);
} finally {
await this.releaseLock(lockKey);
}
}
// --------------------------------------------------------
// Job 2: ERP-Tag Push (alle 30 Minuten)
// --------------------------------------------------------
@Cron('0 */30 * * * *')
async pushErpTaggedEntities(): Promise<void> {
if (!this.lexwareClient.isEnabled()) return;
const lockKey = 'lexware:sync:push:lock';
const acquired = await this.acquireLock(lockKey, 900); // 15 Min Lock
if (!acquired) {
this.logger.log('ERP-Push: Lock nicht erhalten. Ueberspringe.');
return;
}
try {
this.logger.log('Starte ERP-Push fuer getaggte Entitaeten...');
// Companies mit "ERP" Tag
const companies = await this.prisma.company.findMany({
where: {
tags: { has: 'ERP' },
},
select: {
id: true,
tenantId: true,
name: true,
lexwareContactId: true,
},
});
let pushCount = 0;
for (const company of companies) {
try {
await this.contactsService.pushCompanyToLexware(
company.tenantId,
company.id,
);
pushCount++;
} catch (error) {
this.logger.warn(
`ERP-Push fehlgeschlagen fuer Company "${company.name}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
);
}
}
// Contacts mit "ERP" Tag
const contacts = await this.prisma.contact.findMany({
where: {
tags: { has: 'ERP' },
},
select: {
id: true,
tenantId: true,
firstName: true,
lastName: true,
lexwareContactId: true,
},
});
for (const contact of contacts) {
try {
await this.contactsService.pushContactToLexware(
contact.tenantId,
contact.id,
);
pushCount++;
} catch (error) {
this.logger.warn(
`ERP-Push fehlgeschlagen fuer Contact "${contact.firstName} ${contact.lastName}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
);
}
}
this.logger.log(
`ERP-Push abgeschlossen. ${pushCount} von ${companies.length + contacts.length} Entitaeten gepusht.`,
);
} finally {
await this.releaseLock(lockKey);
}
}
// --------------------------------------------------------
// Distributed Lock (Redis SET NX EX)
// --------------------------------------------------------
private async acquireLock(
key: string,
ttlSeconds: number,
): Promise<boolean> {
try {
const result = await this.redis.setNx(key, 'locked', ttlSeconds);
return result;
} catch {
return false;
}
}
private async releaseLock(key: string): Promise<void> {
try {
await this.redis.del(key);
} catch {
// Lock wird durch TTL automatisch freigegeben
}
}
}

View file

@ -0,0 +1,190 @@
// ============================================================
// Lexware Office - Vouchers REST Controller
// ============================================================
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { CurrentUser, JwtPayload } from '../common/decorators';
import {
paginatedResponse,
singleResponse,
} from '../common/dto/pagination.dto';
import { LexwareVouchersService } from './lexware-vouchers.service';
import { QueryLexwareVouchersDto } from './dto/query-lexware-vouchers.dto';
import { LinkDealVoucherDto } from './dto/link-deal-voucher.dto';
@ApiTags('Lexware Office - Belege')
@ApiBearerAuth('access-token')
@UseGuards(TenantGuard)
@Controller('lexware/vouchers')
export class LexwareVouchersController {
constructor(private readonly service: LexwareVouchersService) {}
// --------------------------------------------------------
// Belege abrufen
// --------------------------------------------------------
@Get('company/:companyId')
@ApiOperation({ summary: 'Lexware-Belege fuer Unternehmen abrufen (Cache)' })
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
async getForCompany(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
@Query() query: QueryLexwareVouchersDto,
) {
const result = await this.service.getVouchersForCompany(
user.tenantId!,
companyId,
query,
);
return paginatedResponse(
result.data,
result.total,
result.page,
result.pageSize,
);
}
@Get('contact/:contactId')
@ApiOperation({ summary: 'Lexware-Belege fuer Kontakt abrufen (Cache)' })
@ApiParam({ name: 'contactId', type: 'string', format: 'uuid' })
async getForContact(
@CurrentUser() user: JwtPayload,
@Param('contactId', ParseUUIDPipe) contactId: string,
@Query() query: QueryLexwareVouchersDto,
) {
const result = await this.service.getVouchersForContact(
user.tenantId!,
contactId,
query,
);
return paginatedResponse(
result.data,
result.total,
result.page,
result.pageSize,
);
}
@Get('deal/:dealId')
@ApiOperation({ summary: 'Lexware-Belege fuer Vorgang abrufen' })
@ApiParam({ name: 'dealId', type: 'string', format: 'uuid' })
async getForDeal(
@CurrentUser() user: JwtPayload,
@Param('dealId', ParseUUIDPipe) dealId: string,
@Query() query: QueryLexwareVouchersDto,
) {
const result = await this.service.getVouchersForDeal(
user.tenantId!,
dealId,
query,
);
return paginatedResponse(
result.data,
result.total,
result.page,
result.pageSize,
);
}
// --------------------------------------------------------
// Beleg mit Vorgang verknuepfen / trennen
// --------------------------------------------------------
@Post('deal/:dealId/link')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Lexware-Beleg mit Vorgang verknuepfen' })
@ApiParam({ name: 'dealId', type: 'string', format: 'uuid' })
async linkToDeal(
@CurrentUser() user: JwtPayload,
@Param('dealId', ParseUUIDPipe) dealId: string,
@Body() dto: LinkDealVoucherDto,
) {
const result = await this.service.linkVoucherToDeal(
user.tenantId!,
dealId,
dto.voucherId,
user.sub,
);
return singleResponse(result);
}
@Delete('deal/:dealId/unlink/:voucherId')
@ApiOperation({ summary: 'Lexware-Beleg von Vorgang trennen' })
@ApiParam({ name: 'dealId', type: 'string', format: 'uuid' })
@ApiParam({ name: 'voucherId', type: 'string', format: 'uuid' })
async unlinkFromDeal(
@CurrentUser() user: JwtPayload,
@Param('dealId', ParseUUIDPipe) dealId: string,
@Param('voucherId', ParseUUIDPipe) voucherId: string,
) {
const result = await this.service.unlinkVoucherFromDeal(
user.tenantId!,
dealId,
voucherId,
);
return singleResponse(result);
}
// --------------------------------------------------------
// Cache manuell aktualisieren
// --------------------------------------------------------
@Post('refresh/company/:companyId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Beleg-Cache fuer Unternehmen manuell aktualisieren',
})
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
async refreshCompany(
@CurrentUser() user: JwtPayload,
@Param('companyId', ParseUUIDPipe) companyId: string,
) {
const count = await this.service.refreshCompanyVouchers(
user.tenantId!,
companyId,
);
return {
success: true,
data: { refreshedCount: count },
meta: { timestamp: new Date().toISOString() },
};
}
@Post('refresh/contact/:contactId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Beleg-Cache fuer Kontakt manuell aktualisieren' })
@ApiParam({ name: 'contactId', type: 'string', format: 'uuid' })
async refreshContact(
@CurrentUser() user: JwtPayload,
@Param('contactId', ParseUUIDPipe) contactId: string,
) {
const count = await this.service.refreshContactVouchers(
user.tenantId!,
contactId,
);
return {
success: true,
data: { refreshedCount: count },
meta: { timestamp: new Date().toISOString() },
};
}
}

View file

@ -0,0 +1,381 @@
// ============================================================
// Lexware Office - Voucher Operations Service
// ============================================================
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { Prisma } from '.prisma/crm-client';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { LexwareClientService } from './lexware-client.service';
import { QueryLexwareVouchersDto } from './dto/query-lexware-vouchers.dto';
import {
LexwareVoucherListResponse,
LexwareVoucherDetail,
} from './interfaces/lexware-api.interfaces';
import {
voucherTypeFromLexware,
voucherTypeToLexwareEndpoint,
voucherDetailToCacheData,
} from './utils/lexware-mapper';
@Injectable()
export class LexwareVouchersService {
private readonly logger = new Logger(LexwareVouchersService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly lexwareClient: LexwareClientService,
) {}
// --------------------------------------------------------
// Belege von Lexware laden und cachen
// --------------------------------------------------------
async fetchAndCacheVouchers(
tenantId: string,
lexwareContactId: string,
companyId?: string | null,
contactId?: string | null,
): Promise<number> {
this.logger.log(
`Lade Belege fuer Lexware-Kontakt ${lexwareContactId}...`,
);
// Alle relevanten Belegtypen laden
const voucherTypes = [
'invoice',
'quotation',
'orderconfirmation',
'creditnote',
];
let totalUpserted = 0;
for (const voucherType of voucherTypes) {
let page = 0;
let hasMore = true;
while (hasMore) {
const listResponse =
await this.lexwareClient.get<LexwareVoucherListResponse>(
'/v1/voucherlist',
{
contactId: lexwareContactId,
voucherType,
page,
size: 100,
},
);
for (const item of listResponse.content) {
try {
// Detail laden fuer vollstaendige Daten
const crmVoucherType = voucherTypeFromLexware(item.voucherType);
const endpoint = voucherTypeToLexwareEndpoint(crmVoucherType);
const detail =
await this.lexwareClient.get<LexwareVoucherDetail>(
`/v1/${endpoint}/${item.voucherId}`,
);
const cacheData = voucherDetailToCacheData(
detail,
crmVoucherType,
lexwareContactId,
);
// Upsert: Existierenden Beleg aktualisieren oder neuen anlegen
await this.prisma.lexwareVoucher.upsert({
where: {
tenantId_lexwareVoucherId: {
tenantId,
lexwareVoucherId: item.voucherId,
},
},
create: {
tenantId,
lexwareVoucherId: item.voucherId,
...cacheData,
companyId: companyId || undefined,
contactId: contactId || undefined,
fetchedAt: new Date(),
},
update: {
...cacheData,
companyId: companyId || undefined,
contactId: contactId || undefined,
fetchedAt: new Date(),
},
});
totalUpserted++;
} catch (error) {
this.logger.warn(
`Fehler beim Laden von Beleg ${item.voucherId}: ${error instanceof Error ? error.message : 'Unbekannt'}`,
);
}
}
hasMore = !listResponse.last;
page++;
}
}
this.logger.log(
`${totalUpserted} Belege fuer Lexware-Kontakt ${lexwareContactId} gecacht.`,
);
return totalUpserted;
}
// --------------------------------------------------------
// Belege fuer Company abrufen (aus Cache)
// --------------------------------------------------------
async getVouchersForCompany(
tenantId: string,
companyId: string,
query: QueryLexwareVouchersDto,
) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
return this.queryVouchers(tenantId, { companyId }, query);
}
// --------------------------------------------------------
// Belege fuer Contact abrufen (aus Cache)
// --------------------------------------------------------
async getVouchersForContact(
tenantId: string,
contactId: string,
query: QueryLexwareVouchersDto,
) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
return this.queryVouchers(tenantId, { contactId }, query);
}
// --------------------------------------------------------
// Belege fuer Deal abrufen (ueber DealVoucher Join-Table)
// --------------------------------------------------------
async getVouchersForDeal(
tenantId: string,
dealId: string,
query: QueryLexwareVouchersDto,
) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, tenantId },
});
if (!deal) {
throw new NotFoundException('Vorgang nicht gefunden');
}
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.DealVoucherWhereInput = {
tenantId,
dealId,
};
// Filter auf Voucher-Ebene
const voucherWhere: Prisma.LexwareVoucherWhereInput = {};
if (query.voucherType) {
voucherWhere.voucherType = query.voucherType;
}
if (query.voucherStatus) {
voucherWhere.voucherStatus = query.voucherStatus;
}
if (Object.keys(voucherWhere).length > 0) {
where.voucher = voucherWhere;
}
const [dealVouchers, total] = await Promise.all([
this.prisma.dealVoucher.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { linkedAt: 'desc' },
include: {
voucher: true,
},
}),
this.prisma.dealVoucher.count({ where }),
]);
return {
data: dealVouchers.map((dv) => ({
...dv.voucher,
linkedBy: dv.linkedBy,
linkedAt: dv.linkedAt,
dealVoucherId: dv.id,
})),
total,
page,
pageSize,
};
}
// --------------------------------------------------------
// Beleg mit Deal verknuepfen
// --------------------------------------------------------
async linkVoucherToDeal(
tenantId: string,
dealId: string,
voucherId: string,
userId: string,
) {
// Deal pruefen
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, tenantId },
});
if (!deal) {
throw new NotFoundException('Vorgang nicht gefunden');
}
// Voucher pruefen
const voucher = await this.prisma.lexwareVoucher.findFirst({
where: { id: voucherId, tenantId },
});
if (!voucher) {
throw new NotFoundException('Beleg nicht gefunden');
}
// Pruefe ob bereits verknuepft
const existing = await this.prisma.dealVoucher.findUnique({
where: { dealId_voucherId: { dealId, voucherId } },
});
if (existing) {
throw new ConflictException('Beleg ist bereits mit diesem Vorgang verknuepft');
}
return this.prisma.dealVoucher.create({
data: {
tenantId,
dealId,
voucherId,
linkedBy: userId,
},
include: {
voucher: true,
},
});
}
// --------------------------------------------------------
// Beleg von Deal trennen
// --------------------------------------------------------
async unlinkVoucherFromDeal(
tenantId: string,
dealId: string,
voucherId: string,
) {
const dealVoucher = await this.prisma.dealVoucher.findFirst({
where: { tenantId, dealId, voucherId },
});
if (!dealVoucher) {
throw new NotFoundException('Verknuepfung nicht gefunden');
}
return this.prisma.dealVoucher.delete({
where: { id: dealVoucher.id },
});
}
// --------------------------------------------------------
// Cache manuell aktualisieren
// --------------------------------------------------------
async refreshCompanyVouchers(tenantId: string, companyId: string) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
if (!company.lexwareContactId) {
throw new NotFoundException(
'Unternehmen ist nicht mit Lexware verknuepft',
);
}
return this.fetchAndCacheVouchers(
tenantId,
company.lexwareContactId,
companyId,
null,
);
}
async refreshContactVouchers(tenantId: string, contactId: string) {
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
if (!contact.lexwareContactId) {
throw new NotFoundException('Kontakt ist nicht mit Lexware verknuepft');
}
return this.fetchAndCacheVouchers(
tenantId,
contact.lexwareContactId,
null,
contactId,
);
}
// --------------------------------------------------------
// Interne Hilfsfunktion: Paginierte Voucher-Abfrage
// --------------------------------------------------------
private async queryVouchers(
tenantId: string,
filter: { companyId?: string; contactId?: string },
query: QueryLexwareVouchersDto,
) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.LexwareVoucherWhereInput = {
tenantId,
...filter,
};
if (query.voucherType) {
where.voucherType = query.voucherType;
}
if (query.voucherStatus) {
where.voucherStatus = query.voucherStatus;
}
const [data, total] = await Promise.all([
this.prisma.lexwareVoucher.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { voucherDate: 'desc' },
}),
this.prisma.lexwareVoucher.count({ where }),
]);
return { data, total, page, pageSize };
}
}

View file

@ -0,0 +1,46 @@
// ============================================================
// Lexware Office Integration - NestJS Module
// ============================================================
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
import { RedisModule } from '../redis/redis.module';
import { LexwareClientService } from './lexware-client.service';
import { LexwareContactsService } from './lexware-contacts.service';
import { LexwareVouchersService } from './lexware-vouchers.service';
import { LexwareSyncService } from './lexware-sync.service';
import { LexwareContactsController } from './lexware-contacts.controller';
import { LexwareVouchersController } from './lexware-vouchers.controller';
@Module({
imports: [
HttpModule.registerAsync({
useFactory: (config: ConfigService) => ({
baseURL: config.get<string>(
'LEXWARE_API_URL',
'https://api.lexware.io',
),
timeout: 15000,
headers: {
Authorization: `Bearer ${config.get<string>('LEXWARE_API_KEY', '')}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
}),
inject: [ConfigService],
}),
CrmPrismaModule,
RedisModule,
],
controllers: [LexwareContactsController, LexwareVouchersController],
providers: [
LexwareClientService,
LexwareContactsService,
LexwareVouchersService,
LexwareSyncService,
],
exports: [LexwareContactsService, LexwareVouchersService, LexwareClientService],
})
export class LexwareModule {}

View file

@ -0,0 +1,334 @@
// ============================================================
// Mapping-Funktionen: Lexware Office <-> CRM
// ============================================================
import { VoucherType } from '.prisma/crm-client';
import {
LexwareContact,
LexwareVoucherDetail,
LexwareVoucherListItem,
} from '../interfaces/lexware-api.interfaces';
// --------------------------------------------------------
// Lexware Contact -> CRM Company
// --------------------------------------------------------
export function lexwareContactToCompanyData(lc: LexwareContact): {
name: string;
email?: string;
phone?: string;
website?: string;
street?: string;
zip?: string;
city?: string;
country?: string;
notes?: string;
} {
const name =
lc.company?.name || [lc.person?.firstName, lc.person?.lastName].filter(Boolean).join(' ') || 'Unbekannt';
const billingAddr = lc.addresses?.billing?.[0];
const email = getFirstEmail(lc);
const phone = getFirstPhone(lc);
return {
name,
email: email || undefined,
phone: phone || undefined,
street: billingAddr?.street || undefined,
zip: billingAddr?.zip || undefined,
city: billingAddr?.city || undefined,
country: billingAddr?.countryCode || 'DE',
notes: lc.note || undefined,
};
}
// --------------------------------------------------------
// Lexware Contact -> CRM Contact
// --------------------------------------------------------
export function lexwareContactToContactData(lc: LexwareContact): {
firstName?: string;
lastName?: string;
companyName?: string;
position?: string;
email?: string;
phone?: string;
mobile?: string;
street?: string;
zip?: string;
city?: string;
country?: string;
notes?: string;
type: 'PERSON' | 'ORGANIZATION';
} {
const isPerson = !!lc.person;
const billingAddr = lc.addresses?.billing?.[0];
const email = getFirstEmail(lc);
const phone = getFirstPhone(lc);
const mobile = lc.phoneNumbers?.mobile?.[0];
if (isPerson) {
return {
type: 'PERSON',
firstName: lc.person?.firstName || undefined,
lastName: lc.person?.lastName || 'Unbekannt',
companyName: lc.company?.name || undefined,
position: lc.company?.contactPersons?.[0]?.primary
? 'Primaerkontakt'
: undefined,
email: email || undefined,
phone: phone || undefined,
mobile: mobile || undefined,
street: billingAddr?.street || undefined,
zip: billingAddr?.zip || undefined,
city: billingAddr?.city || undefined,
country: billingAddr?.countryCode || 'DE',
notes: lc.note || undefined,
};
}
return {
type: 'ORGANIZATION',
firstName: lc.company?.contactPersons?.[0]?.firstName || undefined,
lastName:
lc.company?.contactPersons?.[0]?.lastName || lc.company?.name || 'Unbekannt',
companyName: lc.company?.name || undefined,
email: email || undefined,
phone: phone || undefined,
mobile: mobile || undefined,
street: billingAddr?.street || undefined,
zip: billingAddr?.zip || undefined,
city: billingAddr?.city || undefined,
country: billingAddr?.countryCode || 'DE',
notes: lc.note || undefined,
};
}
// --------------------------------------------------------
// CRM Company -> Lexware Contact Body (fuer POST/PUT)
// --------------------------------------------------------
export function companyToLexwareContactBody(company: {
name: string;
email?: string | null;
phone?: string | null;
street?: string | null;
zip?: string | null;
city?: string | null;
country?: string | null;
notes?: string | null;
}): Record<string, unknown> {
const body: Record<string, unknown> = {
version: 0,
roles: { customer: {} },
company: { name: company.name },
};
if (company.street || company.zip || company.city) {
body.addresses = {
billing: [
{
street: company.street || undefined,
zip: company.zip || undefined,
city: company.city || undefined,
countryCode: company.country || 'DE',
},
],
};
}
if (company.email) {
body.emailAddresses = { business: [company.email] };
}
if (company.phone) {
body.phoneNumbers = { business: [company.phone] };
}
if (company.notes) {
body.note = company.notes.substring(0, 1000);
}
return body;
}
// --------------------------------------------------------
// CRM Contact -> Lexware Contact Body (fuer POST/PUT)
// --------------------------------------------------------
export function contactToLexwareContactBody(contact: {
firstName?: string | null;
lastName?: string | null;
companyName?: string | null;
email?: string | null;
phone?: string | null;
mobile?: string | null;
street?: string | null;
zip?: string | null;
city?: string | null;
country?: string | null;
notes?: string | null;
type: string;
}): Record<string, unknown> {
const body: Record<string, unknown> = {
version: 0,
roles: { customer: {} },
};
if (contact.type === 'ORGANIZATION' && contact.companyName) {
body.company = { name: contact.companyName };
} else {
body.person = {
firstName: contact.firstName || undefined,
lastName: contact.lastName || 'Unbekannt',
};
}
if (contact.street || contact.zip || contact.city) {
body.addresses = {
billing: [
{
street: contact.street || undefined,
zip: contact.zip || undefined,
city: contact.city || undefined,
countryCode: contact.country || 'DE',
},
],
};
}
if (contact.email) {
body.emailAddresses = { business: [contact.email] };
}
const phoneNumbers: Record<string, string[]> = {};
if (contact.phone) phoneNumbers.business = [contact.phone];
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
if (Object.keys(phoneNumbers).length > 0) {
body.phoneNumbers = phoneNumbers;
}
if (contact.notes) {
body.note = contact.notes.substring(0, 1000);
}
return body;
}
// --------------------------------------------------------
// Voucher Type Mapping
// --------------------------------------------------------
export function voucherTypeFromLexware(lexwareType: string): VoucherType {
const map: Record<string, VoucherType> = {
invoice: 'INVOICE',
quotation: 'QUOTATION',
orderconfirmation: 'ORDER_CONFIRMATION',
creditnote: 'CREDIT_NOTE',
};
return map[lexwareType.toLowerCase()] || 'INVOICE';
}
export function voucherTypeToLexwareEndpoint(type: VoucherType): string {
const map: Record<VoucherType, string> = {
INVOICE: 'invoices',
QUOTATION: 'quotations',
ORDER_CONFIRMATION: 'order-confirmations',
CREDIT_NOTE: 'credit-notes',
};
return map[type];
}
// --------------------------------------------------------
// Deep Link Builder
// --------------------------------------------------------
export function buildLexwareDeepLink(
voucherType: VoucherType,
voucherId: string,
): string {
const typeMap: Record<VoucherType, string> = {
INVOICE: 'invoices',
QUOTATION: 'quotations',
ORDER_CONFIRMATION: 'order-confirmations',
CREDIT_NOTE: 'credit-notes',
};
const typePath = typeMap[voucherType];
return `https://app.lexware.de/permalink/${typePath}/view/${voucherId}`;
}
// --------------------------------------------------------
// Lexware Voucher Detail -> CRM Cache Daten
// --------------------------------------------------------
export function voucherDetailToCacheData(
detail: LexwareVoucherDetail,
voucherType: VoucherType,
lexwareContactId: string,
): {
voucherType: VoucherType;
voucherNumber: string | null;
voucherDate: Date | null;
voucherStatus: string | null;
totalGrossAmount: number | null;
totalNetAmount: number | null;
totalTaxAmount: number | null;
currency: string;
title: string | null;
lineItemsCount: number | null;
lineItemsJson: string | null;
lexwareContactId: string;
lexwareDeepLink: string;
} {
const lineItems = detail.lineItems?.map((li) => ({
name: li.name,
quantity: li.quantity,
unitName: li.unitName,
unitPrice: li.unitPrice?.grossAmount,
amount: li.lineItemAmount,
}));
return {
voucherType,
voucherNumber: detail.voucherNumber || null,
voucherDate: detail.voucherDate ? new Date(detail.voucherDate) : null,
voucherStatus: detail.voucherStatus || null,
totalGrossAmount: detail.totalPrice?.totalGrossAmount ?? null,
totalNetAmount: detail.totalPrice?.totalNetAmount ?? null,
totalTaxAmount: detail.totalPrice?.totalTaxAmount ?? null,
currency: detail.totalPrice?.currency || 'EUR',
title: detail.title || null,
lineItemsCount: detail.lineItems?.length ?? null,
lineItemsJson: lineItems ? JSON.stringify(lineItems) : null,
lexwareContactId,
lexwareDeepLink: buildLexwareDeepLink(voucherType, detail.id),
};
}
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
function getFirstEmail(lc: LexwareContact): string | undefined {
const emails = lc.emailAddresses;
if (!emails) return undefined;
return (
emails.business?.[0] ||
emails.office?.[0] ||
emails.private?.[0] ||
emails.other?.[0]
);
}
function getFirstPhone(lc: LexwareContact): string | undefined {
const phones = lc.phoneNumbers;
if (!phones) return undefined;
return (
phones.business?.[0] ||
phones.office?.[0] ||
phones.private?.[0] ||
phones.other?.[0]
);
}
// --------------------------------------------------------
// Lexware VoucherList Item -> Voucher Type
// --------------------------------------------------------
export function voucherListItemToType(
item: LexwareVoucherListItem,
): VoucherType {
return voucherTypeFromLexware(item.voucherType);
}

View file

@ -0,0 +1,64 @@
// ============================================================
// Token Bucket Rate Limiter fuer Lexware Office API
// ============================================================
// Max 2 Requests/Sekunde (Lexware Limit)
// ============================================================
export class RateLimiter {
private tokens: number;
private lastRefill: number;
private readonly maxTokens: number;
private readonly refillRate: number; // Tokens pro Sekunde
constructor(maxTokens = 2, refillRate = 2) {
this.maxTokens = maxTokens;
this.refillRate = refillRate;
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
/**
* Wartet bis ein Token verfuegbar ist, dann wird er verbraucht.
* Blockiert maximal 5 Sekunden.
*/
async acquire(): Promise<void> {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return;
}
// Berechne Wartezeit bis naechster Token verfuegbar
const deficit = 1 - this.tokens;
const waitMs = Math.ceil((deficit / this.refillRate) * 1000);
// Max 5 Sekunden warten
const clampedWait = Math.min(waitMs, 5000);
await new Promise((resolve) => setTimeout(resolve, clampedWait));
this.refill();
this.tokens = Math.max(0, this.tokens - 1);
}
/**
* Fuellt Tokens basierend auf vergangener Zeit auf.
*/
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(
this.maxTokens,
this.tokens + elapsed * this.refillRate,
);
this.lastRefill = now;
}
/**
* Aktuelle Token-Anzahl (fuer Monitoring).
*/
getTokens(): number {
this.refill();
return this.tokens;
}
}

View file

@ -76,4 +76,13 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
async del(key: string): Promise<void> {
await this.client.del(key);
}
/**
* SET key NX EX ttl - Distributed Lock.
* Gibt true zurueck wenn Lock erworben, false wenn bereits gelockt.
*/
async setNx(key: string, value: string, ttlSeconds: number): Promise<boolean> {
const result = await this.client.set(key, value, 'EX', ttlSeconds, 'NX');
return result === 'OK';
}
}