Skip to content

billing: no CLI path to PAYG credit purchases and their Stripe invoice IDs; /pay-as-you-go/billing/* is session-only #594

Description

@frantisekrehor

Context

There is no billing command group. rg -i "credits|payg|pay-as-you-go|billing" src/ tests/ returns 0 matches on 8d3aca6 (v0.84.1).

For a PAYG project, two different things live behind two different hosts:

Data Endpoint Host Storage token works?
Current balance GET /credits billing.{stack} yes
Purchase history + Stripe invoice IDs /pay-as-you-go/billing/* connection.{stack} no

The balance is reachable today and just has no command wrapping it. The purchase history is the part that has no token-reachable path at all, and that is the ask in this issue.

Why it matters

Anyone funding several PAYG projects on one billing identity has to answer "which project does this Stripe invoice belong to" for cost allocation. Stripe invoices for credit top-ups carry no project identifier, so the only workaround is matching invoices to projects by (date, amount) — which breaks as soon as two projects top up the same denomination on the same day.

Keboola already stores this mapping per project: the Billing Overview UI renders a "Latest Purchases" table with live pay.stripe.com/receipts/invoices/... links per row. The data exists and is already exposed per project — it just is not reachable with a project-scoped token, so no CLI, script, or scheduled agent can get at it.

(A related request to put the project ID on the Stripe invoice itself is SUPPORT-17365. This issue asks for something smaller and CLI-shaped: read access to the invoice ID Keboola already stores.)

Current state (verified on 8d3aca6, live API, north-europe.azure)

GET /credits on billing.{stack} works with a plain per-project Storage API token:

$ curl -H "X-StorageApi-Token: <redacted>" https://billing.north-europe.azure.keboola.com/credits
{"consumed":100.5,
 "stats":{"componentJobs":{"consumed":95.25},
          "workspaceJobs":[{"workspaceType":"sandbox-sql","warehouseSize":"small","consumed":5.0},
                           {"workspaceType":"writer","warehouseSize":"small","consumed":0.25}]},
 "remaining":25.5}

(values rounded; the shape is verbatim)

Note workspaceJobs is an array carrying consumed, not the object shown in the public docs — a model for this must be tolerant (extra="allow"), not strict.

The purchase history is not on the billing service at all. 13 candidate paths on billing.{stack} (/credits/history, /top-ups, /invoices, /transactions, …) all return 404 No route found. Reading the Billing Overview page's network traffic shows the UI calls connection.{stack} instead:

POST /pay-as-you-go/billing/credits
GET  /pay-as-you-go/billing/configuration
GET  /pay-as-you-go/billing/stripe-customer?idProject={id}
GET  /pay-as-you-go/billing/top-up/{id}

That group does not accept a Storage token:

Auth Result
X-StorageApi-Token: <storage token> 302/admin/auth/login/forwardTo/...
(no auth at all) 302 → same location, byte-identical
Authorization: Bearer <storage token> 401
Bearer + X-KBC-ProjectId: {id} 401

The Storage-token response being identical to the unauthenticated one means the header is not read. The 401 on the Bearer variants means something does read Authorization and rejects a Storage token there — so a session/OIDC bearer may well be accepted. That is the open question below.

Read with a browser session, for completeness: top-up/{id} returns the auto-top-up configuration (recharge amount, remaining recharge limit, enabled flag) and stripe-customer returns billing address plus card metadata. Neither is the purchase list, which leaves POST /pay-as-you-go/billing/credits as the remaining candidate — not probed here, since POST on a billing path can trigger a real top-up.

Service discovery: billing is in the stack service index on both stacks tested, and its URL matches what _derive_service_url(stack_url, "billing") (http_base.py:113) already produces — so this needs no new URL mechanism. Caveat: on eu-central-1 the index advertises billing.eu-central-1.keboola.com, but that host is NXDOMAIN. It correlates exactly with PAYG availability — across 16 projects checked, all 9 verifiable ones on north-europe.azure carry the pay-as-you-go feature and all 6 on eu-central-1 do not (a 10th azure project could not be checked, expired token). The eu-central-1 index carries use-different-stack-payg-wizard. So "billing host unreachable" should be reported as "PAYG not available on this stack", not as a generic network error.

Units: the API returns credits, the UI displays minutes — the balance from GET /credits and the auto-top-up amount both render in the UI multiplied by 60. So 1 credit = 60 minutes, and at the observed PAYG rate of $8.40 ext. VAT per credit, a purchase of 8 credits shows up as "480 minutes ($67.20)". A command should surface the API's native unit and derive minutes, not the reverse.

The ask

Primary — make credit purchases and their Stripe invoice IDs reachable with a project-scoped token.

The public docs describe a credit record shape that is exactly what is needed:

{"id": 259, "creditsAmount": 10, "moneyAmount": null,
 "idStripeInvoice": null, "description": "Promo credits",
 "created": "2019-06-17T15:07:48+01:00"}

but do not document a path serving it that a project token can reach. Concretely, either:

  1. such an endpoint already exists and is just undocumented — in which case, what is the path and what auth does it take; or
  2. /pay-as-you-go/billing/* accepts a kbc_at_* session bearer (from kbagent auth login), in which case the CLI can already reach it with the existing auth/ machinery and no new token surface; or
  3. neither, and it needs a new read endpoint on the billing service, e.g. GET /credits/purchases returning the records above for the token's project.

I could not distinguish (2) from (3) from the outside. Maintainers can answer it immediately, which is why this is an issue and not a PR.

Secondary — kbagent billing credits. GET /credits is already reachable, so this is implementable today independent of the above: multi-project parallel read by default, --project to narrow, --json. Useful on its own for anyone running more than one PAYG project, and it is the natural home for the history command once the primary ask is resolved. Happy to open a PR for this part now if the shape below is acceptable.

Proposed scope

  • New client/billing.py mixin on _CoreClient, URL via the existing _derive_service_url(stack_url, "billing"). No hardcoded hostnames.
  • BillingService + commands/billing.py, billing.credits registered read in OPERATION_REGISTRY, matching route in server/routers/billing.py per the 1:1 convention.
  • Tolerant Pydantic model (workspaceJobs as a list, extra="allow").
  • kbagent billing history once the primary ask lands: one row per purchase (created, creditsAmount, moneyAmount, idStripeInvoice, description) plus project alias in multi-project mode, --json shaped to join against a Stripe invoice export.

Explicitly out of scope

  • POST /credits must not be reachable from any command. It triggers an automatic top-up — real money — when the balance is ≤ 0. If it is ever exposed it is write at minimum, needs --dry-run / --yes, and belongs in its own PR with its own decision. Nothing here should make it callable by accident.
  • No Stripe API integration; invoice IDs are enough to reconcile downstream.
  • No new token or config mechanism — the per-project token store as it exists today.

Acceptance criteria

  • billing credits --json returns valid JSON for a PAYG project and fans out across all connected projects when --project is omitted.
  • Per-project failures degrade individually and never abort the run. Three real modes observed across 16 projects: PAYG/billing not available on the stack (6), invalid token 401 (1), success (9).
  • A non-PAYG project produces an actionable error naming the missing feature. This can be checked deterministically up front — owner.features from /v2/storage/tokens/verify carries pay-as-you-go, and _CoreClient already has has_feature() / _features_cache — rather than mapping an opaque billing 4xx.
  • make check, make typecheck, make check-error-codes, command-sync-check clean; service + CLI + E2E tests; doc surfaces per the Plugin synchronization map.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions