Skip to content

refactor(lexware): API surface migration + lexoffice → lexware rename - #32

Merged
owittek merged 19 commits into
mainfrom
feat/lexware-api-surface-migration
Apr 9, 2026
Merged

refactor(lexware): API surface migration + lexoffice → lexware rename#32
owittek merged 19 commits into
mainfrom
feat/lexware-api-surface-migration

Conversation

@owittek

@owittek owittek commented Apr 9, 2026

Copy link
Copy Markdown
Owner

Summary

Audit-driven migration of the Lexware Office integration to the post-rebrand API surface, closing three webhook coverage gaps, and completing the lexoffice → lexware rename 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.md
Plan: docs/superpowers/plans/2026-04-09-lexware-api-surface-migration.md

Why

  1. api.lexoffice.io was sunset December 2025 — we were 4 months past deadline on an undocumented endpoint that could have been cut without notice.
  2. Hidden runtime crash in listEventSubscriptions — the code cast a Spring-style Page wrapper as a flat array; the first real call from the connection refresh path would have thrown existing.filter is not a function. The bug was hidden by a test mock returning [].
  3. Three webhook coverage gapstoken.revoked, payment.changed, and downpaymentinvoice (Anzahlungsrechnung) were all missing. Real freshness loss for users.
  4. Partial rename — Phase 5a renamed service exports but explicitly deferred types, schema, routes, i18n, validation, and comments. This PR finishes the job.

What changed

Phase A — Audit fixes (5 commits)

  • Fix listEventSubscriptions Page wrapper unwrap (TDD against fetch directly, no more mocking the bug away)
  • Migrate base URL to api.lexware.io
  • Migrate permalink deep-link to app.lexware.de
  • Correct posting category type annotation ("REVENUE"|"EXPENSE""income"|"outgo")
  • Add voucherlist 10K cap defensive warning

Phase B — New webhook events (5 commits + 2 review followups)

  • token.revokedlexwareConnectionService.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 → new resolvePaymentResourceKind helper 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 immutable lexwareVoucherType column with fallback to legacy status inference.
  • downpaymentinvoice voucher type — German project work (Anzahlungsrechnungen) now flows through delta sync and the dispatcher. Lexware fires regular invoice.* events for these so no new subscription needed; the fix was purely consumer-side.
  • Bonus: the never exhaustiveness guard added to dispatchVoucher caught a pre-existing bug where "invoice" and "creditnote" voucher types were silently unhandled.

Phase C — Rename (6 commits + 1 followup)

  • DB schema (3 tables, 4 enums, 7 columns) via migration 0015_watery_virginia_dare.sql:
    • Tables: secrets.lexoffice_connectionslexware_connections, lexoffice_sync_loglexware_sync_log, lexoffice_category_mappingslexware_category_mappings
    • Enums: actual_source/webhook_source value lexofficelexware (in-place ALTER TYPE ... RENAME VALUE, metadata-only, zero data conversion); lexoffice_sync_type/lexoffice_sync_status type names → lexware_*
    • Columns: lexoffice_voucher_id, lexoffice_contact_id, lexoffice_contact_type, lexoffice_voucher_type on income_plans/commitmentslexware_*
    • Critical fix during implementation: replaced Drizzle generators unsafe drop+recreate enum pattern with ALTER TYPE ... RENAME VALUE — the drop+recreate would have failed at migration apply because the USING source::actual_source cast cannot convert lexoffice text to the new enum that only contains lexware.
  • Code identifiers in 52 files:
    • All 21 Lexoffice* TypeScript types → Lexware*
    • Private helper lexofficeRequestlexwareRequest
    • Schema accessors, column accessors, SQL string literals, validation file rename, hook rename, settings client rename
  • Webhook route: /api/webhooks/lexoffice/api/webhooks/lexware + env var LEXOFFICE_WEBHOOK_URLLEXWARE_WEBHOOK_URL
  • Cron routes: /api/cron/lexoffice-*/api/cron/lexware-* (3 dirs) + GitHub Actions workflow updated
  • i18n: translation key root Settings.lexoffice.*Settings.lexware.*
  • Docs: docs/CLAUDE.md REST exemption list updated

Intentionally preserved

  • "lexoffice_connected" PostHog event in lib/trpc/routers/lexware/connection.ts:65 — external analytics ID, renaming breaks historical queries
  • Historical migration files (append-only)
  • lib/db/migrations/schema.ts — stale db:pull dev-time snapshot, not imported anywhere

Deployment notes

  • Env var rename: Update .env.local and production env to rename LEXOFFICE_WEBHOOK_URLLEXWARE_WEBHOOK_URL before the next connect/refresh cycle. Without it, registerWebhooks silently skips registration (no crash, but webhooks wont register).
  • AAD encryption string changed: lexoffice_api_key|${userId}lexware_api_key|${userId} in lib/services/lexware-connection.ts. Safe for this deployment (zero existing rows in secrets.lexware_connections at migration time). Any hypothetical row encrypted with the old AAD would fail decryption at the auth-guard boundary and surface as a reconnect prompt.
  • Webhook URL change: no production subscriptions reference the old URL per earlier confirmation. registerWebhooks re-registers on next connect/refresh.
  • No data migrations: enum ALTER TYPE ... RENAME VALUE is metadata-only, so existing rows with source = lexoffice automatically read as source = lexware post-migration.

Test plan

  • bun typecheck — clean
  • bun run lint — 0 errors, 13 pre-existing warnings
  • bun run test:unit3423 tests passing across 135 files
  • Spec + code quality review passed on every task via subagent-driven development
  • Verified LexwareVoucherType in lexware-permalink.ts NOT touched (separate pre-existing UI type)
  • Verified PostHog event "lexoffice_connected" preserved
  • Verified migration 0015_watery_virginia_dare.sql is fully idempotent with IF EXISTS guards
  • Integration tests — cannot run in worktree without DATABASE_URL. CI will run them. 28 known pre-existing failures in scenario-projections.integration.test.ts and projection-pipeline.integration.test.ts (caused by trigger commit 3ba423e on main, unrelated to this branch)
  • Manual smoke test in staging: disconnect Lexware → reconnect → confirm webhooks register against /api/webhooks/lexware → trigger a voucher change → confirm targeted-sync processes it → confirm permalink button opens app.lexware.de

🤖 Generated with Claude Code

owittek added 19 commits April 9, 2026 15:34
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.
@owittek
owittek merged commit a92ae47 into main Apr 9, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant