Skip to content

Billing Service

billing-service owns AZ subscription plans, monthly prices, invoices, subscription lifecycle state, and commercial plan entitlements.

Public /api/wallet*, /api/admin/wallet*, /api/hooks/*, subscription pages, and /wallet?tab=billing remain legacy-proxied. Native /v1/* routes are for service tests, migration tooling, and future gateway adapters only.

Boundary

Owns:

  • Plan catalog rows such as FREE_TRIAL, AZ_STARTER, AZ_PRO, AZ_CENTER, AZ_SCHOOL, and future enterprise plans.
  • Monthly plan prices and billing periods.
  • Subscription state, current billing periods, cancel-at-period-end flag, and invoice state.
  • Commercial entitlements emitted to usage-service.
  • Optional best-effort audit projection for billing state events using sourceService=billing-service and sourceEventId=<billing event id>.

Does not own:

  • SePay/provider webhook ingress or provider reconciliation. Those belong to payment-service.
  • AZ Credit wallet balances or immutable ledger grants. Those belong to wallet-service.
  • Live quota counters and consume/reset decisions. Those belong to usage-service.

Broker Subscriber

When BILLING_EVENT_TRANSPORT=nats or MONETIZATION_EVENT_TRANSPORT=nats is configured, billing-service starts a durable NATS JetStream subscriber for monetization.payment.order.paid.v1. The broker handler reuses the same /v1/events/payment-order-paid activation usecase as HTTP fallback, then the existing billing outbox publishes entitlement and subscription-credit events. Default transport remains HTTP until broker runtime proof and route rollback evidence are complete.

Native Routes

MethodPathPurpose
PUT/v1/plans/{planCode}Upsert a plan, price, and entitlements.
GET/v1/plansList plan bundles.
POST/v1/plans/seed-defaultsIdempotently seed fixed legacy default plans.
GET/v1/plans/{planCode}Read one plan bundle.
POST/v1/subscriptionsCreate a subscription plus invoice idempotently.
GET/v1/subscriptions/by-account/{accountId}Read current open subscription by account and optional organizationId.
GET/v1/subscriptions/{subscriptionId}Read subscription state.
POST/v1/subscriptions/{subscriptionId}/activate-paymentActivate from a payment-service outcome.
POST/v1/subscriptions/{subscriptionId}/cancelCancel now or at period end.
POST/v1/subscriptions/{subscriptionId}/renewal-previewPreview the next full-period renewal without creating invoices or events.
POST/v1/subscriptions/{subscriptionId}/renewCreate an idempotent full-period renewal invoice without proration.
GET/v1/subscriptions/{subscriptionId}/entitlementsReturn plan entitlements for usage-service consumption.
GET/v1/invoices/{invoiceId}Read invoice state.
POST/v1/events/payment-order-paidConsume a payment.order.paid.v1 envelope from payment-service.

Idempotency

POST /v1/plans/seed-defaults upserts the fixed legacy azCreditPlans constants for FREE_TRIAL, AZ_STARTER, AZ_PRO, AZ_CENTER, and AZ_SCHOOL. Running it repeatedly keeps the same plan codes, monthly price rows, and entitlement keys without creating duplicate plans/prices/entitlements; it uses the current plan upsert event semantics.

Legacy also declares AZ_CREDIT_ENTERPRISE_PLAN_CODE = "AZ_ENTERPRISE" but does not define a fixed azCreditPlans price or limit bundle for it. Native seeding returns this as customPlanMappings with billingModel=custom_contract, fixedPrice=false, and fixedEntitlements=false; it does not create an active hidden enterprise plan row.

POST /v1/subscriptions and POST /v1/subscriptions/{subscriptionId}/activate-payment require an idempotencyKey body field or Idempotency-Key header. Replays return replay: true and do not create duplicate subscriptions, paid invoices, or activation events.

POST /v1/events/payment-order-paid validates source=payment-service and type=payment.order.paid.v1, requires metadata.subscriptionId for SUBSCRIPTION payments, and is replay-safe by source payment event id. Public wallet/subscription routes stay legacy-proxied until gateway parity evidence is available.

The HTTP fallback is internal-only: it requires X-Internal-Service: payment-service and X-Internal-Token, matched against BILLING_INTERNAL_SERVICE_TOKEN or INTERNAL_SERVICE_TOKEN. Missing or wrong credentials return 401; an unconfigured receiver returns 503 and does not decode or process the event. Billing's HTTP outbox deliveries carry X-Internal-Service: billing-service with the same shared token to Usage and Wallet.

When USAGE_SERVICE_URL/USAGE_SERVICE_BASE_URL and WALLET_SERVICE_URL/WALLET_SERVICE_BASE_URL are configured, the same native payment-event flow stores billing.entitlements.updated.v1 and billing.subscription.activated.v1 rows in the billing event outbox. It dispatches entitlement snapshots to usage-service at /v1/events/billing-entitlements-updated and subscription credit grant events to wallet-service at /v1/events/billing-subscription-activated. The dispatcher retries failed deliveries according to BILLING_EVENT_OUTBOX_* runtime settings while keeping public wallet/subscription routes legacy-proxied.

Set MONETIZATION_EVENT_TRANSPORT=nats or BILLING_EVENT_TRANSPORT=nats to switch the billing outbox dispatcher to NATS JetStream. The publisher uses NATS_URL or MONETIZATION_NATS_URL, stream MONETIZATION by default, subject monetization.billing.entitlements.updated.v1 for entitlement snapshots, and monetization.billing.subscription.activated.v1 for subscription activation credits. The billing outbox event id is the broker message id. Keep the default http mode until broker live smoke evidence, route rehearsal, and rollback notes exist.

Operator replay/dead-letter handling uses tools/monetization-outbox/cmd/monetization-outbox. The billing outbox event id is also the outbox row id and JetStream message id. list, list-dead, and inspect read only the billing-service database; replay --confirm resets the billing outbox row to PENDING and lets the existing dispatcher republish the same event through HTTP fallback or NATS.

Zero-price subscription creation, including seeded FREE_TRIAL, immediately marks the subscription active and invoice paid, then stores and dispatches a single billing.entitlements.updated.v1 row for usage-service. It does not emit billing.subscription.activated.v1 because wallet subscription-credit grants remain tied to payment evidence.

When AUDIT_SERVICE_URL or AUDIT_SERVICE_BASE_URL is configured, billing state events are also posted to audit-service /v1/audit-events best-effort with category=DOMAIN, severity=INFO, outcome=SUCCESS, metadataSchemaVersion=1, retentionPolicy=billing-events, and createdAt -> occurredAt. The audit producer is intentionally separate from the usage/wallet outbox so audit failures do not cause downstream delivery retries.

POST /v1/subscriptions/{subscriptionId}/renewal-preview is non-mutating. It returns the next full-month renewal period, commercial amount, entitlement snapshot, and current billing policy notes. prorationBehavior must be NONE; the route does not create invoices, billing events, wallet ledger entries, or usage counters. Paid renewals preview an OPEN invoice and require later payment-backed activation before wallet-service receives subscription-credit grant events.

POST /v1/subscriptions/{subscriptionId}/renew applies that no-proration policy as a service-local mutation. It is idempotent by body/header key, creates a renewal invoice with periodStartedAt/periodEndsAt, and rejects duplicate renewal attempts while the latest invoice is still OPEN or NEEDS_REVIEW. Paid renewals do not advance the subscription period or emit usage/wallet outbox events until payment-service sends a paid event for the renewal invoice. Zero-price renewals advance the period immediately, mark the invoice PAID, and emit one billing.entitlements.updated.v1 event for usage-service; they still do not emit billing.subscription.activated.v1 wallet-credit events.

Verification

bash
GOTOOLCHAIN=go1.25.11 go test ./services/billing-service/... -count=1
GOTOOLCHAIN=go1.25.11 go test ./services/billing-service/internal/eventfanout -count=1

Optional Postgres repository integration:

bash
BILLING_SERVICE_POSTGRES_TEST_DATABASE_URL='postgres://hoctapaz:hoctapaz@localhost:5433/hoctapaz_platform?sslmode=disable' \
  GOTOOLCHAIN=go1.25.11 go test ./services/billing-service/internal/repository \
  -run TestPostgresBillingSubscriptionReplayIntegration -count=1 -v

Go-platform documentation is generated from repository Markdown.