bus-integration-billing — billing integration

bus-integration-billing — billing integration

bus-integration-billing owns provider-neutral billing business logic. It tracks generic billing state and feature entitlements, creates hosted setup and portal sessions through a provider boundary, exports billable usage to provider meters, and returns account-safe billing decisions through Bus Events.

Hosted setup and portal sessions can be local deterministic responses or provider event request/reply flows. In event mode, this integration publishes configured provider-specific events and waits for correlated responses without importing provider SDKs or provider-specific packages.

Production deployments should use BUS_BILLING_STORE_BACKEND=postgres and BUS_BILLING_DATABASE_URL so account subscription and entitlement state is durable. Memory storage is for local development.

The PostgreSQL store contains the operational billing state used by Bus API providers: account subscription status, enabled feature scopes, provider customer and subscription identifiers, idempotently processed provider events, usage export state, and quota usage buckets. It is not a replacement for the payment provider ledger or invoice system.

Plan quotas are provider-neutral and loaded by the billing integration from a quota config/catalog file. Each plan can define multiple simultaneous windows for the same feature and meter, for example per-minute and per-month token limits for llm:proxy. Supported windows are minute, hour, day, week, month, and total. If any matching quota is exhausted, entitlement checks return quota_exceeded and upgrade guidance before billable API work starts.

Run The Worker

Run a deterministic local check with:

bus-integration-billing --self-test

For an Events-backed local worker with durable storage, start a reachable Bus Events API first and create the PostgreSQL database/user referenced by BUS_BILLING_DATABASE_URL. Then create the local service token with the same HS256 secret trusted by the Events API:

mkdir -p ./local
BUS_AUTH_HS256_SECRET=dev-secret \
bus operator token --format token issue --local \
  --subject billing-worker \
  --audience ai.hg.fi/api \
  --scope "billing:read billing:setup billing:entitlement:check billing:subscription:write billing:usage:export billing:provider" \
  --ttl 1h > ./local/billing-worker.token

export BUS_EVENTS_API_URL=http://127.0.0.1:8081
export BUS_API_TOKEN="$(cat ./local/billing-worker.token)"
BUS_BILLING_DATABASE_URL='postgres://bus:bus@127.0.0.1:5432/bus_billing?sslmode=disable' \
bus-integration-billing \
  --events-url "$BUS_EVENTS_API_URL" \
  --store-backend postgres \
  --provider-backend local

The service token needs the billing event scopes used by the enabled features, typically billing:read, billing:setup, billing:entitlement:check, billing:subscription:write, billing:usage:export, and billing:provider when provider-backed checkout, portal, or meter events are enabled. The worker is connected when it stays running without startup errors and the Events API accepts billing request streams for that token. With PostgreSQL storage, missing or invalid BUS_BILLING_DATABASE_URL fails startup instead of falling back to memory.

Events

Every request/reply event uses the Bus Events envelope correlation_id. Responses copy the request correlation identifier so callers can match the reply to the request. If processing fails, the response event carries the same correlation identifier and an event error such as billing_unavailable with a human-readable message. Provider-backed calls use the configured provider timeout; timeout failures are returned as billing errors instead of silently hanging.

bus.billing.status.request asks for one account’s billing status. Payload:

{"account_id":"account-uuid"}

account_id is required. The worker returns billing_unavailable when the store cannot read the account. Unknown accounts return a normal missing-status response rather than an event error.

bus.billing.status.response returns status, enabled features, quota usage, and setup or upgrade guidance. Payload:

{
  "account_id": "account-uuid",
  "status": "active",
  "provider": "stripe",
  "plan_id": "llm-pro",
  "features": ["llm:proxy"],
  "usage": [],
  "setup_required": false
}

status is one of missing, incomplete, active, past_due, or canceled. When setup is required, setup_required=true, next_action is setup_billing, and command is usually bus billing setup.

usage is omitted or empty when the account has no quota policy. Each usage item has feature, meter_event_name, window, used, limit, remaining, exceeded, and optional upgrade_plan_id. used, limit, and remaining are integer meter units such as tokens or runtime seconds. If any usage item is exceeded, upgrade_required=true, setup_required=false, next_action=upgrade_plan, recommended_plan is copied from upgrade_plan_id when configured, and command remains the user-facing setup or upgrade command. Optional fields are omitted when they do not apply.

bus.billing.checkout_session.request asks for a hosted billing setup URL. Payload:

{"account_id":"account-uuid","feature":"llm:proxy","return_url":"https://app.example.test/billing/return"}

account_id is required. feature and return_url are optional. In event-backed mode, checkout requests trigger a provider checkout request such as bus.stripe.checkout_session.create.request.

bus.billing.checkout_session.response returns the setup URL and provider name. Payload:

{"url":"https://provider.example.test/checkout/session","provider":"stripe"}

url is required in provider responses. If a provider response omits provider, the billing worker fills it from the configured provider label.

bus.billing.portal_session.request asks for a hosted billing portal URL. Payload:

{"account_id":"account-uuid","return_url":"https://app.example.test/billing/return"}

account_id is required. return_url is optional. In event-backed mode, portal requests trigger a provider portal request such as bus.stripe.portal_session.create.request.

bus.billing.portal_session.response returns the portal URL and provider name. Payload:

{"url":"https://provider.example.test/customer/portal","provider":"stripe"}

url is required in provider responses. If a provider response omits provider, the billing worker fills it from the configured provider label.

bus.billing.entitlement.check.request asks whether an account may receive a billable feature scope. Payload:

{"account_id":"account-uuid","scope":"llm:proxy"}

account_id and scope are required.

bus.billing.entitlement.check.response returns an allow/deny decision. Denied paid-feature access uses the provider-neutral billing_required reason and bus billing setup command. Exhausted plan quotas use quota_exceeded and include the exhausted quota window. Payload:

{
  "allowed": false,
  "reason": "quota_exceeded",
  "command": "bus billing setup",
  "plan_id": "llm-basic",
  "recommended_plan": "llm-pro",
  "usage": {
    "feature": "llm:proxy",
    "meter_event_name": "bus_llm_tokens",
    "window": "month",
    "used": 1000000,
    "limit": 1000000,
    "remaining": 0,
    "exceeded": true,
    "upgrade_plan_id": "llm-pro"
  }
}

Allowed responses use allowed=true and reason=billing_active. Missing or inactive billing uses reason=billing_required. Quota denial responses use the optional usage object to identify the exhausted quota; the quota window is usage.window.

bus.billing.subscription.update applies a provider-neutral subscription update from a payment provider integration. It is idempotent by event_id, enables listed features only when status is active, and disables paid-feature access for past_due, canceled, incomplete, or unknown statuses. Payload:

{
  "event_id": "provider-event-id",
  "account_id": "account-uuid",
  "provider": "stripe",
  "provider_customer_id": "cus_123",
  "provider_subscription_id": "sub_123",
  "plan_id": "llm-pro",
  "status": "active",
  "features": ["llm:proxy"]
}

event_id, account_id, provider, and status are required. Duplicate event_id values are accepted but do not reapply the update.

bus.billing.subscription.result returns the correlated application result. Payload:

{"account_id":"account-uuid","status":"active","applied":true}

applied=false means the update was a duplicate or otherwise did not change stored billing state.

bus.billing.usage.export.request asks the billing domain to export one provider-neutral usage metric to a payment meter. Requests identify account_id, billable feature, meter_event_name, positive integer quantity, and an idempotency event_id. bus-integration-usage can emit these requests automatically from canonical usage records; the first built-in usage mappings are LLM tokens and successful container runtime seconds. If feature or meter is omitted, the billing worker keeps the LLM-compatible defaults llm:proxy and bus_llm_tokens. Payload:

{
  "event_id": "usage-event-id",
  "account_id": "account-uuid",
  "event_type": "llm_request_finished",
  "feature": "llm:proxy",
  "meter_event_name": "bus_llm_tokens",
  "quantity": 1200,
  "data": {
    "total_tokens": 1200
  }
}

account_id is required. quantity must be a positive integer after defaults and LLM token derivation are applied. event_id is the preferred idempotency key. If it is omitted, the worker derives a deterministic key from account, meter, and quantity; callers should still send stable event IDs to avoid collisions between separate equal-sized usage records.

data is an optional object for raw usage fields. If quantity is omitted, LLM quantity is derived from data.total_tokens, data.tokens, data.input_tokens + data.output_tokens, or data.prompt_tokens + data.completion_tokens, in that order. Numeric values must decode as integers or JSON numbers. Container usage should normally send quantity directly in runtime seconds because no container-specific derived field is currently read from data.

event_type is optional. It is a provider-neutral source label such as llm_request_finished or container_run_finished; the billing worker accepts it for audit and troubleshooting context but does not use it as the idempotency key, meter event name, token derivation input, or quota selector. Those decisions come from event_id, account_id, feature, meter_event_name, quantity, and data.

bus.billing.usage.export.response returns export status, quantity, meter name, idempotency key, and provider event metadata. Successfully exported idempotency keys are not sent to the provider again. Failed exports are stored as failed and remain retryable. Payload:

{
  "account_id": "account-uuid",
  "meter_event_name": "bus_llm_tokens",
  "quantity": 1200,
  "idempotency_key": "usage-event-id",
  "status": "exported",
  "exported": true,
  "provider": "stripe",
  "provider_event_id": "meter-event-id"
}

exported=true means the provider accepted the meter event or the local provider accepted the record. A failed export includes exported=false, status=failed, and reason.

In event-backed mode, usage export triggers a provider meter event such as bus.stripe.meter_event.record.request. Successful usage exports increment matching quota buckets once per idempotency key; duplicate export requests do not double-count quota usage.

Provider Backend Configuration

BUS_BILLING_QUOTA_CONFIG points to the provider-neutral JSON quota configuration used for plan enforcement.

Setting Required Default Valid values Failure behavior
BUS_EVENTS_API_URL or --events-url Required for event-listener mode. Not required for --self-test. Empty. Bus Events API base URL, without /api/v1/events; clients append event paths. Missing or unreachable URL prevents the worker from receiving billing requests.
BUS_API_TOKEN Required when connecting to a secured Events API. Not required for in-memory self-test. Empty. Bus API token with scopes such as billing:read, billing:setup, billing:entitlement:check, billing:subscription:write, billing:usage:export, and billing:provider according to enabled provider flows. Missing, expired, or underscoped tokens produce Events API authentication or authorization failures.
BUS_BILLING_PROVIDER_BACKEND or --provider-backend Optional. local. local, events. Unsupported values fail startup. Production payment-provider routing should use events; local returns deterministic local sessions and meter acceptance.
BUS_BILLING_PROVIDER or --provider Optional for local; required operationally for events when provider labels matter. stripe. Provider label such as stripe. Used in provider event payloads and responses; mismatched labels can route requests to the wrong provider integration.
BUS_BILLING_PROVIDER_TIMEOUT or --provider-timeout Optional. 30s. Go duration such as 5s, 30s, or 2m; integer environment values are seconds. Provider request/reply calls return timeout errors after this duration.
BUS_BILLING_STORE_BACKEND or --store-backend Optional. memory. memory, postgres. Unknown backends fail readiness with an unsupported-backend error.
BUS_BILLING_DATABASE_URL Required when BUS_BILLING_STORE_BACKEND=postgres. Ignored by memory. Empty. PostgreSQL connection URL. Missing value with postgres makes storage unavailable and the worker exits on readiness.
BUS_BILLING_QUOTA_CONFIG or --quota-config Optional. Required only when the deployment enforces quotas. Empty, meaning no quota rules. Path to a JSON quota policy file. Missing file, invalid JSON, duplicate quota rules, unsupported windows, or non-positive limits make startup fail.

Use BUS_BILLING_PROVIDER_BACKEND=local for deterministic local responses. Use BUS_BILLING_PROVIDER_BACKEND=events when hosted setup, portal, and meter recording should be delegated to another provider integration such as bus-integration-stripe.

Use BUS_BILLING_STORE_BACKEND=memory only for local development or single-process checks. Use BUS_BILLING_STORE_BACKEND=postgres with BUS_BILLING_DATABASE_URL for durable account billing status, usage export idempotency, and quota buckets.

The default provider event names target the current Stripe integration. The mapping is configurable through the Go EventSessionProvider and EventMeterRecorder boundaries for future providers.

The BusDK superproject compose.yaml runs this worker as bus-billing-worker with --store-backend postgres, BUS_BILLING_DATABASE_URL, and --events-url http://bus-events:8081. The default local provider backend is local, which returns deterministic setup and portal responses. Set BUS_LOCAL_BILLING_PROVIDER_BACKEND=events in the superproject .env file or on the compose command line when the local stack should route checkout, portal, and meter calls through a provider integration such as bus-integration-stripe:

BUS_LOCAL_BILLING_PROVIDER_BACKEND=events docker compose up --build

Quota Configuration

A quota rule connects a Bus feature, a usage meter, a time window, and a limit. The same plan can have several rules for the same feature. For example, an LLM plan can limit both tokens per minute and tokens per month; a container plan can limit runtime seconds per day and per month. When any matching quota is exhausted, entitlement checks deny new billable work before the API provider starts expensive processing.

Common built-in meter names are bus_llm_tokens for LLM token usage and bus_container_runtime_seconds for successful container runtime. Additional meters can be added through the usage export policy in bus-integration-usage and corresponding plan quota rules.

Quota counters are updated when usage export succeeds. Replayed usage export requests with the same idempotency key do not increment quota buckets again.

The quota file has one top-level object with plans. Each plan has a required id and optional quotas. Each quota requires feature, meter_event_name, window, and limit. upgrade_plan_id is optional and is returned as upgrade guidance when the quota is exhausted.

Accepted window values are minute, hour, day, week, month, and total. Common aliases such as minutes, daily, weekly, monthly, lifetime, and all are normalized to those values. limit must be a positive integer. A plan cannot define two quotas with the same feature, meter_event_name, and window.

Minimal LLM and container quota file:

{
  "plans": [
    {
      "id": "starter",
      "quotas": [
        {
          "feature": "llm:proxy",
          "meter_event_name": "bus_llm_tokens",
          "window": "month",
          "limit": 1000000,
          "upgrade_plan_id": "pro"
        },
        {
          "feature": "container:run",
          "meter_event_name": "bus_container_runtime_seconds",
          "window": "day",
          "limit": 3600,
          "upgrade_plan_id": "pro"
        }
      ]
    }
  ]
}

Use it by setting:

export BUS_EVENTS_API_URL=http://127.0.0.1:8081
export BUS_API_TOKEN="$(cat ./local/billing-worker.token)"
export BUS_BILLING_QUOTA_CONFIG=/etc/bus/billing-quotas.json
export BUS_BILLING_DATABASE_URL='postgres://bus:bus@127.0.0.1:5432/bus_billing?sslmode=disable'
bus-integration-billing \
  --events-url "$BUS_EVENTS_API_URL" \
  --store-backend postgres \
  --provider-backend local \
  --quota-config "$BUS_BILLING_QUOTA_CONFIG"

Using from .bus files

Inside a .bus file, write the module target without the bus prefix:

# same as: bus integration billing --events-url "$BUS_EVENTS_API_URL"
integration billing --events-url "$BUS_EVENTS_API_URL" --store-backend memory

Production Flow

In a Stripe-backed deployment, the billing provider receives a user checkout request from bus-api-provider-billing, asks bus-integration-stripe to create the hosted Checkout Session, and later receives a provider-neutral bus.billing.subscription.update event from the Stripe webhook path. Once the subscription is active, entitlement checks allow the enabled feature scopes.

LLM and container API providers call the entitlement check before doing billable work. Accepted usage is recorded through bus-integration-usage, which can then emit usage export requests back to this billing worker. This keeps payment-provider details out of the API providers while still allowing plans to enforce limits for different usage metrics.