Přeskočit na obsah

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ů.

  • Port: 3600
  • Tech: Fastify 5.9, TypeScript (strict: true), logger Winston (ne Pino — vědomá výjimka, stejný tábor jako git-connector/cloud-connector), Axios, amqplib 0.10 pro audit eventy
  • Observabilita: prom-client (Prometheus), @sentry/node (self-hosted GlitchTip backend, dle konvence čte SENTRY_DSN, i když backend je GlitchTip, ne Sentry SaaS)
  • Mock mode: když je LAGO_API_KEY prá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
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 = true
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)
setTimeout scheduler (1. den v měsíci, 02:00 UTC — AGENCY_BILLING_CRON je jen
dokumentač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.generated

Marketplace 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():

  1. marketplace_app_id (pokud je na metering eventu nastaveno) se propaguje jako Lago event property — jak properties bag v lago-client.ts, tak skutečný HTTP payload builder uvnitř createEvent() musí obě znát toto pole, jinak se tiše zahodí.
  2. Pokud event.metadata.gross_amount existuje (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čkou X-Service-Secret: BILLING_ADAPTER_SERVICE_SECRET (ne Strapi bearer token).
  3. 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).

MethodPathAuthPopis
GET/healthHealth check, obsahuje mock_mode
GET/statusStav adaptéru (mock mode, cron enabled, sync interval)
GET/metricsPrometheus metriky
POST/syncX-Service-SecretOn-demand spuštění sync cyklu
POST/lago/webhookX-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-scoped addContentTypeParser uchová 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 onRequest hook 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 limit 429. In-memory/per-instance, stejné omezení jako idempotency ledger — nedrží přes horizontálně škálované repliky.
  • Neznámé hodnoty webhook_type se vždy potvrdí 200 OK (jinak by Lago retryoval donekonečna).
  • invoice.external_subscription_id se mapuje na subscription-tier documentId ve Strapi — chybějící mapping se zaloguje a webhook se potvrdí jako skipped.

src/audit.ts — tenký fire-and-forget publisher do RabbitMQ:

  • Exchange: audit.events (topic), routing key audit.write
  • Fail-open: pokud RABBITMQ_URL není 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ém CLAUDE.md

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-adapter zůstává jediným producentem, který skutečně flipuje metering_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 job labelem, takže jdou srovnat vedle sebe v jednom Grafana dashboardu (sencai-metering-cutover).
  • billingBatchSyncTotal/billingEventsProcessedTotal ve vlastním src/metrics.ts té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é v runSync().
  • Rozhodnutí o cutoveru (vypnutí LAGO_CRON_ENABLED na této službě ve prospěch Go enginu) nebylo učiněno a vyžaduje vlastní explicitní budoucí schválení — viz sencai-backend/PHASE-5.5.md pro burn-in kritéria.

Plná šablona v .env.example. Klíčové proměnné:

Terminál
PORT=3600
LAGO_BASE_URL=http://lago-api:3000
LAGO_API_KEY= # prázdné = mock mode
LAGO_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=60
LAGO_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:1337
API_MANAGER_URL=http://api-manager:3200
API_MANAGER_SERVICE_SECRET=
BILLING_ADAPTER_SERVICE_SECRET= # pro vytváření marketplace-revenue-share
RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672
SENTRY_DSN= # GlitchTip DSN; prázdné = bezpečný no-op

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__/.

Terminál
npm test # jest, jednorázově
npm test:watch # jest --watch

LagoClient/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.

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.

  • sencai.space — zdroj content-types metering-event, subscription-tier, agency-billing-summary a marketplace-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-consumer zapisuje hash-chained audit log řádek
  • Lago — externí metering/billing backend; Stripe je fakturační vrstva dle ADR-002, mimo scope tohoto repozitáře