Skip to content

Billing Adapter

Billing Adapter is the bridge between the Sencai platform and Lago (self-hosted usage metering) and Stripe (payments), per ADR-002. It has three independent responsibilities: syncing usage events out to Lago, receiving payment-status webhooks back from Lago, and generating monthly agency/MSP billing summaries.

  • Port: 3600
  • Tech: Fastify 5.9, TypeScript (strict: true), Winston logger (not Pino — a deliberate exception, same camp as git-connector/cloud-connector), Axios, amqplib 0.10 for audit events
  • Observability: prom-client (Prometheus), @sentry/node (GlitchTip self-hosted backend, reads SENTRY_DSN by convention even though the backend is GlitchTip, not Sentry SaaS)
  • Mock mode: when LAGO_API_KEY is empty, every Lago call is logged and reported as successful without a real Lago instance — useful for local development
Strapi (metering-event content-type)
→ billing-adapter SyncService (POST /sync, or the periodic cron)
→ Lago API (POST /api/v1/events) — or mock mode
→ Strapi: metering-event.lago_synced = true
Lago → POST /lago/webhook
→ verify X-Lago-Signature (HMAC-SHA256 over the raw request bytes, LAGO_WEBHOOK_SECRET)
→ invoice.paid → PUT /api/subscription-tiers/:documentId { status: active }
→ invoice.payment_failure → PUT /api/subscription-tiers/:documentId { status: grace_period, grace_expires_at: +7d }
→ publishAuditEvent() → RabbitMQ audit.events (billing.tier.activated / billing.tier.suspended)
setTimeout scheduler (1st of month, 02:00 UTC — AGENCY_BILLING_CRON is a documentation-only
reference value, check src/agency-billing.cron.ts for the real registration)
→ Strapi: find operator organisations (organisation-relationship, parent_org)
→ POST /api/agency-billing-summaries/generate (per operator; one failure never blocks the rest)
→ publishAuditEvent() → billing.agency_summary.generated

Marketplace revenue-share side effect (F4.MARKET.06a)

Section titled “Marketplace revenue-share side effect (F4.MARKET.06a)”

After every successful lagoClient.createEvent() call inside runSync():

  1. marketplace_app_id (if present on the metering event) is propagated as a Lago event property — both lago-client.ts’s properties bag and the real HTTP payload builder inside createEvent() have to know this field, or it is silently dropped.
  2. If event.metadata.gross_amount is present (a numeric value — this adapter never invents a price, it only forwards whatever the emitting side already computed), strapiClient.createMarketplaceRevenueShare() calls POST /api/marketplace-revenue-shares, authenticated with X-Service-Secret: BILLING_ADAPTER_SERVICE_SECRET (not the Strapi bearer token).
  3. This step is best-effort — a failure is only logged, it never fails the underlying sync cycle or decrements events_synced.

This is calculation/evidence, not an actual payout. sencai.space’s marketplace-revenue-share controller alone computes sencai_fee_amount/partner_share_amount (a fee-percent snapshot taken at the moment the record is created) — this adapter never computes or trusts a client-supplied amount. Real partner payout via Stripe Connect is F4.MARKET.06b, still PROD-GATED (not implemented — a business decision, not a technical blocker).

MethodPathAuthDescription
GET/healthHealth check, includes mock_mode
GET/statusAdapter state (mock mode, cron enabled, sync interval)
GET/metricsPrometheus metrics
POST/syncX-Service-SecretOn-demand sync cycle
POST/lago/webhookX-Lago-Signature (HMAC-SHA256)Inbound Lago webhooks
  • Fail-closed (SEC-109): the webhook plugin throws at plugin-registration time if LAGO_WEBHOOK_SECRET is missing — the service refuses to start. Never accept unsigned webhooks, not even “temporarily”.
  • Raw-bytes HMAC (SEC-170): the signature is computed over the exact raw request bytes, never JSON.stringify(request.body) (re-serialization can reorder keys/reformat numbers and break verification). A plugin-scoped addContentTypeParser keeps the raw string alongside the parsed body.
  • Signature comparison uses crypto.timingSafeEqual, never ===.
  • Idempotency: an in-memory ledger keyed ${lago_id}:${webhook_type} with a 24h TTL — Lago retries delivery on timeout/5xx, so without dedup a tier activation/suspension (and its audit event) could apply twice.
  • Rate limiting (defense in depth): a plugin-scoped onRequest hook caps requests per source IP within a fixed window (LAGO_WEBHOOK_RATE_LIMIT_MAX, default 60; LAGO_WEBHOOK_RATE_LIMIT_WINDOW_MS, default 60000ms) — 429 over the limit. This is in-memory/per-instance, same limitation as the idempotency ledger — it does not hold across horizontally scaled replicas.
  • Unknown webhook_type values are always acknowledged 200 OK (otherwise Lago would retry indefinitely).
  • invoice.external_subscription_id maps to a subscription-tier documentId in Strapi — a missing mapping is logged and the webhook is acknowledged as skipped.

src/audit.ts is a thin fire-and-forget RabbitMQ publisher:

  • Exchange: audit.events (topic), routing key audit.write
  • Fail-open: if RABBITMQ_URL is unset or the connection fails, the event is just logged as “no RabbitMQ” and the operation continues — audit never blocks billing logic (unlike HTTP mutations against Strapi, where the audit middleware is stricter)
  • Emitted actions: billing.tier.activated, billing.tier.suspended (from the webhook), billing.agency_summary.generated (from the cron)
  • This is a reference example of the “non-HTTP path calling audit.log()/publishAuditEvent() explicitly” pattern described in the root CLAUDE.md

BILLING_ADAPTER_STRAPI_TOKEN used to be a static, hand-rotated env var. As of 2026-07-18, it was replaced with a dynamic, api-manager-vended token (src/services/token-client.ts, tokenClient.getToken()) — billing-adapter is now a registered api-manager service identity with automatic 7-day rotation, matching the pattern most other services already use. API_MANAGER_URL/API_MANAGER_SERVICE_SECRET must be set, or tokenClient.getToken() throws and the sync cycle/webhook fails loudly rather than silently.

Fixing the token alone was not sufficient — three independent backend bugs (a service-account org-scoping gap on metering-event.find(), a completely missing PUT route, and a service-account gate on subscription-tier.activate()) were closed at the same time in sencai.space; see that repo’s CHANGELOG.md for the root-cause writeup.

The Go backend’s shadow metering-sync engine (F5.METERING.04)

Section titled “The Go backend’s shadow metering-sync engine (F5.METERING.04)”

As of 2026-07-18, sencai-backend (the Go strangler-fig backend — see Go Backend Migration) runs its own independent, read-only implementation of the exact same sync-out responsibility (internal/meteringsync + cmd/metering-consumer), reading backend-db via a dedicated, read-only go_metering_reader MySQL credential and writing its own bookkeeping to go-backend-db. This is a shadow dual-write burn-in, not a cutover:

  • billing-adapter remains the only producer that actually flips metering_events.lago_synced — the Go engine tracks its own sync state independently and never writes back to Strapi’s own column.
  • Both producers publish Prometheus counters under the same series names, distinguished only by the job label, so they can be compared side by side on one Grafana dashboard (sencai-metering-cutover).
  • billingBatchSyncTotal/billingEventsProcessedTotal in this service’s own src/metrics.ts were dead code (declared, registered, never incremented) until this parity design surfaced the gap — they are now wired for real in runSync().
  • A cutover decision (disabling LAGO_CRON_ENABLED on this service in favor of the Go engine) has not been made and requires its own explicit future approval — see sencai-backend/PHASE-5.5.md for the burn-in criteria.

Full template in .env.example. Key variables:

Terminal window
PORT=3600
LAGO_BASE_URL=http://lago-api:3000
LAGO_API_KEY= # empty = mock mode
LAGO_WEBHOOK_SECRET= # MANDATORY — service refuses to start without it (fail-closed)
LAGO_CRON_ENABLED=false # periodic sync — never enable without explicit confirmation
LAGO_WEBHOOK_RATE_LIMIT_MAX=60
LAGO_WEBHOOK_RATE_LIMIT_WINDOW_MS=60000
AGENCY_BILLING_CRON_ENABLED=false # never enable without explicit confirmation
AGENCY_BILLING_CRON=0 2 1 * * # documentation-only reference value
STRAPI_URL=http://backend:1337
API_MANAGER_URL=http://api-manager:3200
API_MANAGER_SERVICE_SECRET=
BILLING_ADAPTER_SERVICE_SECRET= # for marketplace-revenue-share creation
RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672
SENTRY_DSN= # GlitchTip DSN; empty = safe no-op

Jest v29 + ts-jest (added F4.MARKET.06a — the service had no test runner before that). Tests live in src/services/__tests__/.

Terminal window
npm test # jest, once
npm test:watch # jest --watch

LagoClient/StrapiClient are singletons that read process.env in their constructor at first import — a test needing a different env variant must process.env[...] = ...jest.resetModules()await import(...) per test, and axios itself must be dynamically re-imported the same way inside the same test (not statically at the top of the file) — otherwise the axios mock’s module-registry epoch inside lago-client.ts/strapi-client.ts won’t match the reference the test asserts against, and expect(mockedAxios.post).toHaveBeenCalledWith(...) silently fails with “Number of calls: 0” even though the real call happened. See src/services/__tests__/lago-client.test.ts’s own doc comment for the pattern.

Q: /lago/webhook route doesn’t exist / service won’t start

A: LAGO_WEBHOOK_SECRET is missing — fail-closed by design (SEC-109).

Q: Sync cycle “succeeds” but nothing shows up in Lago

A: Check LAGO_API_KEY — an empty value silently runs mock mode, which reports success on every call without contacting a real Lago instance.

Q: Webhook tier update never applies

A: invoice.external_subscription_id must match a real subscription-tier.documentId in Strapi. A missing mapping logs skipped/no_tier_ref with no error — check the Lago subscription’s external ID first, not the webhook logic.

Q: Audit events missing from the audit log

A: RabbitMQ outages never stop webhook/cron processing — audit events are only logged as dropped. Check RABBITMQ_URL and sencai-mq reachability before suspecting webhook logic.

Q: tokenClient.getToken() throws on startup

A: API_MANAGER_URL/API_MANAGER_SERVICE_SECRET are unset, or api-manager doesn’t have a registered billing-adapter service identity yet.

  • sencai.space — source of the metering-event, subscription-tier, agency-billing-summary, and marketplace-revenue-share (F4.MARKET.06a) content-types
  • sencai-watchdog — the dunning half of billing: payment-failure suspend/archive cron, independent of this service
  • sencai-backend (internal/meteringsync) — shadow parity implementation of the sync-out responsibility, see above
  • sencai-mq — RabbitMQ instance; auth-service-consumer/audit-consumer writes the hash-chained audit log row
  • Lago — external metering/billing backend; Stripe is the invoicing layer per ADR-002, out of scope for this repository