refactor(lexware): API surface migration + lexoffice → lexware rename - #32
Merged
Conversation
The Lexware Office API returns event subscriptions wrapped in a
Spring-style Page object ({ content: [...] }), not as a flat array.
The previous code cast the response directly and would throw
exists.filter is not a function on the first real call from
the connection refresh path. The bug was hidden by a test mock that
returned an empty array.
Confirmed against the official docs and the focus-shift Java SDK
WireMock fixture.
Lexware sunset api.lexoffice.io in December 2025 (4 months ago). The old endpoint still resolves but is undocumented and could be cut without notice.
The legacy app.lexoffice.de host 301-redirects to app.lexware.de today, but is fragile and not the canonical domain per the docs. Skip the redirect hop.
Lexware returns lowercase income/outgo per docs, not uppercase REVENUE/EXPENSE. The field is not read at runtime so this is purely a typing correction.
Lexware caps search-window pagination at 10K entries. Add a loud warning at 9K so we hear about it before silently losing data. Defensive only — actual chunking is deferred until prevalence data shows we need it.
Lexware emits token.revoked when a user revokes their API key in the Lexware Office UI. Wire it through the dispatcher to call the existing suspend path so the connection flips to suspended in ~1s instead of waiting up to 24h for the next failed sync.
Lexware emits payment.changed when a bank transaction is matched against a voucher (the open->paid moment). Our regular .changed events fire on the document, not the payment record, so we miss state transitions recorded via Lexware bank-matching UI. Resolve the resourceId to the right targeted-sync function via a local DB lookup so we avoid wasted API calls and do not fight the rate limiter.
… resolution Code review followup to c19166c. The helper inferred quotation vs orderconfirmation from incomePlans.status, which is a business- lifecycle field that can be mutated independently of the Lexware- assigned voucher type. Switch to the immutable lexofficeVoucherType column (added in f93e43f) when it is populated; fall back to status inference for legacy rows where the column is null. Two new tests prove the helper trusts voucherType over a mismatched status; the original two tests are kept and now exercise the legacy NULL fallback path.
German project work uses Anzahlungsrechnungen (down-payment
invoices). Lexware fires regular invoice.created/changed events
for them — the gap was on our consumer side. Add to the type
union, delta sync constants, dispatcher, and full-sync income
partition. They are billed via /v1/invoices/{id} so syncSingleInvoice
handles them as-is.
…voice Code review followups to 8ba39a5: 1. Add a never-exhaustiveness check to dispatchVoucher in lexware-delta-sync.ts. The voucherTypeToEventType switch in the same file already has one, but dispatchVoucher relied on a manual default warn — meaning future voucher types could silently fall through. Mirror the pattern so the compiler forces switch updates. The guard immediately surfaced a real gap: "invoice" and "creditnote" were present in voucherTypeToEventType but absent from dispatchVoucher. Both added to the salesinvoice branch (same syncSingleInvoice target, consistent with event mapping). 2. Add a partitionIncomeVouchers test case for downpaymentinvoice. The existing tests covered the four pre-task voucher types but not the new one, so a regression that removes downpaymentinvoice from ACTUAL_VOUCHER_TYPES would not be caught.
DB schema (Task 9): - Tables: secrets.lexoffice_connections -> lexware_connections, lexoffice_sync_log -> lexware_sync_log, lexoffice_category_mappings -> lexware_category_mappings - Enums: actual_source / webhook_source value lexoffice -> lexware (in-place ALTER TYPE RENAME VALUE, metadata only); lexoffice_sync_type / lexoffice_sync_status type names -> lexware_* - Columns: lexoffice_voucher_id/contact_id/contact_type/voucher_type on income_plans and commitments -> lexware_*; lexoffice_category_id on lexware_category_mappings -> lexware_category_id - Indexes / constraints / policies renamed - Idempotent migration with IF EXISTS guards - No data UPDATEs needed (Postgres stores enum values as integers, the in-place value rename is metadata-only) Code identifiers: - Lexoffice* type names in lexware-api.ts -> Lexware* - lexofficeRequest private helper -> lexwareRequest - All schema accessor renames (lexofficeVoucherId etc. -> lexware*) - SQL string literals lexoffice -> lexware in targeted-sync upserts - lib/validations/lexoffice.ts -> lib/validations/lexware.ts - Comments and docstrings updated Phase 5a service exports (lexwareApi, lexwareSyncService, etc.) were already renamed on main — unchanged. DO NOT TOUCH (intentional): - PostHog event name lexoffice_connected (external analytics id) - LexwareVoucherType in lexware-permalink.ts (separate pre-existing UI deep-link type, already correct) - Historical migration files No backwards-compat shims (per CLAUDE.md no-backcompat rule). Routes and i18n keys are renamed in follow-up commits.
Followup to 501405c — this private module-level constant in lexware-sync.ts was missed by the broad rename. Zero runtime impact (not exported, no external consumers), purely cosmetic consistency with the lexwareCategoryName parameter that reads from it.
…pi/webhooks/lexware User confirmed no production subscriptions reference the old URL. The connection register-subscriptions flow re-registers all webhooks against the new URL on next connect/refresh. Also renames env var LEXOFFICE_WEBHOOK_URL -> LEXWARE_WEBHOOK_URL. Deployment operators must update .env.local and production env.
…ron/lexware-* GitHub Actions workflow update follows in the next commit.
Updates job URLs from /api/cron/lexoffice-* to /api/cron/lexware-*. First cron run after merge uses the new URL; no broken window.
…ings.lexware User-visible strings (Lexware Office) stay in German/English unchanged — only the JSON key path changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Audit-driven migration of the Lexware Office integration to the post-rebrand API surface, closing three webhook coverage gaps, and completing the
lexoffice → lexwarerename across code, DB schema, routes, and i18n that Phase 5a deliberately scoped out.Spec:
docs/superpowers/specs/2026-04-09-lexware-api-surface-migration-design.mdPlan:
docs/superpowers/plans/2026-04-09-lexware-api-surface-migration.mdWhy
api.lexoffice.iowas sunset December 2025 — we were 4 months past deadline on an undocumented endpoint that could have been cut without notice.listEventSubscriptions— the code cast a Spring-stylePagewrapper as a flat array; the first real call from the connection refresh path would have thrownexisting.filter is not a function. The bug was hidden by a test mock returning[].token.revoked,payment.changed, anddownpaymentinvoice(Anzahlungsrechnung) were all missing. Real freshness loss for users.What changed
Phase A — Audit fixes (5 commits)
listEventSubscriptionsPage wrapper unwrap (TDD againstfetchdirectly, no more mocking the bug away)api.lexware.ioapp.lexware.de"REVENUE"|"EXPENSE"→"income"|"outgo")Phase B — New webhook events (5 commits + 2 review followups)
token.revoked→lexwareConnectionService.suspend(userId, "token-revoked"). Flips the connection to suspended in ~1s when the user revokes in Lexware Office, instead of waiting up to 24h for the next failed sync.payment.changed→ newresolvePaymentResourceKindhelper reads the local DB (commitments + incomePlans) to figure out which targeted-sync function to call. Avoids wasted API calls and rate-limiter contention. Follows the immutablelexwareVoucherTypecolumn with fallback to legacystatusinference.downpaymentinvoicevoucher type — German project work (Anzahlungsrechnungen) now flows through delta sync and the dispatcher. Lexware fires regularinvoice.*events for these so no new subscription needed; the fix was purely consumer-side.neverexhaustiveness guard added todispatchVouchercaught a pre-existing bug where"invoice"and"creditnote"voucher types were silently unhandled.Phase C — Rename (6 commits + 1 followup)
0015_watery_virginia_dare.sql:secrets.lexoffice_connections→lexware_connections,lexoffice_sync_log→lexware_sync_log,lexoffice_category_mappings→lexware_category_mappingsactual_source/webhook_sourcevaluelexoffice→lexware(in-placeALTER TYPE ... RENAME VALUE, metadata-only, zero data conversion);lexoffice_sync_type/lexoffice_sync_statustype names →lexware_*lexoffice_voucher_id,lexoffice_contact_id,lexoffice_contact_type,lexoffice_voucher_typeonincome_plans/commitments→lexware_*ALTER TYPE ... RENAME VALUE— the drop+recreate would have failed at migration apply because theUSING source::actual_sourcecast cannot convertlexofficetext to the new enum that only containslexware.Lexoffice*TypeScript types →Lexware*lexofficeRequest→lexwareRequest/api/webhooks/lexoffice→/api/webhooks/lexware+ env varLEXOFFICE_WEBHOOK_URL→LEXWARE_WEBHOOK_URL/api/cron/lexoffice-*→/api/cron/lexware-*(3 dirs) + GitHub Actions workflow updatedSettings.lexoffice.*→Settings.lexware.*docs/CLAUDE.mdREST exemption list updatedIntentionally preserved
"lexoffice_connected"PostHog event inlib/trpc/routers/lexware/connection.ts:65— external analytics ID, renaming breaks historical querieslib/db/migrations/schema.ts— staledb:pulldev-time snapshot, not imported anywhereDeployment notes
.env.localand production env to renameLEXOFFICE_WEBHOOK_URL→LEXWARE_WEBHOOK_URLbefore the next connect/refresh cycle. Without it,registerWebhookssilently skips registration (no crash, but webhooks wont register).lexoffice_api_key|${userId}→lexware_api_key|${userId}inlib/services/lexware-connection.ts. Safe for this deployment (zero existing rows insecrets.lexware_connectionsat migration time). Any hypothetical row encrypted with the old AAD would fail decryption at the auth-guard boundary and surface as a reconnect prompt.registerWebhooksre-registers on next connect/refresh.ALTER TYPE ... RENAME VALUEis metadata-only, so existing rows withsource = lexofficeautomatically read assource = lexwarepost-migration.Test plan
bun typecheck— cleanbun run lint— 0 errors, 13 pre-existing warningsbun run test:unit— 3423 tests passing across 135 filesLexwareVoucherTypeinlexware-permalink.tsNOT touched (separate pre-existing UI type)"lexoffice_connected"preserved0015_watery_virginia_dare.sqlis fully idempotent withIF EXISTSguardsDATABASE_URL. CI will run them. 28 known pre-existing failures inscenario-projections.integration.test.tsandprojection-pipeline.integration.test.ts(caused by trigger commit3ba423eon main, unrelated to this branch)/api/webhooks/lexware→ trigger a voucher change → confirm targeted-sync processes it → confirm permalink button opensapp.lexware.de🤖 Generated with Claude Code