Billing Adapter
Billing Adapter
Section titled “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.
Key Facts
Section titled “Key Facts”- Port: 3600
- Tech: Fastify 5.9, TypeScript (
strict: true), Winston logger (not Pino — a deliberate exception, same camp asgit-connector/cloud-connector), Axios,amqplib0.10 for audit events - Observability:
prom-client(Prometheus),@sentry/node(GlitchTip self-hosted backend, readsSENTRY_DSNby convention even though the backend is GlitchTip, not Sentry SaaS) - Mock mode: when
LAGO_API_KEYis empty, every Lago call is logged and reported as successful without a real Lago instance — useful for local development
Responsibilities
Section titled “Responsibilities”Sync out (F2.MON.03)
Section titled “Sync out (F2.MON.03)”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 = trueWebhook in (F2.MON.05)
Section titled “Webhook in (F2.MON.05)”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)Agency billing cron (F2.XT.09)
Section titled “Agency billing cron (F2.XT.09)”setTimeout scheduler (1st of month, 02:00 UTC — AGENCY_BILLING_CRON is a documentation-onlyreference 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.generatedMarketplace 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():
marketplace_app_id(if present on the metering event) is propagated as a Lago event property — bothlago-client.ts’s properties bag and the real HTTP payload builder insidecreateEvent()have to know this field, or it is silently dropped.- If
event.metadata.gross_amountis present (a numeric value — this adapter never invents a price, it only forwards whatever the emitting side already computed),strapiClient.createMarketplaceRevenueShare()callsPOST /api/marketplace-revenue-shares, authenticated withX-Service-Secret: BILLING_ADAPTER_SERVICE_SECRET(not the Strapi bearer token). - 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).
Endpoints
Section titled “Endpoints”| Method | Path | Auth | Description |
|---|---|---|---|
GET | /health | — | Health check, includes mock_mode |
GET | /status | — | Adapter state (mock mode, cron enabled, sync interval) |
GET | /metrics | — | Prometheus metrics |
POST | /sync | X-Service-Secret | On-demand sync cycle |
POST | /lago/webhook | X-Lago-Signature (HMAC-SHA256) | Inbound Lago webhooks |
Lago webhook security (F2.MON.05)
Section titled “Lago webhook security (F2.MON.05)”- Fail-closed (SEC-109): the webhook plugin throws at plugin-registration time if
LAGO_WEBHOOK_SECRETis 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-scopedaddContentTypeParserkeeps 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
onRequesthook caps requests per source IP within a fixed window (LAGO_WEBHOOK_RATE_LIMIT_MAX, default 60;LAGO_WEBHOOK_RATE_LIMIT_WINDOW_MS, default 60000ms) —429over the limit. This is in-memory/per-instance, same limitation as the idempotency ledger — it does not hold across horizontally scaled replicas. - Unknown
webhook_typevalues are always acknowledged200 OK(otherwise Lago would retry indefinitely). invoice.external_subscription_idmaps to asubscription-tierdocumentId in Strapi — a missing mapping is logged and the webhook is acknowledged asskipped.
Audit logging
Section titled “Audit logging”src/audit.ts is a thin fire-and-forget RabbitMQ publisher:
- Exchange:
audit.events(topic), routing keyaudit.write - Fail-open: if
RABBITMQ_URLis 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 rootCLAUDE.md
Authentication with Strapi
Section titled “Authentication with Strapi”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-adapterremains the only producer that actually flipsmetering_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
joblabel, so they can be compared side by side on one Grafana dashboard (sencai-metering-cutover). billingBatchSyncTotal/billingEventsProcessedTotalin this service’s ownsrc/metrics.tswere dead code (declared, registered, never incremented) until this parity design surfaced the gap — they are now wired for real inrunSync().- A cutover decision (disabling
LAGO_CRON_ENABLEDon this service in favor of the Go engine) has not been made and requires its own explicit future approval — seesencai-backend/PHASE-5.5.mdfor the burn-in criteria.
Configuration
Section titled “Configuration”Full template in .env.example. Key variables:
PORT=3600LAGO_BASE_URL=http://lago-api:3000LAGO_API_KEY= # empty = mock modeLAGO_WEBHOOK_SECRET= # MANDATORY — service refuses to start without it (fail-closed)LAGO_CRON_ENABLED=false # periodic sync — never enable without explicit confirmationLAGO_WEBHOOK_RATE_LIMIT_MAX=60LAGO_WEBHOOK_RATE_LIMIT_WINDOW_MS=60000
AGENCY_BILLING_CRON_ENABLED=false # never enable without explicit confirmationAGENCY_BILLING_CRON=0 2 1 * * # documentation-only reference value
STRAPI_URL=http://backend:1337API_MANAGER_URL=http://api-manager:3200API_MANAGER_SERVICE_SECRET=
BILLING_ADAPTER_SERVICE_SECRET= # for marketplace-revenue-share creationRABBITMQ_URL=amqp://admin:admin@sencai-mq:5672SENTRY_DSN= # GlitchTip DSN; empty = safe no-opTesting
Section titled “Testing”Jest v29 + ts-jest (added F4.MARKET.06a — the service had no test runner before that). Tests live in src/services/__tests__/.
npm test # jest, oncenpm test:watch # jest --watchLagoClient/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.
Troubleshooting
Section titled “Troubleshooting”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.
Related services
Section titled “Related services”- sencai.space — source of the
metering-event,subscription-tier,agency-billing-summary, andmarketplace-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-consumerwrites 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