Billing Adapter
Billing Adapter
Section titled “Billing Adapter”Billing Adapter je propojení mezi Sencai platformou a Lago (self-hosted usage metering) a Stripe (platby), dle ADR-002. Má tři nezávislé odpovědnosti: sync usage eventů do Lago, příjem webhooků o stavu platby zpět z Lago, a generování měsíčních agenturních (MSP) fakturačních souhrnů.
Klíčové údaje
Section titled “Klíčové údaje”- Port: 3600
- Tech: Fastify 5.9, TypeScript (
strict: true), logger Winston (ne Pino — vědomá výjimka, stejný tábor jakogit-connector/cloud-connector), Axios,amqplib0.10 pro audit eventy - Observabilita:
prom-client(Prometheus),@sentry/node(self-hosted GlitchTip backend, dle konvence čteSENTRY_DSN, i když backend je GlitchTip, ne Sentry SaaS) - Mock mode: když je
LAGO_API_KEYprázdné, každé volání Lago se jen zaloguje a nahlásí jako úspěšné bez reálné Lago instance — užitečné pro lokální vývoj
Odpovědnosti
Section titled “Odpovědnosti”Sync out (F2.MON.03)
Section titled “Sync out (F2.MON.03)”Strapi (content-type metering-event) → billing-adapter SyncService (POST /sync, nebo periodický cron) → Lago API (POST /api/v1/events) — nebo mock mode → Strapi: metering-event.lago_synced = trueWebhook in (F2.MON.05)
Section titled “Webhook in (F2.MON.05)”Lago → POST /lago/webhook → ověření X-Lago-Signature (HMAC-SHA256 nad raw bajty requestu, 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)Agenturní fakturační cron (F2.XT.09)
Section titled “Agenturní fakturační cron (F2.XT.09)”setTimeout scheduler (1. den v měsíci, 02:00 UTC — AGENCY_BILLING_CRON je jendokumentační referenční hodnota, skutečnou registraci ověřit v src/agency-billing.cron.ts) → Strapi: najde operátorské organizace (organisation-relationship, parent_org) → POST /api/agency-billing-summaries/generate (per operátor; chyba u jednoho nikdy nezastaví ostatní) → publishAuditEvent() → billing.agency_summary.generatedMarketplace revenue-share side effect (F4.MARKET.06a)
Section titled “Marketplace revenue-share side effect (F4.MARKET.06a)”Po každém úspěšném volání lagoClient.createEvent() uvnitř runSync():
marketplace_app_id(pokud je na metering eventu nastaveno) se propaguje jako Lago event property — jak properties bag vlago-client.ts, tak skutečný HTTP payload builder uvnitřcreateEvent()musí obě znát toto pole, jinak se tiše zahodí.- Pokud
event.metadata.gross_amountexistuje (numerická hodnota — tento adaptér si cenu nikdy nevymýšlí, jen přeposílá to, co emitující strana už spočítala),strapiClient.createMarketplaceRevenueShare()voláPOST /api/marketplace-revenue-shares, autentizováno hlavičkouX-Service-Secret: BILLING_ADAPTER_SERVICE_SECRET(ne Strapi bearer token). - Tento krok je best-effort — selhání se jen zaloguje, nikdy neshodí underlying sync cyklus ani nesníží
events_synced.
Toto je kalkulace/evidence, ne skutečná výplata. sencai_fee_amount/partner_share_amount počítá výhradně sencai.space’s marketplace-revenue-share controller (fee-percent snapshot v momentě vzniku záznamu) — tento adaptér žádnou částku nepočítá ani nedůvěřuje klientem dodané kalkulaci. Reálná výplata partnerům přes Stripe Connect je F4.MARKET.06b, stále PROD-GATED (neimplementováno — byznysové rozhodnutí, ne technická překážka).
Endpoints
Section titled “Endpoints”| Method | Path | Auth | Popis |
|---|---|---|---|
GET | /health | — | Health check, obsahuje mock_mode |
GET | /status | — | Stav adaptéru (mock mode, cron enabled, sync interval) |
GET | /metrics | — | Prometheus metriky |
POST | /sync | X-Service-Secret | On-demand spuštění sync cyklu |
POST | /lago/webhook | X-Lago-Signature (HMAC-SHA256) | Příjem Lago webhooků |
Lago webhook — bezpečnostní detaily (F2.MON.05)
Section titled “Lago webhook — bezpečnostní detaily (F2.MON.05)”- Fail-closed (SEC-109): webhook plugin vyhodí chybu už při registraci pluginu, pokud chybí
LAGO_WEBHOOK_SECRET— služba odmítne nastartovat. Nikdy nepřijímat nepodepsané webhooky, ani “dočasně”. - Raw-bytes HMAC (SEC-170): signatura se počítá nad přesnými raw bajty requestu, nikdy nad
JSON.stringify(request.body)(re-serializace může přeuspořádat klíče/přeformátovat čísla a rozbít ověření). Plugin-scopedaddContentTypeParseruchová raw string vedle parsovaného objektu. - Porovnání signatur přes
crypto.timingSafeEqual, nikdy===. - Idempotence: in-memory ledger klíčovaný
${lago_id}:${webhook_type}s TTL 24h — Lago retryuje doručení při timeoutu/5xx, bez dedup by se aktivace/suspenze tieru (i její audit event) mohla aplikovat dvakrát. - Rate limiting (defense in depth): plugin-scoped
onRequesthook omezuje počet requestů per zdrojová IP v rámci fixního okna (LAGO_WEBHOOK_RATE_LIMIT_MAX, výchozí 60;LAGO_WEBHOOK_RATE_LIMIT_WINDOW_MS, výchozí 60000ms) — nad limit429. In-memory/per-instance, stejné omezení jako idempotency ledger — nedrží přes horizontálně škálované repliky. - Neznámé hodnoty
webhook_typese vždy potvrdí200 OK(jinak by Lago retryoval donekonečna). invoice.external_subscription_idse mapuje nasubscription-tierdocumentId ve Strapi — chybějící mapping se zaloguje a webhook se potvrdí jakoskipped.
Audit logging
Section titled “Audit logging”src/audit.ts — tenký fire-and-forget publisher do RabbitMQ:
- Exchange:
audit.events(topic), routing keyaudit.write - Fail-open: pokud
RABBITMQ_URLnení nastaveno nebo spojení selže, event se jen zaloguje jako “no RabbitMQ” a operace pokračuje — audit nikdy neblokuje billing logiku (na rozdíl od HTTP mutací na Strapi, kde je audit middleware striktnější) - Emitované akce:
billing.tier.activated,billing.tier.suspended(z webhooku),billing.agency_summary.generated(z cronu) - Referenční příklad vzoru “non-HTTP cesta volající
audit.log()/publishAuditEvent()explicitně” popsaného v kořenovémCLAUDE.md
Autentizace vůči Strapi
Section titled “Autentizace vůči Strapi”BILLING_ADAPTER_STRAPI_TOKEN byl dřív statický, ručně rotovaný env var. Od 2026-07-18 nahrazen dynamickým, api-manager-vydávaným tokenem (src/services/token-client.ts, tokenClient.getToken()) — billing-adapter je teď zaregistrovaná api-manager identita s automatickou 7denní rotací, stejně jako většina ostatních služeb. API_MANAGER_URL/API_MANAGER_SERVICE_SECRET musí být nastavené, jinak tokenClient.getToken() vyhodí chybu a sync cyklus/webhook selže hlasitě, ne tiše.
Samotná oprava tokenu nestačila — tři nezávislé backendové bugy (mezera ve service-account org-scopingu na metering-event.find(), úplně chybějící PUT route a service-account gate na subscription-tier.activate()) byly zavřeny souběžně v sencai.space; viz CHANGELOG.md tohoto repa pro plný popis root cause.
Stínový metering-sync engine Go backendu (F5.METERING.04)
Section titled “Stínový metering-sync engine Go backendu (F5.METERING.04)”Od 2026-07-18 sencai-backend (Go strangler-fig backend — viz Migrace Go backendu) běží vlastní nezávislou, read-only implementaci úplně stejné sync-out odpovědnosti (internal/meteringsync + cmd/metering-consumer), čte backend-db přes dedikovaný, read-only go_metering_reader MySQL credential a zapisuje vlastní bookkeeping do go-backend-db. Toto je stínový dual-write burn-in, ne cutover:
billing-adapterzůstává jediným producentem, který skutečně flipujemetering_events.lago_synced— Go engine sleduje vlastní sync stav nezávisle a nikdy nezapisuje zpět do Strapi sloupce.- Oba producenti publikují Prometheus countery pod stejnými jmény sérií, odlišené jen
joblabelem, takže jdou srovnat vedle sebe v jednom Grafana dashboardu (sencai-metering-cutover). billingBatchSyncTotal/billingEventsProcessedTotalve vlastnímsrc/metrics.tstéhle služby byly mrtvý kód (deklarované, registrované, nikdy neinkrementované) až do doby, kdy tenhle parity design mezeru odhalil — teď jsou skutečně zapojené vrunSync().- Rozhodnutí o cutoveru (vypnutí
LAGO_CRON_ENABLEDna této službě ve prospěch Go enginu) nebylo učiněno a vyžaduje vlastní explicitní budoucí schválení — vizsencai-backend/PHASE-5.5.mdpro burn-in kritéria.
Konfigurace
Section titled “Konfigurace”Plná šablona v .env.example. Klíčové proměnné:
PORT=3600LAGO_BASE_URL=http://lago-api:3000LAGO_API_KEY= # prázdné = mock modeLAGO_WEBHOOK_SECRET= # POVINNÉ — služba odmítne nastartovat bez něj (fail-closed)LAGO_CRON_ENABLED=false # periodický sync — nikdy nezapínat bez explicitního potvrzeníLAGO_WEBHOOK_RATE_LIMIT_MAX=60LAGO_WEBHOOK_RATE_LIMIT_WINDOW_MS=60000
AGENCY_BILLING_CRON_ENABLED=false # nikdy nezapínat bez explicitního potvrzeníAGENCY_BILLING_CRON=0 2 1 * * # dokumentační referenční hodnota
STRAPI_URL=http://backend:1337API_MANAGER_URL=http://api-manager:3200API_MANAGER_SERVICE_SECRET=
BILLING_ADAPTER_SERVICE_SECRET= # pro vytváření marketplace-revenue-shareRABBITMQ_URL=amqp://admin:admin@sencai-mq:5672SENTRY_DSN= # GlitchTip DSN; prázdné = bezpečný no-opTestování
Section titled “Testování”Jest v29 + ts-jest (přidáno F4.MARKET.06a — služba předtím neměla žádný test runner). Testy v src/services/__tests__/.
npm test # jest, jednorázověnpm test:watch # jest --watchLagoClient/StrapiClient jsou singletony, které čtou process.env ve svém konstruktoru při prvním importu — test potřebující jinou env variantu musí process.env[...] = ... → jest.resetModules() → await import(...) pro každý test zvlášť, a axios samo musí být znovu-importované dynamicky stejným způsobem uvnitř téhož testu (ne staticky na začátku souboru) — jinak modulová registry epocha axios mocku uvnitř lago-client.ts/strapi-client.ts neodpovídá referenci, kterou test assertuje, a expect(mockedAxios.post).toHaveBeenCalledWith(...) tiše selže s “Number of calls: 0”, i když k reálnému volání došlo. Viz docstring v src/services/__tests__/lago-client.test.ts.
Časté problémy
Section titled “Časté problémy”Q: Route /lago/webhook neexistuje / služba nenastartuje
A: Chybí LAGO_WEBHOOK_SECRET — fail-closed dle návrhu (SEC-109).
Q: Sync cyklus “uspěje”, ale v Lago se nic neobjeví
A: Zkontrolovat LAGO_API_KEY — prázdná hodnota tiše spouští mock mode, který hlásí úspěch na každém volání bez kontaktu na reálnou Lago instanci.
Q: Webhook aktualizace tieru se nikdy neaplikuje
A: invoice.external_subscription_id musí odpovídat reálnému subscription-tier.documentId ve Strapi. Chybějící mapping zaloguje skipped/no_tier_ref bez chyby — zkontrolovat nejdřív external ID Lago subscription, ne logiku webhooku.
Q: Chybí audit eventy v audit logu
A: Výpadky RabbitMQ nikdy nezastaví zpracování webhooku/cronu — audit eventy se jen zalogují jako zahozené. Zkontrolovat RABBITMQ_URL a dostupnost sencai-mq dřív, než podezírat logiku webhooku.
Q: tokenClient.getToken() vyhodí chybu při startu
A: API_MANAGER_URL/API_MANAGER_SERVICE_SECRET nejsou nastavené, nebo api-manager ještě nemá zaregistrovanou identitu billing-adapter.
Související služby
Section titled “Související služby”- sencai.space — zdroj content-types
metering-event,subscription-tier,agency-billing-summaryamarketplace-revenue-share(F4.MARKET.06a) - sencai-watchdog — druhá polovina billingu, dunning: cron pro suspend/archive při selhání platby, nezávislý na téhle službě
- sencai-backend (
internal/meteringsync) — stínová parity implementace sync-out odpovědnosti, viz výše - sencai-mq — RabbitMQ instance;
auth-service-consumer/audit-consumerzapisuje hash-chained audit log řádek - Lago — externí metering/billing backend; Stripe je fakturační vrstva dle ADR-002, mimo scope tohoto repozitáře