From 1e6db7a9dfc06d1ccaa4855669d2fd9ad03c056a Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 17 Aug 2026 16:27:44 -0400 Subject: [PATCH 1/4] feat(billing): add `kbagent billing credits` for PAYG balance (#594) Wraps `GET /credits` on the `billing.{stack}` host, which accepts a plain per-project Storage token -- the endpoint was reachable all along and simply had no command over it. Multi-project parallel fan-out by default, `--project` narrows, `--json` emits `{"credits": [...], "errors": [...]}`. Three details the API forces: - Units. The API speaks credits; the Keboola UI displays minutes = credits x 60. Rows carry the native unit AND derived minutes, and the conversion only ever runs in that direction, so a unit bug cannot invert into a wrong credit figure. - Shape. `stats.workspaceJobs` is an ARRAY in the live payload, not the object the public docs show, so the pydantic models are tolerant (`extra="allow"`, every field defaulted) rather than strict-against-the-docs. - Availability. A project without the `pay-as-you-go` flag in `owner.features` is gated out BEFORE any billing request and reported as the new `PAYG_NOT_AVAILABLE` code. This is deliberately not a mapped billing 4xx: on non-PAYG stacks the service index advertises a `billing.` host that does not resolve, so an ungated call surfaces a DNS failure instead of the real reason. Per-project failures degrade individually and never abort the run. Mirrored on `kbagent serve` as `GET /billing/credits`. Read-only by design: the billing service's `POST /credits` triggers a real automatic top-up (real money) and is wrapped by nothing in kbagent -- the client mixin exposes GET only, and there is a regression test guarding that. Not included, and tracked by #594's still-open primary ask: credit PURCHASE history and the Stripe invoice IDs Keboola already stores per project. That data lives on `connection.{stack}` under `/pay-as-you-go/billing/*`, which ignores `X-StorageApi-Token` entirely and rejects it as a bearer with 401, so no CLI, script, or scheduled agent can reach it today. --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 10 + docs/error-codes.md | 6 + plugins/kbagent/.claude-plugin/plugin.json | 2 +- plugins/kbagent/skills/kbagent/SKILL.md | 4 +- .../kbagent/references/billing-workflow.md | 146 +++++++ .../kbagent/references/commands-reference.md | 3 + .../skills/kbagent/references/gotchas.md | 36 ++ pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 28 ++ src/keboola_agent_cli/cli.py | 5 + src/keboola_agent_cli/client/_client.py | 10 +- src/keboola_agent_cli/client/_core.py | 22 ++ src/keboola_agent_cli/client/billing.py | 29 ++ src/keboola_agent_cli/commands/billing.py | 109 ++++++ src/keboola_agent_cli/commands/context.py | 19 + src/keboola_agent_cli/constants.py | 15 + src/keboola_agent_cli/errors.py | 4 + src/keboola_agent_cli/models.py | 59 +++ src/keboola_agent_cli/permissions.py | 2 + src/keboola_agent_cli/server/app.py | 12 + src/keboola_agent_cli/server/dependencies.py | 3 + .../server/routers/billing.py | 32 ++ .../services/billing_service.py | 169 ++++++++ tests/test_billing_cli.py | 155 ++++++++ tests/test_billing_client.py | 228 +++++++++++ tests/test_billing_service.py | 364 ++++++++++++++++++ tests/test_e2e.py | 97 +++++ tests/test_server_router_calls.py | 79 ++++ uv.lock | 2 +- 30 files changed, 1645 insertions(+), 9 deletions(-) create mode 100644 plugins/kbagent/skills/kbagent/references/billing-workflow.md create mode 100644 src/keboola_agent_cli/client/billing.py create mode 100644 src/keboola_agent_cli/commands/billing.py create mode 100644 src/keboola_agent_cli/server/routers/billing.py create mode 100644 src/keboola_agent_cli/services/billing_service.py create mode 100644 tests/test_billing_cli.py create mode 100644 tests/test_billing_client.py create mode 100644 tests/test_billing_service.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a8bb4beb..8857796f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.84.1", + "version": "0.85.0", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index 213843a0..4aa5310a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,6 +467,16 @@ kbagent sharing edges [--project NAME] kbagent org setup --org-id ID --url URL [--dry-run] [--yes] [--token-description PREFIX] [--refresh] kbagent org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes] [--token-description PREFIX] [--refresh] +kbagent billing credits [--project ALIAS ...] +# billing credits (0.85.0+, issue #594 secondary ask): read-only PAYG credit balance, fanned out +# across all registered projects in parallel by default (--project repeatable narrows). A project +# without the `pay-as-you-go` owner.features flag never touches the billing host (NXDOMAIN on some +# non-PAYG stacks) -- it gets a per-project error_code PAYG_NOT_AVAILABLE instead; per-project +# failures degrade individually, the run never aborts. Rows report the API's native unit (credits) +# AND derived minutes (1 credit = 60 min, matching the Keboola UI). Purchase history / Stripe +# invoice IDs are NOT available here -- that data lives on connection.{stack} +# /pay-as-you-go/billing/*, which does not accept a Storage token (issue #594 primary ask, open). + # feature: requires a super-admin Manage API token (inline hidden prompt; never persisted; --allow-env-manage-token for CI). --project resolves the stack URL (+ project_id for project ops) from config. kbagent feature list --project ALIAS kbagent feature project-show --project ALIAS diff --git a/docs/error-codes.md b/docs/error-codes.md index e6d4fba0..0981a388 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -183,3 +183,9 @@ of `ErrorCode` in `src/keboola_agent_cli/errors.py`. | `SESSION_EXPIRED` | The programmatic-auth session's refresh token expired or was revoked; run `kbagent auth login` again | | `SESSION_NOT_FOUND` | No programmatic-auth session is persisted for this stack; run `kbagent auth login` | | `AUTH_MFA_INVALID` | `auth login-password` hit an MFA factor it cannot resolve without a browser (e.g. WebAuthn-only) -- use `kbagent auth login` for that account instead | + +### Billing (Pay-As-You-Go) + +| Code | Description | +|---|---| +| `PAYG_NOT_AVAILABLE` | The project does not have the `pay-as-you-go` feature, so it has no credit balance; the billing host may not even resolve on this stack | diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index ce767427..f5efaf25 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.84.1", + "version": "0.85.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index d47c380e..a61a2c90 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -15,7 +15,7 @@ description: > data stream, OTLP, scoped token, bucket sharing, encrypt secrets, feature flag, flow schedule, invite member, SQL transformation edit, sync action, keboola docs, table snapshot, auth, login, sign in, - browser login. + browser login, PAYG credits. --- # kbagent -- Keboola Agent CLI @@ -101,6 +101,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Mint a scoped Storage API token (secret shown once) | `kbagent token create --project PROJECT --description DESCRIPTION` | | Revoke a Storage API token immediately (destructive; only non-master tokens) | `kbagent token delete --project PROJECT --token-id TOKEN-ID` | | Rotate a token: generate a new value and invalidate the old one (secret shown once) | `kbagent token refresh --project PROJECT --token-id TOKEN-ID` | +| Show the current PAYG credit balance for one or more projects | `kbagent billing credits` | | List available components from connected projects | `kbagent component list` | | Show detailed information about a specific component | `kbagent component detail --component-id COMPONENT-ID` | | Run a synchronous component action such as testConnection | `kbagent component sync-action --component-id COMPONENT-ID --project PROJECT` | @@ -395,6 +396,7 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | **Typify a typeless table** (profile -> CTAS -> swap-tables -> validate -> handoff) | [typify-table-workflow](references/typify-table-workflow.md) | | Bucket sharing & linking | [sharing-workflow](references/sharing-workflow.md) | | **Project members & invitations** (single + bulk via CSV, role change, remove) | [member-workflow](references/member-workflow.md) | +| **Billing / PAYG credits** (balance only; the shape of the invoice-history gap; PAYG_NOT_AVAILABLE; units) | [billing-workflow](references/billing-workflow.md) | | Dev branches | [branch-workflow](references/branch-workflow.md) | | Encrypting secrets for MCP tools | [encrypt-workflow](references/encrypt-workflow.md) | | Sync & Git-branching (GitOps) | [sync-workflow](references/sync-workflow.md) | diff --git a/plugins/kbagent/skills/kbagent/references/billing-workflow.md b/plugins/kbagent/skills/kbagent/references/billing-workflow.md new file mode 100644 index 00000000..c67337fb --- /dev/null +++ b/plugins/kbagent/skills/kbagent/references/billing-workflow.md @@ -0,0 +1,146 @@ +# Billing (PAYG Credits) workflow + +> Audience: a developer or a kbagent agent asked about Keboola PAYG (pay-as- +> you-go) usage, balance, or invoices. Goal: know exactly what `kbagent +> billing` can and cannot answer *before* burning a loop hunting for a +> command that does not exist. (since v0.85.0; issue +> https://github.com/keboola/cli/issues/594) + +## The shape of the gap (read this first) + +`kbagent billing` is **one command**: `billing credits`. It reports the +*current PAYG balance*, nothing more. There is **no** `billing history`, +`billing invoices`, or anything that returns `idStripeInvoice` — and there is +no way to build one from the CLI today. If a user asks to "reconcile our +Stripe invoices against Keboola projects" or "when did we last top up," the +correct answer is **that data is not reachable from kbagent** (see +[What is NOT reachable](#what-is-not-reachable-and-why) below) — not a +workaround, not a guess, and not a hand-rolled HTTP call. + +## What IS reachable today + +```bash +kbagent --json billing credits --project prod --project staging +``` + +```json +{ + "credits": [ + { + "project_alias": "prod", + "project_id": 9621, + "consumed": 12.5, + "remaining": 25.5, + "purchased": 38.0, + "consumed_minutes": 750.0, + "remaining_minutes": 1530.0, + "component_jobs_consumed": 11.75, + "workspace_jobs": [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 0.5}, + {"workspace_type": "writer", "warehouse_size": "small", "consumed": 0.25} + ] + }, + { + "project_alias": "staging", + "project_id": null, + "consumed": 0.0, + "remaining": 0.0, + "purchased": 0.0, + "consumed_minutes": 0.0, + "remaining_minutes": 0.0, + "component_jobs_consumed": 0.0, + "workspace_jobs": [] + } + ], + "errors": [] +} +``` + +- `--project ALIAS` is repeatable; omit it to fan out across every registered + project in parallel. +- `consumed` / `remaining` come straight off `GET /credits` on the + `billing.{stack}` host, which -- unlike the invoice endpoints below -- + **does** accept the CLI's normal per-project `X-StorageApi-Token`. No + manage token, no extra login step. +- `purchased` is a client-side convenience: `consumed + remaining`. +- Per-project failures land in `errors`, never abort the run -- always check + both arrays, not just `credits`. + +## The units trap + +The API speaks **credits**. The Keboola UI speaks **minutes**. The +conversion is fixed: **1 credit = 60 minutes**. Every row already carries +both — never hand-convert, and never convert in the other direction (minutes +-> credits) on a value that already came from the CLI. + +``` +consumed_minutes = consumed * 60 +remaining_minutes = remaining * 60 +``` + +Money is a separate axis again: PAYG credits are purchased at a fixed rate +per stack/contract (observed in issue #594: **$8.40 ex. VAT per credit**). A +purchase of 8 credits is what the Keboola UI shows as **"480 minutes +($67.20)"** — the CLI has no price field; if a user needs the dollar amount, +that comes from their contract/invoice, not from `billing credits`. + +## The PAYG gate + +Not every project is PAYG. Before `billing credits` ever calls the billing +host, it checks the `pay-as-you-go` flag in the project token's +`owner.features` (`GET /v2/storage/tokens/verify`). A project without that +flag gets a per-project entry: + +```json +{"project_alias": "legacy-project", "error_code": "PAYG_NOT_AVAILABLE", + "message": "Project 'legacy-project' does not have the pay-as-you-go feature enabled; PAYG balance is only available on PAYG projects."} +``` + +**This is a feature-flag verdict, not a network failure.** On a non-PAYG +stack (e.g. plain `eu-central-1`) the service index still *advertises* a +`billing.eu-central-1.keboola.com` host, but it does not resolve (NXDOMAIN) +— the feature check exists specifically so a non-PAYG project never dials +that host at all. Do not treat `PAYG_NOT_AVAILABLE` as something to retry or +debug as connectivity; it means "this project has no PAYG balance to show." + +## What is NOT reachable, and why + +Purchase history and `idStripeInvoice` live on `connection.{stack}` under +`/pay-as-you-go/billing/*` — a **completely different host and API surface** +from `billing.{stack}/credits`. That endpoint does not accept a Storage API +token: a request with `X-StorageApi-Token` gets the byte-identical +302-to-login response as an unauthenticated request, and presenting the +token as a bearer credential gets a plain 401. There is no +project-token-based path to it, so there is nothing for `kbagent billing` to +wrap. + +This is the **still-open primary ask of issue #594**: +https://github.com/keboola/cli/issues/594 — link it verbatim when a user +asks about invoice access so they can track the maintainer's answer. + +**Do not improvise a substitute.** In particular: + +- Do not attempt to reach `/pay-as-you-go/billing/*` with `kbagent http`, a + raw `httpx`/`curl` call, or by asking the user to paste a manage/session + token for it — none of those change what the endpoint accepts. +- Do not fall back to matching invoices to projects by `(date, amount)` + heuristics. It silently breaks the moment two projects top up the same + credit amount on the same day, and produces a wrong-but-confident answer + instead of an honest "not available." +- If asked to reconcile invoices, tell the user directly: kbagent can report + the current balance (`billing credits`) but not purchase history; that + reconciliation needs the Stripe/billing portal directly until #594 lands. + +## The money guardrail + +`POST /credits` on the billing service triggers a **real automatic top-up** +— actual money is charged. It is deliberately not wrapped by any `kbagent` +command, CLI or REST. Never reach for it, and never construct a raw HTTP +call to it (via `kbagent http`, a manual `httpx` request, or otherwise) even +if a user asks "just top up my credits" — that action requires going through +Keboola's own billing UI, not an agent-driven CLI. + +## Permission class + +`billing.credits` = read. Safe to run under `--deny-writes` / +`--deny-destructive`; it makes no mutating calls. diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 23061c75..5630e821 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -102,6 +102,9 @@ The `permissions` subcommands persist a write/destructive policy to config.json - `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org (org admin; manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) - `org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes]` -- onboard specific projects by ID (any project member; manage token / Personal Access Token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) +## Billing (PAYG Credits) (since v0.85.0) +- `billing credits [--project ALIAS ...]` -- read-only PAYG credit balance (`GET /credits` on `billing.{stack}`, plain Storage token). Fans out across all registered projects in parallel by default; `--project` (repeatable) narrows. Per-project failures degrade individually and are collected in `errors`, never abort the run. A project without the `pay-as-you-go` `owner.features` flag never calls the billing host (NXDOMAIN on some non-PAYG stacks) -- it gets an `error_code: PAYG_NOT_AVAILABLE` entry instead. `--json` emits `{"credits": [...], "errors": [...]}`. Rows carry the API's native unit (`consumed`/`remaining` credits) plus derived `*_minutes` fields (1 credit = 60 minutes, matching the Keboola UI). Gives the current balance only -- purchase history / Stripe invoice IDs are not reachable with a project token (issue #594 primary ask, still open; that data lives on `connection.{stack}` `/pay-as-you-go/billing/*`). See [billing-workflow.md](billing-workflow.md) for the full shape of the invoice-history gap and why it must not be worked around. + ## Feature Flags (since v0.48.0) Requires a **super-admin** Manage API token (same kind as `org setup`). Same default-deny token policy: interactive hidden prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI. `--project ALIAS` resolves the stack URL (and, for project ops, the numeric `project_id`) from config -- the alias is the only handle you pass. - `feature list --project ALIAS` -- the stack-wide feature catalogue (`GET /manage/features`). Returns `{alias, stack_url, features: [{name, title, description, type, ...}]}`. Only `name` is a stable identifier; extra fields pass through unmodified. diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 9612ddfd..3d879792 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -3564,3 +3564,39 @@ machine's locale, and tolerates files that are not valid UTF-8. --value-file`, and `semantic-layer reference-data set --members-file` read UTF-8 without a fallback, so a genuinely mis-encoded input file fails loudly and identically everywhere rather than being silently mis-parsed. + +## `billing credits`: credits vs. minutes, array not object, feature-gated before it ever hits the network (since v0.85.0) + +`billing credits [--project ALIAS ...]` is a read-only PAYG (pay-as-you-go) +balance check (`GET /credits` on `billing.{stack}`, plain Storage token). +Four things a coding agent will otherwise get wrong: + +- **The API speaks credits; the Keboola UI speaks minutes.** `consumed` and + `remaining` in the raw response are in PAYG credits. The CLI derives + `consumed_minutes` / `remaining_minutes` as `credits * 60` + (`MINUTES_PER_CREDIT` in `constants.py`) because that is the unit the UI + actually displays. Always report the pre-computed `*_minutes` fields when a + user asks "how many minutes do I have left" -- do not hand-multiply the + credit fields yourself, and never assume the reverse conversion. +- **`stats.workspaceJobs` is an ARRAY, not the object shape the public + Keboola docs show.** The real payload is + `"workspaceJobs": [{"workspaceType": "sandbox-sql", "warehouseSize": + "small", "consumed": 5.0}, ...]` -- one entry per workspace type/size + combination, not a single object keyed by type. The CLI's `row.workspace_jobs` + is already this list, projected to snake_case; do not try to read it as a + dict. +- **`PAYG_NOT_AVAILABLE` is a feature-flag verdict, not a network failure.** + Before calling the billing host at all, the service checks the + `pay-as-you-go` flag in `owner.features` (`client.has_feature(PAYG_FEATURE)`). + A project without that flag never makes a billing-host request -- on some + stacks (e.g. a plain `eu-central-1` project) the `billing.{stack}` hostname + does not even resolve (NXDOMAIN). Seeing `error_code: PAYG_NOT_AVAILABLE` + in a project's `errors` entry means "this project isn't on PAYG," not "the + billing service is down." A genuine connection/DNS failure past the + feature gate is reported separately and mentions the host is unreachable. +- **Balance only -- no purchase history, no invoice IDs.** `billing credits` + cannot answer "when did we last top up" or "what's our Stripe invoice ID." + That data lives on `connection.{stack}` `/pay-as-you-go/billing/*`, which + does not accept a Storage API token -- it is the still-open primary ask of + issue #594. Do not imply this command covers billing/invoice history; tell + the user it is out of reach from the CLI today. diff --git a/pyproject.toml b/pyproject.toml index cb478a1a..44b894a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.84.1" +version = "0.85.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 64816248..784f009e 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,34 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.85.0": [ + "New (#594): `kbagent billing credits [--project ALIAS ...]` reads the Pay-As-You-Go " + "credit balance, fanned out across every registered project in parallel. Wraps `GET " + "/credits` on the `billing.{stack}` host, which accepts a plain per-project Storage " + "token -- the endpoint was reachable all along and simply had no command over it. Rows " + "carry the API's native unit (credits: `consumed` / `remaining` / derived `purchased`) " + "AND derived minutes, because the Keboola UI displays minutes = credits x 60; the " + "conversion only ever runs in that direction, so a unit bug cannot invert into a wrong " + "credit figure. The per-workspace breakdown is parsed tolerantly: the live API returns " + "`stats.workspaceJobs` as an ARRAY, not the object its public docs show. A project " + "without the `pay-as-you-go` flag in `owner.features` is gated out BEFORE any billing " + "request and reported as the new `PAYG_NOT_AVAILABLE` error code -- deliberately not a " + "mapped 4xx, because on non-PAYG stacks the service index advertises a `billing.` host " + "that does not resolve at all, so an ungated call would surface a DNS failure instead " + "of the real reason. Per-project failures degrade individually and never abort the " + "run. Mirrored on `kbagent serve` as `GET /billing/credits`. Read-only by design: the " + "billing service's `POST /credits` triggers a real automatic top-up (real money) and " + "is wrapped by nothing in kbagent.", + "Note (#594): credit PURCHASE history and the Stripe invoice IDs Keboola already " + "stores per project remain unreachable from the CLI. That data lives on " + "`connection.{stack}` under `/pay-as-you-go/billing/*`, which ignores " + "`X-StorageApi-Token` entirely (a Storage-token request returns the byte-identical " + "302-to-login as an unauthenticated one) and rejects it as a bearer with 401 -- so no " + "CLI, script, or scheduled agent can reach it, and anyone funding several PAYG " + 'projects on one billing identity still cannot answer "which project does this ' + 'Stripe invoice belong to" without matching on (date, amount). Issue #594 tracks the ' + "ask; `billing credits` is the half that was already implementable.", + ], "0.84.1": [ "Fix: `kbagent config new --push` schema validation now validates the body's " "`parameters` section instead of the whole configuration object (closes #587). A " diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 94cb6a71..c72f43c0 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -8,6 +8,7 @@ from .commands.agent import agent_app from .commands.auth import auth_app +from .commands.billing import billing_app from .commands.branch import branch_app from .commands.changelog import changelog_command from .commands.component import component_app @@ -50,6 +51,7 @@ from .permissions import PermissionEngine from .services.agent_service import AgentService from .services.auth_service import AuthService +from .services.billing_service import BillingService from .services.branch_service import BranchService from .services.component_service import ComponentService from .services.config_service import ConfigService @@ -112,6 +114,7 @@ app.add_typer(org_app, name="org", rich_help_panel=_PROJ) app.add_typer(feature_app, name="feature", rich_help_panel=_PROJ) app.add_typer(token_app, name="token", rich_help_panel=_PROJ) +app.add_typer(billing_app, name="billing", rich_help_panel=_PROJ) # -- Browse & Inspect -- _BROWSE = "Browse & Inspect" @@ -355,6 +358,7 @@ def main( http_forwarder_service = HttpForwarderService() agent_service = AgentService(config_store=config_store, mcp_service=mcp_service) auth_service = AuthService(config_store=config_store) + billing_service = BillingService(config_store=config_store) try: config = config_store.load() @@ -414,6 +418,7 @@ def main( ctx.obj["http_forwarder_service"] = http_forwarder_service ctx.obj["agent_service"] = agent_service ctx.obj["auth_service"] = auth_service + ctx.obj["billing_service"] = billing_service # Warn if empty local config shadows global with projects (#104) if source == "local" and not json_output and ctx.invoked_subcommand != "init": diff --git a/src/keboola_agent_cli/client/_client.py b/src/keboola_agent_cli/client/_client.py index 7a64f716..ce753950 100644 --- a/src/keboola_agent_cli/client/_client.py +++ b/src/keboola_agent_cli/client/_client.py @@ -2,10 +2,10 @@ ``KeboolaClient`` is assembled here from the per-family mixins (storage tables, storage files, configs, queue, tokens, branches, stream, query, workspaces, -misc) over the shared ``_CoreClient`` plumbing base. It stays a single class -exposing every Storage/Queue method at its original signature, so -``keboola_agent_cli.Client`` and its ``.raw`` accessor are unaffected by the -split of the former single-file ``client.py`` into a package (issue #520). +billing, misc) over the shared ``_CoreClient`` plumbing base. It stays a +single class exposing every Storage/Queue method at its original signature, +so ``keboola_agent_cli.Client`` and its ``.raw`` accessor are unaffected by +the split of the former single-file ``client.py`` into a package (issue #520). Inherits shared retry/error logic from BaseHttpClient (via _CoreClient). """ @@ -13,6 +13,7 @@ import httpx from ._core import _CoreClient +from .billing import _BillingMixin from .branches import _BranchesMixin from .configs import _ConfigsMixin from .misc import _MiscMixin @@ -35,6 +36,7 @@ class KeboolaClient( _StreamMixin, _QueryMixin, _WorkspacesMixin, + _BillingMixin, _MiscMixin, _CoreClient, ): diff --git a/src/keboola_agent_cli/client/_core.py b/src/keboola_agent_cli/client/_core.py index 8d09565d..58702eae 100644 --- a/src/keboola_agent_cli/client/_core.py +++ b/src/keboola_agent_cli/client/_core.py @@ -45,6 +45,7 @@ def __init__(self, stack_url: str, token: str, *, http_auth: httpx.Auth | None = self._query_client: httpx.Client | None = None self._encrypt_client: httpx.Client | None = None self._sync_actions_client: httpx.Client | None = None + self._billing_client: httpx.Client | None = None # Lazily built on first Data Streams call (per-device OTLP sources); the # Stream control plane is a sibling host reachable from this stack+token. self._stream_client: StreamClient | None = None @@ -71,6 +72,10 @@ def _encrypt_base_url(self) -> str: def _sync_actions_base_url(self) -> str: return self._derive_service_url(self._stack_url, "sync-actions") + @property + def _billing_base_url(self) -> str: + return self._derive_service_url(self._stack_url, "billing") + def close(self) -> None: """Close the underlying HTTP clients.""" super().close() @@ -82,6 +87,8 @@ def close(self) -> None: self._encrypt_client.close() if self._sync_actions_client is not None: self._sync_actions_client.close() + if self._billing_client is not None: + self._billing_client.close() if self._stream_client is not None: self._stream_client.close() @@ -157,6 +164,21 @@ def _sync_actions_request(self, method: str, path: str, **kwargs: Any) -> httpx. method, path, client=client, base_url=self._sync_actions_base_url, **kwargs ) + def _billing_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + """Execute a Billing API request with retry. + + The billing service is a sibling host derived from the stack URL + (``billing.{stack-suffix}``); the sub-client inherits the main + client's headers, so the ``X-StorageApi-Token`` auth carries over. On + stacks without Pay-As-You-Go the host may not resolve at all (DNS + failure) -- callers should feature-gate with ``has_feature()`` before + reaching this method rather than relying on the resulting error. + """ + client = self._get_or_create_sub_client("_billing_client", self._billing_base_url) + return self._do_request( + method, path, client=client, base_url=self._billing_base_url, **kwargs + ) + def _wait_for_storage_job( self, job: dict[str, Any], diff --git a/src/keboola_agent_cli/client/billing.py b/src/keboola_agent_cli/client/billing.py new file mode 100644 index 00000000..dfcfd620 --- /dev/null +++ b/src/keboola_agent_cli/client/billing.py @@ -0,0 +1,29 @@ +"""Pay-As-You-Go credit balance -- GET /credits on the billing service. + +New for issue #594. The billing service also exposes ``POST /credits``, which +triggers a REAL automatic top-up (real money charged to the project). That +endpoint is deliberately NOT wrapped anywhere in this mixin -- kbagent's +billing surface is read-only by design; see the module-level guardrail in +``services/billing_service.py`` for the feature-gate that keeps non-PAYG +projects off this host entirely. +""" + +from typing import Any + +from ._core import _CoreClient + + +class _BillingMixin(_CoreClient): + """Pay-As-You-Go credit balance -- GET /credits on the billing service.""" + + def get_credits(self) -> dict[str, Any]: + """Fetch the project's PAYG credit balance. + + GETs ``/credits`` on the ``billing.{stack-suffix}`` host and returns + the raw JSON dict verbatim (no shaping here -- see + ``ProjectCredits`` in ``models.py`` for the tolerant parse used by + the service layer). Read-only: this is the only method this mixin + exposes, by design -- see the module docstring. + """ + response = self._billing_request("GET", "/credits") + return response.json() diff --git a/src/keboola_agent_cli/commands/billing.py b/src/keboola_agent_cli/commands/billing.py new file mode 100644 index 00000000..b290a4a9 --- /dev/null +++ b/src/keboola_agent_cli/commands/billing.py @@ -0,0 +1,109 @@ +"""Pay-As-You-Go (PAYG) credit balance commands. + +Thin CLI layer over :class:`BillingService`. One subcommand: + +- ``billing credits`` -- current credit balance (consumed/remaining/purchased) + across one or more projects, sourced from ``GET /credits`` on the + ``billing.`` host. + +This surfaces the BALANCE ONLY. Purchase history and Stripe invoice IDs live +on ``connection.`` `/pay-as-you-go/billing/*`, which does not accept a +plain project Storage token -- that surface is out of scope for issue #594 +and is not exposed here. + +Read-only: safe under ``--deny-writes``. +""" + +from __future__ import annotations + +from typing import Any + +import typer +from rich.markup import escape +from rich.table import Table + +from ..errors import ConfigError, ErrorCode +from ._helpers import check_cli_permission, get_formatter, get_service + +billing_app = typer.Typer( + help="PAYG credit balance across projects (issue #594). Balance only -- " + "purchase history / Stripe invoice IDs are not reachable with a " + "project token and are not exposed by this command." +) + + +@billing_app.callback(invoke_without_command=True) +def _billing_permission_check(ctx: typer.Context) -> None: + check_cli_permission(ctx, "billing") + + +def _format_credits_table(formatter: Any, credits: list[dict[str, Any]]) -> None: + tbl = Table( + "Project", + "Remaining", + "Consumed", + "Purchased", + "Remaining (min)", + show_header=True, + header_style="bold cyan", + ) + for row in credits: + tbl.add_row( + escape(row.get("project_alias", "")), + f"{row.get('remaining', 0.0):.2f}", + f"{row.get('consumed', 0.0):.2f}", + f"{row.get('purchased', 0.0):.2f}", + f"{row.get('remaining_minutes', 0.0):.0f}", + ) + formatter.console.print(tbl) + + +def _emit_errors(formatter: Any, errors: list[dict[str, Any]]) -> None: + for err in errors: + formatter.warning( + f"Project '{escape(str(err.get('project_alias', '?')))}': " + f"{escape(str(err.get('message', 'error')))}" + ) + + +@billing_app.command("credits") +def billing_credits( + ctx: typer.Context, + project: list[str] | None = typer.Option( + None, + "--project", + help="Project alias (repeatable; omit for all registered projects)", + ), +) -> None: + """Show the current PAYG credit balance for one or more projects. + + Balance only -- consumed, remaining, and purchased (derived as + consumed + remaining) credits, plus the same figures expressed in + minutes (the Keboola UI's unit: minutes = credits * 60). A project + without the `pay-as-you-go` feature flag surfaces as a per-project + warning (`PAYG_NOT_AVAILABLE`), not a hard failure -- one non-PAYG + project in a multi-project run never blocks the others. + + Purchase history and Stripe invoice IDs are NOT available here: that + data lives on `connection.` `/pay-as-you-go/billing/*`, which + does not accept a plain project Storage API token (issue #594). + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "billing_service") + + try: + result = service.get_credits(aliases=project) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + if formatter.json_mode: + formatter.output(result) + return + + credits = result.get("credits", []) + if not credits: + formatter.console.print("[dim]No PAYG projects found.[/dim]") + else: + _format_credits_table(formatter, credits) + _emit_errors(formatter, result.get("errors", [])) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 2b15be31..f708ee97 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -833,6 +833,25 @@ Default-deny since 0.29.0 -- closes the AI-exfiltration risk where subprocesses inherit the manage token via env. +### Billing / PAYG Credits (since v0.85.0) + + kbagent billing credits [--project ALIAS ...] + Read-only PAYG (pay-as-you-go) credit balance. Fans out across all + registered projects in parallel by default; --project (repeatable) + narrows to specific aliases. Per-project failures degrade individually + and never abort the run -- check the "errors" array in --json output. + A project without the `pay-as-you-go` owner.features flag never touches + the billing host (it can be NXDOMAIN on non-PAYG stacks) -- it gets a + per-project error entry with error_code PAYG_NOT_AVAILABLE instead of a + generic network error. + Units: the API speaks credits; rows also carry derived minutes + (1 credit = 60 minutes, matching what the Keboola UI displays) -- never + hand-convert, use the minutes fields already in the row. + This command gives the CURRENT BALANCE only. Purchase history / Stripe + invoice IDs are NOT available -- that data lives on connection.{{stack}} + /pay-as-you-go/billing/*, which does not accept a Storage token + (issue #594, still open). Do not imply invoices are retrievable. + ### Feature Flags (since v0.48.0) Requires a SUPER-ADMIN Manage API token (same kind as `org setup`). Same diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index a1d08041..f91704f0 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -484,6 +484,21 @@ def _resolve_app_name() -> str: KAI_STREAM_TIMEOUT: float = 600.0 # 10 min for SSE streaming responses SECRET_PLACEHOLDER: str = "" +# --- Billing / Pay-As-You-Go (issue #594) --- +# The billing service (GET /credits on the `billing.` host) +# speaks CREDITS natively; the Keboola UI displays MINUTES = credits * 60. +# kbagent surfaces the API's native unit and DERIVES minutes from it (never +# the reverse) so a client-side unit bug can never invert into a wrong +# credits figure. +MINUTES_PER_CREDIT: int = 60 +# `owner.features` flag from `GET /v2/storage/tokens/verify` that gates +# whether a project has a PAYG credit balance at all. Projects without it may +# resolve to a billing host that does not exist on that stack (e.g. +# `billing.eu-central-1.keboola.com` is NXDOMAIN) -- checking this flag FIRST +# lets the billing service return a clear PAYG_NOT_AVAILABLE error instead of +# an opaque connection/DNS failure. +PAYG_FEATURE: str = "pay-as-you-go" + # --- Changelog rendering --- # `kbagent changelog` shows a one-line summary per version by default (--full # expands). A summary is the note's first sentence, capped at this many chars diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index 2b6622a1..7f38c1d2 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -140,6 +140,9 @@ class ErrorCode(StrEnum): # Password-grant login (since 0.81.0) AUTH_MFA_INVALID = "AUTH_MFA_INVALID" + # Billing / Pay-As-You-Go (since #594) + PAYG_NOT_AVAILABLE = "PAYG_NOT_AVAILABLE" + def mask_token(token: str) -> str: """Mask a Keboola Storage API token for safe display. @@ -321,6 +324,7 @@ def __init__(self, feature: str, *, remedy: str = "") -> None: ErrorCode.SESSION_EXPIRED: "authentication", ErrorCode.SESSION_NOT_FOUND: "authentication", ErrorCode.AUTH_MFA_INVALID: "authentication", + ErrorCode.PAYG_NOT_AVAILABLE: "configuration", } diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 326a41a4..3d9de56c 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -466,3 +466,62 @@ class BulkInviteResult(BaseModel): failed: int rows: list[MemberInviteRow] = Field(default_factory=list) dry_run: bool = Field(default=False) + + +class WorkspaceJobCredits(BaseModel): + """One workspace-type breakdown row from `GET /credits` on the billing service. + + Deliberately tolerant (`extra="allow"`, every field optional/defaulted): + the publicly documented shape of `stats.workspaceJobs` is an OBJECT, but + the live API (verified against north-europe.azure) returns an ARRAY of + these rows instead. A strict model built against the docs would raise on + every real payload, so nothing here is required. + """ + + workspace_type: str | None = Field(default=None, alias="workspaceType") + warehouse_size: str | None = Field(default=None, alias="warehouseSize") + consumed: float = Field(default=0.0) + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class ComponentJobCredits(BaseModel): + """`stats.componentJobs` breakdown from `GET /credits` -- extras pass through.""" + + consumed: float = Field(default=0.0) + + model_config = {"extra": "allow"} + + +class CreditStats(BaseModel): + """`stats` block from `GET /credits`. + + See `WorkspaceJobCredits` for why `workspace_jobs` must stay a tolerant + list default rather than a required field: the API's real shape does not + match its own public docs, and `stats` itself, or `workspaceJobs` within + it, can be absent entirely on a project with no workspace usage yet. + """ + + component_jobs: ComponentJobCredits | None = Field(default=None, alias="componentJobs") + workspace_jobs: list[WorkspaceJobCredits] = Field(default_factory=list, alias="workspaceJobs") + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class ProjectCredits(BaseModel): + """`GET /credits` response from the billing service (`billing.` host). + + `stats` defaults to `None` because the top-level `consumed`/`remaining` + balance is meaningful on its own -- a caller that only needs the balance + should not be forced through a populated breakdown. Combined with the + tolerance built into `CreditStats`/`WorkspaceJobCredits`, this model + parses the verbatim live payload as well as a minimal + `{"consumed": ..., "remaining": ...}` response with no `stats` key at all, + without ever raising on either. + """ + + consumed: float = Field(default=0.0) + remaining: float = Field(default=0.0) + stats: CreditStats | None = Field(default=None) + + model_config = {"extra": "allow"} diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 3f376f5b..a74c3fc8 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -342,6 +342,8 @@ "schedule.list": "read", "schedule.detail": "read", "schedule.find": "read", + # PAYG credit balance (issue #594) -- read-only, GET /credits only. + "billing.credits": "read", # Top-level commands "search": "read", "init": "admin", diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index bbae675a..4d7e1080 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -34,6 +34,7 @@ from .routers import ( agents, ai_chat, + billing, branches, components, configs, @@ -116,6 +117,16 @@ "Mirrors `kbagent feature list|project-*|user-*`." ), }, + { + "name": "billing", + "description": ( + "**Project Management.** " + "PAYG credit balance across projects (read-only). Purchase " + "history / Stripe invoice IDs are not reachable with a " + "project token. " + "Mirrors `kbagent billing credits`." + ), + }, # ---- Configurations ---- { "name": "configs", @@ -671,6 +682,7 @@ async def _generic_handler(_request, exc: Exception): app.include_router(projects.router) app.include_router(members.router) app.include_router(feature.router) + app.include_router(billing.router) app.include_router(configs.router) app.include_router(components.router) app.include_router(storage.router) diff --git a/src/keboola_agent_cli/server/dependencies.py b/src/keboola_agent_cli/server/dependencies.py index ca839da3..4d52a060 100644 --- a/src/keboola_agent_cli/server/dependencies.py +++ b/src/keboola_agent_cli/server/dependencies.py @@ -15,6 +15,7 @@ from ..config_store import ConfigStore from ..dev_portal_client import DeveloperPortalClient +from ..services.billing_service import BillingService from ..services.branch_service import BranchService from ..services.component_service import ComponentService from ..services.config_service import ConfigService @@ -128,6 +129,7 @@ class ServiceRegistry: token: TokenService = field(init=False) docs: DocsService = field(init=False) transformation: TransformationService = field(init=False) + billing: BillingService = field(init=False) def __post_init__(self) -> None: cs = self.config_store @@ -170,6 +172,7 @@ def __post_init__(self) -> None: self.token = TokenService(config_store=cs) self.docs = DocsService(config_store=cs) self.transformation = TransformationService(config_store=cs) + self.billing = BillingService(config_store=cs) def install_registry(app: FastAPI, registry: ServiceRegistry) -> None: diff --git a/src/keboola_agent_cli/server/routers/billing.py b/src/keboola_agent_cli/server/routers/billing.py new file mode 100644 index 00000000..c58b79a9 --- /dev/null +++ b/src/keboola_agent_cli/server/routers/billing.py @@ -0,0 +1,32 @@ +"""PAYG billing endpoints (credit balance). + +Read-only by design: the upstream billing service on ``billing.{stack}`` +exposes a ``POST /credits`` that triggers a REAL automatic top-up (real +money). This router -- and the client/service layers it delegates to -- +only ever issue GET requests. Do not add a write endpoint here. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from ..dependencies import ServiceRegistry, get_registry + +router = APIRouter(prefix="/billing", tags=["billing"]) + + +@router.get("/credits", summary="PAYG credit balance across projects") +def credits( + project: list[str] | None = Query(None), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """PAYG credit balance per project. Mirrors `kbagent billing credits`. + + Balance only -- purchase history and Stripe invoice IDs are not + reachable with a project token (issue #594) and have no endpoint here. + Read-only: the upstream billing service's `POST /credits` performs a + real-money automatic top-up and is deliberately not exposed. + """ + return registry.billing.get_credits(aliases=project) diff --git a/src/keboola_agent_cli/services/billing_service.py b/src/keboola_agent_cli/services/billing_service.py new file mode 100644 index 00000000..65742317 --- /dev/null +++ b/src/keboola_agent_cli/services/billing_service.py @@ -0,0 +1,169 @@ +"""Pay-As-You-Go credit balance discovery across one or many projects. + +Wraps ``KeboolaClient.get_credits`` (``GET /credits`` on the derived +``billing.{stack}`` host) with the same fan-out / per-project-error shape +every other multi-project service uses (see ``ScheduleService`` in +``schedule_service.py`` for the idiom this mirrors). + +MONEY GUARDRAIL: the billing service also exposes ``POST /credits``, which +triggers a REAL automatic top-up (real money charged to the project). This +service is **read-only by design** -- it only ever calls +``client.get_credits()`` (a GET). No method here issues, or ever should +issue, a POST to the billing host. See ``client/billing.py`` for the mixin +that enforces the same restriction one layer down. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..constants import MINUTES_PER_CREDIT, PAYG_FEATURE +from ..errors import ErrorCode, KeboolaApiError +from ..models import ProjectConfig, ProjectCredits +from .base import BaseService, project_error_entry + +logger = logging.getLogger(__name__) + +# KeboolaApiError codes that mean "we never got a real answer from the +# billing host" -- worth a friendlier message than the raw httpx error, +# because on a stack without PAYG the derived `billing.` host can be +# NXDOMAIN (see the has_feature gate below, which is what normally keeps a +# non-PAYG project from ever reaching this branch at all). +_UNREACHABLE_ERROR_CODES = frozenset( + {ErrorCode.CONNECTION_ERROR, ErrorCode.TIMEOUT, ErrorCode.RETRY_EXHAUSTED} +) + + +def _build_credit_row( + alias: str, project: ProjectConfig, credits_: ProjectCredits +) -> dict[str, Any]: + """Project a parsed ``ProjectCredits`` payload into the CLI-facing row. + + Derives minutes from credits (never the reverse -- the API's native unit + is credits) and ``purchased`` as ``consumed + remaining``, since the + billing endpoint reports the current balance, not the lifetime total. + """ + stats = credits_.stats + component_jobs_consumed = ( + stats.component_jobs.consumed if stats and stats.component_jobs else 0.0 + ) + workspace_jobs = [ + { + "workspace_type": job.workspace_type, + "warehouse_size": job.warehouse_size, + "consumed": job.consumed, + } + for job in (stats.workspace_jobs if stats else []) + ] + + return { + "project_alias": alias, + "project_id": project.project_id, + "consumed": credits_.consumed, + "remaining": credits_.remaining, + "purchased": credits_.consumed + credits_.remaining, + "consumed_minutes": credits_.consumed * MINUTES_PER_CREDIT, + "remaining_minutes": credits_.remaining * MINUTES_PER_CREDIT, + "component_jobs_consumed": component_jobs_consumed, + "workspace_jobs": workspace_jobs, + } + + +class BillingService(BaseService): + """Fleet-wide PAYG credit balance lookup. + + Read-only by construction -- see the module docstring's money guardrail. + Like the other multi-project services, per-project failures degrade + individually into the ``errors`` list rather than aborting the whole + fan-out. + """ + + def get_credits(self, aliases: list[str] | None = None) -> dict[str, Any]: + """Fetch the PAYG credit balance for one, many, or all projects. + + Args: + aliases: Project aliases to query. ``None`` / empty means every + registered project. + + Returns: + ``{"credits": [row, ...], "errors": [entry, ...]}``, both sorted + by ``project_alias`` for deterministic output. Each row has + ``project_alias``, ``project_id``, ``consumed``, ``remaining``, + ``purchased``, ``consumed_minutes``, ``remaining_minutes``, + ``component_jobs_consumed``, ``workspace_jobs``. + """ + projects = self.resolve_projects(aliases) + + def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: + return self._fetch_project_credits(alias, project) + + successes, errors = self._run_parallel(projects, worker) + + credits_rows = [result[1] for result in successes] + credits_rows.sort(key=lambda row: row.get("project_alias", "")) + errors.sort(key=lambda e: e.get("project_alias", "")) + + return {"credits": credits_rows, "errors": errors} + + def _fetch_project_credits(self, alias: str, project: ProjectConfig) -> tuple[Any, ...]: + """Fetch + shape the PAYG balance for a single project. + + Order matters here, in this exact sequence: + + 1. Build the client. + 2. Feature-gate with ``client.has_feature(PAYG_FEATURE)`` BEFORE any + billing call. This is NOT a fallback triggered by a billing 4xx -- + it runs first, unconditionally. On a stack without PAYG, the + Storage API's own service index still advertises a + ``billing.`` host entry that simply does not resolve + (NXDOMAIN); calling it anyway would surface an opaque DNS/connect + failure instead of the actual, actionable reason ("this project + doesn't have PAYG"). Checking the feature flag first turns that + into a clear ``PAYG_NOT_AVAILABLE`` error before the network call + is ever attempted. + 3. ``client.get_credits()``, parsed through ``ProjectCredits`` + (tolerant model -- see ``models.py``) and projected into the row. + + A connection/DNS failure that still slips through step 3 (e.g. a + transient host that resolves but refuses the connection) is + re-worded to say the billing host is unreachable on this stack, + rather than surfacing a bare httpx/stack-trace message. + """ + client = self._client_factory(project.stack_url, project.token) + try: + if not client.has_feature(PAYG_FEATURE): + return ( + alias, + { + "project_alias": alias, + "error_code": str(ErrorCode.PAYG_NOT_AVAILABLE), + "message": ( + f"Project does not have the '{PAYG_FEATURE}' feature enabled. " + "PAYG credit balances only exist for pay-as-you-go projects; " + "ask a Keboola admin to enable it if this project should have one." + ), + }, + ) + + raw = client.get_credits() + parsed = ProjectCredits.model_validate(raw) + row = _build_credit_row(alias, project, parsed) + return (alias, row, True) + except KeboolaApiError as exc: + message = exc.message + if exc.error_code in _UNREACHABLE_ERROR_CODES: + message = ( + f"Could not reach the billing service on stack {project.stack_url!r}; " + f"the PAYG credit balance is unavailable right now. Original error: {exc.message}" + ) + logger.debug("get_credits failed for project '%s': %s", alias, exc) + return (alias, project_error_entry(alias, exc, message=message)) + except Exception as exc: + logger.debug("Unexpected error fetching credits for project '%s': %s", alias, exc) + return (alias, project_error_entry(alias, exc)) + finally: + client.close() + + +__all__ = ["BillingService"] diff --git a/tests/test_billing_cli.py b/tests/test_billing_cli.py new file mode 100644 index 00000000..64c12b0b --- /dev/null +++ b/tests/test_billing_cli.py @@ -0,0 +1,155 @@ +"""Tests for `kbagent billing credits` via CliRunner. + +Mirrors tests/test_schedule_cli.py's structure: patch ConfigStore + the +service class used inside `keboola_agent_cli.cli`, invoke through the real +Typer app, and assert on JSON envelope / human-mode rendering / exit codes. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError +from keboola_agent_cli.models import ProjectConfig + +runner = CliRunner() +TEST_TOKEN = "999-token-abc" + + +def _setup_config(config_dir: Path, projects: dict[str, dict] | None = None) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + if projects: + for alias, info in projects.items(): + store.add_project( + alias, + ProjectConfig( + stack_url=info.get("stack_url", "https://connection.keboola.com"), + token=info.get("token", TEST_TOKEN), + project_name=info.get("project_name", alias), + project_id=info.get("project_id", 1234), + ), + ) + return store + + +def _run(args: list[str], store: ConfigStore, mock_service: MagicMock) -> Any: + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.BillingService") as MockBS, + ): + MockStore.return_value = store + MockBS.return_value = mock_service + return runner.invoke(app, args) + + +def _credit_row(alias: str = "prod") -> dict[str, Any]: + return { + "project_alias": alias, + "project_id": 1234, + "consumed": 100.5, + "remaining": 25.5, + "purchased": 126.0, + "consumed_minutes": 6030.0, + "remaining_minutes": 1530.0, + "component_jobs_consumed": 95.25, + "workspace_jobs": [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 5.0}, + ], + } + + +class TestBillingCreditsCli: + def test_json_output_emits_envelope_verbatim(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + envelope = {"credits": [_credit_row()], "errors": []} + mock_service.get_credits.return_value = envelope + result = _run(["--json", "billing", "credits", "--project", "prod"], store, mock_service) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["data"] == envelope + + def test_human_mode_renders_table_with_balance_and_minutes(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [_credit_row()], "errors": []} + result = _run(["billing", "credits", "--project", "prod"], store, mock_service) + assert result.exit_code == 0, result.output + assert "prod" in result.output + assert "25.50" in result.output # remaining + assert "100.50" in result.output # consumed + assert "126.00" in result.output # purchased + assert "1530" in result.output # remaining minutes + + def test_project_flag_repeatable_forwarded_as_aliases(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"a": {}, "b": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [], "errors": []} + result = _run( + ["--json", "billing", "credits", "--project", "a", "--project", "b"], + store, + mock_service, + ) + assert result.exit_code == 0, result.output + mock_service.get_credits.assert_called_once_with(aliases=["a", "b"]) + + def test_no_project_flag_forwards_none(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"a": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [], "errors": []} + result = _run(["--json", "billing", "credits"], store, mock_service) + assert result.exit_code == 0, result.output + mock_service.get_credits.assert_called_once_with(aliases=None) + + def test_per_project_errors_surface_as_warnings_exit_0(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"a": {}, "b": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = { + "credits": [_credit_row("a")], + "errors": [ + { + "project_alias": "b", + "error_code": "PAYG_NOT_AVAILABLE", + "message": "Project does not have the 'pay-as-you-go' feature enabled.", + } + ], + } + result = _run(["billing", "credits"], store, mock_service) + assert result.exit_code == 0, result.output + assert "b" in result.output + assert "PAYG_NOT_AVAILABLE" in result.output or "pay-as-you-go" in result.output + + def test_empty_result_prints_no_payg_projects_line(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [], "errors": []} + result = _run(["billing", "credits", "--project", "prod"], store, mock_service) + assert result.exit_code == 0, result.output + assert "No PAYG projects found." in result.output + + def test_config_error_exits_5(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.side_effect = ConfigError("No projects") + result = _run(["--json", "billing", "credits"], store, mock_service) + assert result.exit_code == 5 + + def test_deny_writes_still_permits_read(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [_credit_row()], "errors": []} + result = _run( + ["--deny-writes", "--json", "billing", "credits", "--project", "prod"], + store, + mock_service, + ) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["data"]["credits"][0]["project_alias"] == "prod" diff --git a/tests/test_billing_client.py b/tests/test_billing_client.py new file mode 100644 index 00000000..0fb9ad62 --- /dev/null +++ b/tests/test_billing_client.py @@ -0,0 +1,228 @@ +"""Tests for the billing (PAYG credits) L3 client -- issue #594. + +Covers: +- Host derivation: connection. -> billing., mirroring the + sync-actions / stream sibling-host pattern (test_component_sync_action.py, + test_stream_client.py). +- `KeboolaClient.get_credits()` issuing a GET to the derived billing host and + returning the parsed JSON verbatim. +- The verbatim live payload from the billing contract round-tripping through + `ProjectCredits`, including `stats.workspaceJobs` arriving as a LIST (the + live API's actual shape, which diverges from the public docs' object shape). +- `ProjectCredits` tolerance: missing `stats`, `stats.workspaceJobs` absent, + and unknown extra top-level keys must never raise. +- A regression guard on the money rule: `POST /credits` triggers a REAL + automatic top-up, so the mixin must expose no public method that could + issue a POST to the billing host. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from keboola_agent_cli.client import KeboolaClient +from keboola_agent_cli.client.billing import _BillingMixin +from keboola_agent_cli.models import ( + CreditStats, + ProjectCredits, + WorkspaceJobCredits, +) + +TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +STACK_URL = "https://connection.north-europe.azure.keboola.com" +BILLING_URL = "https://billing.north-europe.azure.keboola.com" + +# Verbatim live payload from the billing contract (north-europe.azure). +LIVE_PAYLOAD = { + "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, +} + + +class TestDeriveBillingUrl: + """The control-plane base URL is connection. -> billing..""" + + def test_north_europe_azure_stack(self) -> None: + assert KeboolaClient._derive_service_url(STACK_URL, "billing") == BILLING_URL + + def test_us_stack(self) -> None: + assert ( + KeboolaClient._derive_service_url("https://connection.keboola.com", "billing") + == "https://billing.keboola.com" + ) + + def test_gcp_stack(self) -> None: + assert ( + KeboolaClient._derive_service_url( + "https://connection.us-east4.gcp.keboola.com", "billing" + ) + == "https://billing.us-east4.gcp.keboola.com" + ) + + +class TestGetCredits: + def test_get_credits_issues_get_to_billing_host(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{BILLING_URL}/credits", + method="GET", + json=LIVE_PAYLOAD, + status_code=200, + ) + + with KeboolaClient(stack_url=STACK_URL, token=TEST_TOKEN) as client: + result = client.get_credits() + + assert result == LIVE_PAYLOAD + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "GET" + assert str(requests[0].url) == f"{BILLING_URL}/credits" + # Sub-client inherits the Storage API token header from the main client. + assert requests[0].headers["X-StorageApi-Token"] == TEST_TOKEN + + def test_get_credits_returns_raw_json_verbatim(self, httpx_mock) -> None: + """No shaping happens in the client layer -- the dict is returned as-is.""" + payload = {"consumed": 1.0, "remaining": 2.0, "unknownField": "passthrough"} + httpx_mock.add_response(url=f"{BILLING_URL}/credits", method="GET", json=payload) + + with KeboolaClient(stack_url=STACK_URL, token=TEST_TOKEN) as client: + result = client.get_credits() + + assert result == payload + + +class TestProjectCreditsParsing: + """`models.ProjectCredits` must tolerate the live API's real shape.""" + + def test_parses_verbatim_live_payload(self) -> None: + credits = ProjectCredits.model_validate(LIVE_PAYLOAD) + + assert credits.consumed == 100.5 + assert credits.remaining == 25.5 + assert credits.stats is not None + assert isinstance(credits.stats, CreditStats) + assert credits.stats.component_jobs is not None + assert credits.stats.component_jobs.consumed == 95.25 + + # workspaceJobs is a LIST of two entries in the live payload -- the + # public docs show an object, but the tolerant model parses the array. + assert isinstance(credits.stats.workspace_jobs, list) + assert len(credits.stats.workspace_jobs) == 2 + first, second = credits.stats.workspace_jobs + assert isinstance(first, WorkspaceJobCredits) + assert first.workspace_type == "sandbox-sql" + assert first.warehouse_size == "small" + assert first.consumed == 5.0 + assert second.workspace_type == "writer" + assert second.consumed == 0.25 + + def test_tolerates_missing_stats(self) -> None: + credits = ProjectCredits.model_validate({"consumed": 10.0, "remaining": 5.0}) + assert credits.stats is None + assert credits.consumed == 10.0 + assert credits.remaining == 5.0 + + def test_tolerates_stats_without_workspace_jobs(self) -> None: + credits = ProjectCredits.model_validate( + { + "consumed": 10.0, + "remaining": 5.0, + "stats": {"componentJobs": {"consumed": 10.0}}, + } + ) + assert credits.stats is not None + assert credits.stats.workspace_jobs == [] + assert credits.stats.component_jobs is not None + assert credits.stats.component_jobs.consumed == 10.0 + + def test_tolerates_stats_none(self) -> None: + credits = ProjectCredits.model_validate({"consumed": 10.0, "remaining": 5.0, "stats": None}) + assert credits.stats is None + + def test_tolerates_unknown_extra_top_level_keys(self) -> None: + credits = ProjectCredits.model_validate( + { + "consumed": 10.0, + "remaining": 5.0, + "purchased": 15.0, + "currency": "EUR", + "someFutureField": {"nested": True}, + } + ) + assert credits.consumed == 10.0 + assert credits.remaining == 5.0 + + def test_tolerates_completely_empty_payload(self) -> None: + credits = ProjectCredits.model_validate({}) + assert credits.consumed == 0.0 + assert credits.remaining == 0.0 + assert credits.stats is None + + def test_tolerates_unknown_extra_keys_on_nested_models(self) -> None: + credits = ProjectCredits.model_validate( + { + "consumed": 1.0, + "remaining": 2.0, + "stats": { + "componentJobs": {"consumed": 1.0, "extraField": "x"}, + "workspaceJobs": [ + { + "workspaceType": "sandbox-sql", + "warehouseSize": "small", + "consumed": 1.0, + "extraField": "y", + } + ], + "extraStatsField": "z", + }, + } + ) + assert credits.stats is not None + assert credits.stats.workspace_jobs[0].workspace_type == "sandbox-sql" + + +class TestNoTopUpHelper: + """Regression guard: POST /credits triggers a REAL automatic top-up. + + `_BillingMixin` must expose exactly one public method (`get_credits`) and + must never issue a POST request anywhere in its own source -- both are + checked so an unnoticed future addition of a "top up" / "purchase" helper + fails this test loudly. + """ + + def test_only_get_credits_is_defined_on_the_mixin(self) -> None: + own_public_methods = [ + name + for name, value in vars(_BillingMixin).items() + if not name.startswith("_") and callable(value) + ] + assert own_public_methods == ["get_credits"] + + def test_billing_module_source_never_issues_a_post(self) -> None: + import keboola_agent_cli.client.billing as billing_module + + source = inspect.getsource(billing_module) + assert '"POST"' not in source + assert "'POST'" not in source + + def test_get_credits_never_sends_a_post_request(self, httpx_mock) -> None: + httpx_mock.add_response(url=f"{BILLING_URL}/credits", method="GET", json=LIVE_PAYLOAD) + + with KeboolaClient(stack_url=STACK_URL, token=TEST_TOKEN) as client: + client.get_credits() + + for request in httpx_mock.get_requests(): + assert request.method != "POST" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_billing_service.py b/tests/test_billing_service.py new file mode 100644 index 00000000..5b0c063b --- /dev/null +++ b/tests/test_billing_service.py @@ -0,0 +1,364 @@ +"""Unit tests for BillingService. + +Tests the business logic in isolation using mocked KeboolaClient instances. +Covers: + +- ``get_credits`` happy path against the verbatim live payload from issue #594. +- The PAYG feature gate short-circuiting before any billing call. +- Mixed multi-project fan-out (success + non-PAYG + API error). +- ``ConfigError`` propagation for an unknown alias. +- Deterministic ordering of both ``credits`` and ``errors``. +- Tolerant parsing of a payload missing ``stats`` entirely. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError +from keboola_agent_cli.services.billing_service import BillingService + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +# Fake tokens follow the repo convention: "901--". +_TOKEN_A = "901-storage-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +_TOKEN_B = "901-storage-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +_TOKEN_C = "901-storage-cccccccccccccccccccccccccccccccc" + +# The exact live payload from the billing contract (issue #594). +LIVE_PAYLOAD = { + "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, +} + + +def _mock_config_store(projects: dict) -> MagicMock: + """Build a config-store double mirroring test_schedule_service.py's helper. + + Each ``projects`` value is a dict with ``url``, ``token``, and optionally + ``project_id`` -- explicitly set (rather than left as an auto-generated + ``MagicMock``) so row assertions against ``project_id`` are meaningful. + """ + cs = MagicMock() + config = MagicMock() + config.projects = { + alias: MagicMock( + stack_url=v["url"], + token=v["token"], + active_branch_id=None, + project_id=v.get("project_id"), + ) + for alias, v in projects.items() + } + config.max_parallel_workers = 10 + cs.load.return_value = config + cs.get_project.side_effect = lambda alias: config.projects.get(alias) + return cs + + +def _make_service(mock_client: MagicMock, projects: dict | None = None) -> BillingService: + if projects is None: + projects = { + "prod": {"url": "https://connection.keboola.com", "token": _TOKEN_A, "project_id": 123} + } + cs = _mock_config_store(projects) + return BillingService(config_store=cs, client_factory=lambda url, tok: mock_client) + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestGetCreditsHappyPath: + def test_live_payload_produces_exact_row(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["errors"] == [] + assert len(result["credits"]) == 1 + row = result["credits"][0] + + assert row["project_alias"] == "prod" + assert row["project_id"] == 123 + assert row["consumed"] == 100.5 + assert row["remaining"] == 25.5 + assert row["purchased"] == 126.0 # 100.5 + 25.5 + assert row["consumed_minutes"] == 100.5 * 60 + assert row["remaining_minutes"] == 25.5 * 60 + assert row["component_jobs_consumed"] == 95.25 + assert row["workspace_jobs"] == [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 5.0}, + {"workspace_type": "writer", "warehouse_size": "small", "consumed": 0.25}, + ] + + def test_feature_gate_checked_before_get_credits(self) -> None: + """has_feature must be called, and checked, before get_credits fires.""" + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.has_feature.assert_called_once_with("pay-as-you-go") + client.get_credits.assert_called_once() + + def test_client_closed_on_success(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Non-PAYG feature gate +# --------------------------------------------------------------------------- + + +class TestPaygFeatureGate: + def test_missing_feature_short_circuits(self) -> None: + client = MagicMock() + client.has_feature.return_value = False + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["credits"] == [] + assert len(result["errors"]) == 1 + entry = result["errors"][0] + assert entry["project_alias"] == "prod" + assert entry["error_code"] == str(ErrorCode.PAYG_NOT_AVAILABLE) + assert "pay-as-you-go" in entry["message"] + + def test_get_credits_never_called_when_gate_fails(self) -> None: + """The whole point of the gate: never touch the (possibly NXDOMAIN) billing host.""" + client = MagicMock() + client.has_feature.return_value = False + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.get_credits.assert_not_called() + + def test_client_closed_even_when_gate_fails(self) -> None: + client = MagicMock() + client.has_feature.return_value = False + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Mixed multi-project fan-out +# --------------------------------------------------------------------------- + + +class TestMixedFanOut: + def _client_factory(self): + """Return a (url, token) -> client factory keyed by token. + + ``a`` succeeds, ``b`` is non-PAYG, ``c`` raises KeboolaApiError from + the billing call itself. + """ + client_a = MagicMock() + client_a.has_feature.return_value = True + client_a.get_credits.return_value = LIVE_PAYLOAD + + client_b = MagicMock() + client_b.has_feature.return_value = False + + client_c = MagicMock() + client_c.has_feature.return_value = True + client_c.get_credits.side_effect = KeboolaApiError( + message="Internal server error", + status_code=500, + error_code="API_ERROR", + retryable=False, + ) + + by_token = {_TOKEN_A: client_a, _TOKEN_B: client_b, _TOKEN_C: client_c} + clients = {"a": client_a, "b": client_b, "c": client_c} + + def factory(url: str, token: str) -> MagicMock: + return by_token[token] + + return factory, clients + + def test_three_projects_all_outcomes_present(self) -> None: + factory, clients = self._client_factory() + cs = _mock_config_store( + { + "a": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 1}, + "b": {"url": "https://k.com", "token": _TOKEN_B, "project_id": 2}, + "c": {"url": "https://k.com", "token": _TOKEN_C, "project_id": 3}, + } + ) + service = BillingService(config_store=cs, client_factory=factory) + result = service.get_credits() + + # Success row for "a" still returned. + assert len(result["credits"]) == 1 + assert result["credits"][0]["project_alias"] == "a" + assert result["credits"][0]["consumed"] == 100.5 + + # Both "b" (non-PAYG) and "c" (API error) degrade to error entries; + # neither one aborts the fan-out. + assert len(result["errors"]) == 2 + error_aliases = {e["project_alias"] for e in result["errors"]} + assert error_aliases == {"b", "c"} + + b_entry = next(e for e in result["errors"] if e["project_alias"] == "b") + assert b_entry["error_code"] == str(ErrorCode.PAYG_NOT_AVAILABLE) + + c_entry = next(e for e in result["errors"] if e["project_alias"] == "c") + assert c_entry["error_code"] == "API_ERROR" + + # Every client is closed regardless of outcome. + for client in clients.values(): + client.close.assert_called_once() + + def test_connection_error_message_mentions_unreachable(self) -> None: + """A connection/DNS failure that slips past the gate gets a clear message.""" + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.side_effect = KeboolaApiError( + message="Cannot connect to https://billing.example.com (token: ***)", + status_code=0, + error_code=ErrorCode.CONNECTION_ERROR, + retryable=True, + ) + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["credits"] == [] + entry = result["errors"][0] + assert entry["error_code"] == str(ErrorCode.CONNECTION_ERROR) + assert "could not reach" in entry["message"].lower() + assert "billing" in entry["message"].lower() + + +# --------------------------------------------------------------------------- +# Unknown alias +# --------------------------------------------------------------------------- + + +class TestUnknownAlias: + def test_unknown_alias_raises_config_error(self) -> None: + client = MagicMock() + service = _make_service(client) + with pytest.raises(ConfigError): + service.get_credits(aliases=["ghost"]) + + def test_unknown_alias_does_not_become_a_per_project_error(self) -> None: + """ConfigError from resolve_projects must propagate, not be swallowed.""" + client = MagicMock() + service = _make_service(client) + with pytest.raises(ConfigError): + service.get_credits(aliases=["prod", "ghost"]) + # No billing call should have been attempted for either alias -- + # resolution happens before the fan-out starts. + client.has_feature.assert_not_called() + client.get_credits.assert_not_called() + + +# --------------------------------------------------------------------------- +# Deterministic ordering +# --------------------------------------------------------------------------- + + +class TestDeterministicOrdering: + def test_credits_sorted_by_alias(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + cs = _mock_config_store( + { + "zeta": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 1}, + "alpha": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 2}, + "mid": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 3}, + } + ) + service = BillingService(config_store=cs, client_factory=lambda url, tok: client) + result = service.get_credits() + + aliases = [row["project_alias"] for row in result["credits"]] + assert aliases == sorted(aliases) + assert aliases == ["alpha", "mid", "zeta"] + + def test_errors_sorted_by_alias(self) -> None: + client = MagicMock() + client.has_feature.return_value = False + + cs = _mock_config_store( + { + "zeta": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 1}, + "alpha": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 2}, + } + ) + service = BillingService(config_store=cs, client_factory=lambda url, tok: client) + result = service.get_credits() + + aliases = [e["project_alias"] for e in result["errors"]] + assert aliases == ["alpha", "zeta"] + + +# --------------------------------------------------------------------------- +# Tolerant parsing: missing ``stats`` +# --------------------------------------------------------------------------- + + +class TestMissingStats: + def test_payload_without_stats_zeroes_breakdown(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = {"consumed": 10.0, "remaining": 5.0} + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["errors"] == [] + row = result["credits"][0] + assert row["consumed"] == 10.0 + assert row["remaining"] == 5.0 + assert row["purchased"] == 15.0 + assert row["component_jobs_consumed"] == 0.0 + assert row["workspace_jobs"] == [] + + def test_completely_empty_payload_does_not_raise(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = {} + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["errors"] == [] + row = result["credits"][0] + assert row["consumed"] == 0.0 + assert row["remaining"] == 0.0 + assert row["purchased"] == 0.0 + assert row["component_jobs_consumed"] == 0.0 + assert row["workspace_jobs"] == [] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 09e33a56..837f3400 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -5321,6 +5321,103 @@ def test_tool_call_get_buckets(self) -> None: assert result.exit_code == 0 +# --------------------------------------------------------------------------- +# Billing / PAYG credit balance (issue #594) +# --------------------------------------------------------------------------- + + +@skip_without_credentials +@pytest.mark.e2e +class TestE2EBillingCredits: + """End-to-end test for `kbagent billing credits`. + + The E2E project(s) used in CI are NOT pay-as-you-go enabled, so the + honest contract to assert here is graceful degradation, not a populated + balance: `--json` must return a well-formed `{"credits": [...], + "errors": [...]}` envelope, exit 0, and the non-PAYG project must show + up as a `PAYG_NOT_AVAILABLE` entry in `errors` rather than crashing or + surfacing an opaque billing-host connection failure. The success-path + row shape is only asserted when a PAYG project is actually present in + the envelope, so this test stays meaningful (and starts covering the + happy path) the day a PAYG project is added to the E2E fixtures -- + without needing to be rewritten then. + """ + + @pytest.fixture(autouse=True) + def setup(self, tmp_path: Path) -> None: + self.token = os.environ[ENV_TOKEN] + raw_url = os.environ.get(ENV_URL, "connection.keboola.com") + self.url = raw_url if raw_url.startswith("https://") else f"https://{raw_url}" + self.alias = f"{RUN_ID}-billing" + self.config_dir = tmp_path / "config" + self.config_dir.mkdir() + + result = _invoke( + self.config_dir, + [ + "--json", + "project", + "add", + "--project", + self.alias, + "--url", + self.url, + "--token", + self.token, + ], + ) + assert result.exit_code == 0, f"project add failed: {result.output}" + + def _run(self, *args: str) -> Any: + return _invoke(self.config_dir, ["--json", *args]) + + def test_billing_credits_returns_well_formed_envelope(self) -> None: + """--json always returns {"credits": [...], "errors": [...]}, exit 0. + + A non-PAYG project degrades to a PAYG_NOT_AVAILABLE error entry + instead of failing the command -- billing is a read, and one + project lacking the feature must never abort the whole run. + """ + result = self._run("billing", "credits", "--project", self.alias) + assert result.exit_code == 0, result.output + data = json.loads(result.output)["data"] + assert "credits" in data + assert "errors" in data + assert isinstance(data["credits"], list) + assert isinstance(data["errors"], list) + + # This project is expected to be non-PAYG in the E2E fixtures. If it + # is, it must show up as an actionable error, not a crash or a bare + # connection failure against a possibly-NXDOMAIN billing host. + non_payg_errors = [e for e in data["errors"] if e.get("project_alias") == self.alias] + payg_rows = [c for c in data["credits"] if c.get("project_alias") == self.alias] + + if payg_rows: + # A PAYG project showed up -- assert the full success-path row shape. + row = payg_rows[0] + for key in ( + "project_alias", + "project_id", + "consumed", + "remaining", + "purchased", + "consumed_minutes", + "remaining_minutes", + "component_jobs_consumed", + "workspace_jobs", + ): + assert key in row, f"missing key {key!r} in PAYG credit row: {row}" + assert row["purchased"] == row["consumed"] + row["remaining"] + assert row["remaining_minutes"] == row["remaining"] * 60 + else: + # Graceful-degradation path: the project must be reported as + # non-PAYG, never silently dropped from both lists. + assert non_payg_errors, ( + f"project {self.alias!r} missing from both credits and errors: {data}" + ) + assert non_payg_errors[0]["error_code"] == "PAYG_NOT_AVAILABLE" + + # --------------------------------------------------------------------------- # Job run variable values resolution # --------------------------------------------------------------------------- diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 57a7fa43..d48d894a 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -1204,6 +1204,85 @@ def test_docs_query_requires_bearer_auth(tmp_path: Path) -> None: docs_svc.ask_docs.assert_not_called() +# --------------------------------------------------------------------------- +# billing.py GET /billing/credits +# Service: billing.get_credits(aliases=...) (mirrors `kbagent billing credits`) +# --------------------------------------------------------------------------- + + +def test_billing_credits_passes_none_aliases_when_no_project_given(tmp_path: Path) -> None: + """GET /billing/credits with no `project` query param must call get_credits(aliases=None).""" + billing_svc = MagicMock() + billing_svc.get_credits.return_value = {"credits": [], "errors": []} + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits", headers=AUTH) + + assert res.status_code == 200, res.text + billing_svc.get_credits.assert_called_once_with(aliases=None) + + +def test_billing_credits_repeated_project_param_forwards_alias_list(tmp_path: Path) -> None: + """Repeated `?project=a&project=b` must forward aliases=["a", "b"].""" + billing_svc = MagicMock() + billing_svc.get_credits.return_value = {"credits": [], "errors": []} + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits", headers=AUTH, params={"project": ["a", "b"]}) + + assert res.status_code == 200, res.text + billing_svc.get_credits.assert_called_once_with(aliases=["a", "b"]) + + +def test_billing_credits_returns_service_envelope_unchanged(tmp_path: Path) -> None: + """The router must return the service's {"credits": ..., "errors": ...} dict verbatim.""" + billing_svc = MagicMock() + envelope = { + "credits": [ + { + "project_alias": "prod", + "project_id": 123, + "consumed": 100.5, + "remaining": 25.5, + "purchased": 126.0, + "consumed_minutes": 6030.0, + "remaining_minutes": 1530.0, + "component_jobs_consumed": 95.25, + "workspace_jobs": [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 5.0} + ], + } + ], + "errors": [], + } + billing_svc.get_credits.return_value = envelope + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits", headers=AUTH) + + assert res.status_code == 200, res.text + assert res.json() == envelope + + +def test_billing_credits_requires_bearer_auth(tmp_path: Path) -> None: + """GET /billing/credits without an Authorization header must be rejected.""" + billing_svc = MagicMock() + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits") # no auth header + + assert res.status_code == 401, res.text + billing_svc.get_credits.assert_not_called() + + # --------------------------------------------------------------------------- # configs.py GET /configs/examples/{component_id} # Service: component.get_config_examples(alias=..., component_id=...) diff --git a/uv.lock b/uv.lock index 4f63a6ff..ad651697 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.84.1" +version = "0.85.0" source = { editable = "." } dependencies = [ { name = "croniter" }, From 0e8b10d0485b6ea42f9cdb6404107ba32f5f6401 Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 17 Aug 2026 16:36:38 -0400 Subject: [PATCH 2/4] chore(release): renumber the billing feature to 0.84.2 0.85.0 is reserved for the epic #390 `tool`-group removal; taking that number for an additive feature would have made the removal announcement in gotchas.md / context.py / mcp_parity.py read as already-shipped. Renumbers the changelog key, pyproject, and every `(since vX.Y.Z)` doc tag this PR introduced -- the #390 references to v0.85.0 stay untouched. --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 2 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- plugins/kbagent/skills/kbagent/references/billing-workflow.md | 2 +- plugins/kbagent/skills/kbagent/references/commands-reference.md | 2 +- plugins/kbagent/skills/kbagent/references/gotchas.md | 2 +- pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 2 +- src/keboola_agent_cli/commands/context.py | 2 +- uv.lock | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8857796f..763ad6ed 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.85.0", + "version": "0.84.2", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index 4aa5310a..91b00e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -468,7 +468,7 @@ kbagent org setup --org-id ID --url URL [--dry-run] [--yes] [--token-description kbagent org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes] [--token-description PREFIX] [--refresh] kbagent billing credits [--project ALIAS ...] -# billing credits (0.85.0+, issue #594 secondary ask): read-only PAYG credit balance, fanned out +# billing credits (0.84.2+, issue #594 secondary ask): read-only PAYG credit balance, fanned out # across all registered projects in parallel by default (--project repeatable narrows). A project # without the `pay-as-you-go` owner.features flag never touches the billing host (NXDOMAIN on some # non-PAYG stacks) -- it gets a per-project error_code PAYG_NOT_AVAILABLE instead; per-project diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index f5efaf25..fce2a941 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.85.0", + "version": "0.84.2", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/references/billing-workflow.md b/plugins/kbagent/skills/kbagent/references/billing-workflow.md index c67337fb..e51b6973 100644 --- a/plugins/kbagent/skills/kbagent/references/billing-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/billing-workflow.md @@ -3,7 +3,7 @@ > Audience: a developer or a kbagent agent asked about Keboola PAYG (pay-as- > you-go) usage, balance, or invoices. Goal: know exactly what `kbagent > billing` can and cannot answer *before* burning a loop hunting for a -> command that does not exist. (since v0.85.0; issue +> command that does not exist. (since v0.84.2; issue > https://github.com/keboola/cli/issues/594) ## The shape of the gap (read this first) diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 5630e821..0b94abbe 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -102,7 +102,7 @@ The `permissions` subcommands persist a write/destructive policy to config.json - `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org (org admin; manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) - `org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes]` -- onboard specific projects by ID (any project member; manage token / Personal Access Token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) -## Billing (PAYG Credits) (since v0.85.0) +## Billing (PAYG Credits) (since v0.84.2) - `billing credits [--project ALIAS ...]` -- read-only PAYG credit balance (`GET /credits` on `billing.{stack}`, plain Storage token). Fans out across all registered projects in parallel by default; `--project` (repeatable) narrows. Per-project failures degrade individually and are collected in `errors`, never abort the run. A project without the `pay-as-you-go` `owner.features` flag never calls the billing host (NXDOMAIN on some non-PAYG stacks) -- it gets an `error_code: PAYG_NOT_AVAILABLE` entry instead. `--json` emits `{"credits": [...], "errors": [...]}`. Rows carry the API's native unit (`consumed`/`remaining` credits) plus derived `*_minutes` fields (1 credit = 60 minutes, matching the Keboola UI). Gives the current balance only -- purchase history / Stripe invoice IDs are not reachable with a project token (issue #594 primary ask, still open; that data lives on `connection.{stack}` `/pay-as-you-go/billing/*`). See [billing-workflow.md](billing-workflow.md) for the full shape of the invoice-history gap and why it must not be worked around. ## Feature Flags (since v0.48.0) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 3d879792..867e720b 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -3565,7 +3565,7 @@ machine's locale, and tolerates files that are not valid UTF-8. UTF-8 without a fallback, so a genuinely mis-encoded input file fails loudly and identically everywhere rather than being silently mis-parsed. -## `billing credits`: credits vs. minutes, array not object, feature-gated before it ever hits the network (since v0.85.0) +## `billing credits`: credits vs. minutes, array not object, feature-gated before it ever hits the network (since v0.84.2) `billing credits [--project ALIAS ...]` is a read-only PAYG (pay-as-you-go) balance check (`GET /credits` on `billing.{stack}`, plain Storage token). diff --git a/pyproject.toml b/pyproject.toml index 44b894a7..7b867968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.85.0" +version = "0.84.2" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 784f009e..7ac6097a 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,7 +24,7 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { - "0.85.0": [ + "0.84.2": [ "New (#594): `kbagent billing credits [--project ALIAS ...]` reads the Pay-As-You-Go " "credit balance, fanned out across every registered project in parallel. Wraps `GET " "/credits` on the `billing.{stack}` host, which accepts a plain per-project Storage " diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index f708ee97..6125cded 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -833,7 +833,7 @@ Default-deny since 0.29.0 -- closes the AI-exfiltration risk where subprocesses inherit the manage token via env. -### Billing / PAYG Credits (since v0.85.0) +### Billing / PAYG Credits (since v0.84.2) kbagent billing credits [--project ALIAS ...] Read-only PAYG (pay-as-you-go) credit balance. Fans out across all diff --git a/uv.lock b/uv.lock index ad651697..c08b7997 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.85.0" +version = "0.84.2" source = { editable = "." } dependencies = [ { name = "croniter" }, From 88c373af6420acbf9704c4a9a76dd3b7794535f8 Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 17 Aug 2026 16:46:28 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(billing):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20harden=20the=20money=20guardrail=20at=20the=20type?= =?UTF-8?q?=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NB-1: `_billing_request(method, path)` becomes `_billing_get(path)`. The source-scan regression test only covered `client/billing.py`, so a future caller elsewhere could still have constructed `_billing_request("POST", "/credits")` — a real-money automatic top-up. Hardcoding the verb makes that unconstructible, which no source-scanning test can match. Deliberately breaks symmetry with the `_queue_request` / `_sync_actions_request` siblings; the docstring says why. Pinned by a new test asserting the dispatcher is gone and the replacement takes no `method` parameter. NB-2: reframe the observed $8.40/credit rate in billing-workflow.md as a single historical observation rather than a platform constant, and tell the reader not to quote it — price is contract-specific, the API never returns it, and an agent reading the doc verbatim could otherwise state it as a user's own rate. NIT-2: note why PAYG_NOT_AVAILABLE is classified in _ERROR_CODE_TO_TYPE despite being unreachable from today's only emitter (per-project errors render through formatter.warning(), not formatter.error()). NIT-1 (human table omits the per-workspace breakdown) not taken: adding a verbose breakdown row is scope beyond #594's ask, and --json already carries both fields. --- .../kbagent/references/billing-workflow.md | 14 +++++++++----- src/keboola_agent_cli/client/_core.py | 15 ++++++++++++--- src/keboola_agent_cli/client/billing.py | 2 +- src/keboola_agent_cli/errors.py | 6 ++++++ tests/test_billing_client.py | 19 +++++++++++++++++++ 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/plugins/kbagent/skills/kbagent/references/billing-workflow.md b/plugins/kbagent/skills/kbagent/references/billing-workflow.md index e51b6973..c4056263 100644 --- a/plugins/kbagent/skills/kbagent/references/billing-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/billing-workflow.md @@ -78,11 +78,15 @@ consumed_minutes = consumed * 60 remaining_minutes = remaining * 60 ``` -Money is a separate axis again: PAYG credits are purchased at a fixed rate -per stack/contract (observed in issue #594: **$8.40 ex. VAT per credit**). A -purchase of 8 credits is what the Keboola UI shows as **"480 minutes -($67.20)"** — the CLI has no price field; if a user needs the dollar amount, -that comes from their contract/invoice, not from `billing credits`. +Money is a separate axis again, and this is where it is easy to mislead a +user. The credits-to-minutes factor above is invariant; **the price per credit +is not**. It is contract- and stack-specific, the API never returns it, and the +CLI has no price field. Issue #594 records **$8.40 ex. VAT per credit** on one +contract — that is a single historical observation, NOT a platform constant. +Never quote it to a user as their rate, and never derive a dollar figure from +it. It appears here only to make the UI's arithmetic legible: on that contract, +8 credits rendered as "480 minutes ($67.20)". If a user needs the money number, +it comes from their contract or invoice, not from `billing credits`. ## The PAYG gate diff --git a/src/keboola_agent_cli/client/_core.py b/src/keboola_agent_cli/client/_core.py index 58702eae..8c38774c 100644 --- a/src/keboola_agent_cli/client/_core.py +++ b/src/keboola_agent_cli/client/_core.py @@ -164,8 +164,8 @@ def _sync_actions_request(self, method: str, path: str, **kwargs: Any) -> httpx. method, path, client=client, base_url=self._sync_actions_base_url, **kwargs ) - def _billing_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: - """Execute a Billing API request with retry. + def _billing_get(self, path: str, **kwargs: Any) -> httpx.Response: + """Execute a read-only Billing API request with retry. The billing service is a sibling host derived from the stack URL (``billing.{stack-suffix}``); the sub-client inherits the main @@ -173,10 +173,19 @@ def _billing_request(self, method: str, path: str, **kwargs: Any) -> httpx.Respo stacks without Pay-As-You-Go the host may not resolve at all (DNS failure) -- callers should feature-gate with ``has_feature()`` before reaching this method rather than relying on the resulting error. + + Deliberately NOT shaped like its ``_queue_request`` / + ``_sync_actions_request`` siblings, which take an arbitrary ``method``: + the billing service exposes ``POST /credits``, which charges real money + by triggering an automatic top-up. Hardcoding the verb here means a + future caller cannot construct that request through this dispatcher at + all -- a guarantee in the signature, which no source-scanning test can + match. If a write to the billing service is ever wanted, it needs its + own method, its own review, and its own confirmation flow. """ client = self._get_or_create_sub_client("_billing_client", self._billing_base_url) return self._do_request( - method, path, client=client, base_url=self._billing_base_url, **kwargs + "GET", path, client=client, base_url=self._billing_base_url, **kwargs ) def _wait_for_storage_job( diff --git a/src/keboola_agent_cli/client/billing.py b/src/keboola_agent_cli/client/billing.py index dfcfd620..5f44fdb6 100644 --- a/src/keboola_agent_cli/client/billing.py +++ b/src/keboola_agent_cli/client/billing.py @@ -25,5 +25,5 @@ def get_credits(self) -> dict[str, Any]: the service layer). Read-only: this is the only method this mixin exposes, by design -- see the module docstring. """ - response = self._billing_request("GET", "/credits") + response = self._billing_get("/credits") return response.json() diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index 7f38c1d2..f499a0f1 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -324,6 +324,12 @@ def __init__(self, feature: str, *, remedy: str = "") -> None: ErrorCode.SESSION_EXPIRED: "authentication", ErrorCode.SESSION_NOT_FOUND: "authentication", ErrorCode.AUTH_MFA_INVALID: "authentication", + # Not reachable from today's only emitter: `BillingService` puts + # PAYG_NOT_AVAILABLE in its per-project `errors` list, which renders via + # `formatter.warning()` and never passes through `formatter.error()`. + # Classified anyway so the first single-project billing command to raise + # it inherits the right category instead of silently taking the "api" + # default -- a missing project feature is a configuration problem. ErrorCode.PAYG_NOT_AVAILABLE: "configuration", } diff --git a/tests/test_billing_client.py b/tests/test_billing_client.py index 0fb9ad62..98fd9215 100644 --- a/tests/test_billing_client.py +++ b/tests/test_billing_client.py @@ -23,6 +23,7 @@ import pytest from keboola_agent_cli.client import KeboolaClient +from keboola_agent_cli.client._core import _CoreClient from keboola_agent_cli.client.billing import _BillingMixin from keboola_agent_cli.models import ( CreditStats, @@ -223,6 +224,24 @@ def test_get_credits_never_sends_a_post_request(self, httpx_mock) -> None: for request in httpx_mock.get_requests(): assert request.method != "POST" + def test_billing_dispatcher_takes_no_http_method(self) -> None: + """The signature itself, not just this module's source, forbids a POST. + + The source-scan above only covers `client/billing.py`. A future caller + elsewhere could reach the dispatcher directly, so the dispatcher takes + no `method` argument at all -- unlike its `_queue_request` / + `_sync_actions_request` siblings. Pinned here because reintroducing a + `method` parameter would silently reopen the real-money path. + """ + assert not hasattr(_CoreClient, "_billing_request"), ( + "_billing_request is back: a caller can now pass method='POST' to the " + "billing host, which triggers a real-money automatic top-up." + ) + params = list(inspect.signature(_CoreClient._billing_get).parameters) + assert params == ["self", "path", "kwargs"], ( + f"_billing_get must stay (self, path, **kwargs) with the verb hardcoded; got {params}" + ) + if __name__ == "__main__": pytest.main([__file__, "-v"]) From be29e5d94078d4c88a7ceb3bf1f20c60c3c13017 Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 17 Aug 2026 16:55:16 -0400 Subject: [PATCH 4/4] fix(billing): rename the derived `purchased` row field to `total` Devin review: `consumed + remaining` equals the amount purchased only if every credit ever added is either still available or already consumed, so against expired, revoked, or promo credits the field would silently under- or over-report while looking authoritative -- and the human table's "Purchased" column header asserted it hardest. The name was worse than a generic inaccuracy in this specific command: credit PURCHASES are exactly what #594 is about not being able to reach. Someone reconciling Stripe invoices could have taken this derived figure for the purchase total the CLI explicitly cannot produce. Renamed everywhere (row key, table column, docs, tests) with the assumption now stated at the derivation site, in the command help, and in billing-workflow.md. Unreleased, so no consumer contract breaks. --- .../kbagent/references/billing-workflow.md | 13 ++++++++++--- src/keboola_agent_cli/changelog.py | 2 +- src/keboola_agent_cli/commands/billing.py | 11 ++++++----- .../services/billing_service.py | 17 +++++++++++++---- tests/test_billing_cli.py | 4 ++-- tests/test_billing_service.py | 6 +++--- tests/test_e2e.py | 4 ++-- tests/test_server_router_calls.py | 2 +- 8 files changed, 38 insertions(+), 21 deletions(-) diff --git a/plugins/kbagent/skills/kbagent/references/billing-workflow.md b/plugins/kbagent/skills/kbagent/references/billing-workflow.md index c4056263..5627b4a9 100644 --- a/plugins/kbagent/skills/kbagent/references/billing-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/billing-workflow.md @@ -31,7 +31,7 @@ kbagent --json billing credits --project prod --project staging "project_id": 9621, "consumed": 12.5, "remaining": 25.5, - "purchased": 38.0, + "total": 38.0, "consumed_minutes": 750.0, "remaining_minutes": 1530.0, "component_jobs_consumed": 11.75, @@ -45,7 +45,7 @@ kbagent --json billing credits --project prod --project staging "project_id": null, "consumed": 0.0, "remaining": 0.0, - "purchased": 0.0, + "total": 0.0, "consumed_minutes": 0.0, "remaining_minutes": 0.0, "component_jobs_consumed": 0.0, @@ -62,7 +62,14 @@ kbagent --json billing credits --project prod --project staging `billing.{stack}` host, which -- unlike the invoice endpoints below -- **does** accept the CLI's normal per-project `X-StorageApi-Token`. No manage token, no extra login step. -- `purchased` is a client-side convenience: `consumed + remaining`. +- `total` is a client-side convenience: `consumed + remaining`. It is NOT + named `purchased`, and must not be reported as an amount purchased. It + equals the purchased amount only if every credit ever added is either + still available or already consumed, so it silently mis-reports against + expired, revoked, or promo credits -- and credit purchases are precisely + what this command cannot see (see the gap section below). If a user asks + "how much have we bought", the honest answer is that the CLI cannot tell + them, not this number. - Per-project failures land in `errors`, never abort the run -- always check both arrays, not just `credits`. diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 7ac6097a..36ffc154 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -29,7 +29,7 @@ "credit balance, fanned out across every registered project in parallel. Wraps `GET " "/credits` on the `billing.{stack}` host, which accepts a plain per-project Storage " "token -- the endpoint was reachable all along and simply had no command over it. Rows " - "carry the API's native unit (credits: `consumed` / `remaining` / derived `purchased`) " + "carry the API's native unit (credits: `consumed` / `remaining` / derived `total`) " "AND derived minutes, because the Keboola UI displays minutes = credits x 60; the " "conversion only ever runs in that direction, so a unit bug cannot invert into a wrong " "credit figure. The per-workspace breakdown is parsed tolerantly: the live API returns " diff --git a/src/keboola_agent_cli/commands/billing.py b/src/keboola_agent_cli/commands/billing.py index b290a4a9..560f161d 100644 --- a/src/keboola_agent_cli/commands/billing.py +++ b/src/keboola_agent_cli/commands/billing.py @@ -2,7 +2,7 @@ Thin CLI layer over :class:`BillingService`. One subcommand: -- ``billing credits`` -- current credit balance (consumed/remaining/purchased) +- ``billing credits`` -- current credit balance (consumed/remaining/total) across one or more projects, sourced from ``GET /credits`` on the ``billing.`` host. @@ -42,7 +42,7 @@ def _format_credits_table(formatter: Any, credits: list[dict[str, Any]]) -> None "Project", "Remaining", "Consumed", - "Purchased", + "Total", "Remaining (min)", show_header=True, header_style="bold cyan", @@ -52,7 +52,7 @@ def _format_credits_table(formatter: Any, credits: list[dict[str, Any]]) -> None escape(row.get("project_alias", "")), f"{row.get('remaining', 0.0):.2f}", f"{row.get('consumed', 0.0):.2f}", - f"{row.get('purchased', 0.0):.2f}", + f"{row.get('total', 0.0):.2f}", f"{row.get('remaining_minutes', 0.0):.0f}", ) formatter.console.print(tbl) @@ -77,8 +77,9 @@ def billing_credits( ) -> None: """Show the current PAYG credit balance for one or more projects. - Balance only -- consumed, remaining, and purchased (derived as - consumed + remaining) credits, plus the same figures expressed in + Balance only -- consumed, remaining, and total (a client-side + `consumed + remaining`, NOT a purchase figure: credit purchases are + exactly what this command cannot see), plus the balance expressed in minutes (the Keboola UI's unit: minutes = credits * 60). A project without the `pay-as-you-go` feature flag surfaces as a per-project warning (`PAYG_NOT_AVAILABLE`), not a hard failure -- one non-PAYG diff --git a/src/keboola_agent_cli/services/billing_service.py b/src/keboola_agent_cli/services/billing_service.py index 65742317..14a65a2c 100644 --- a/src/keboola_agent_cli/services/billing_service.py +++ b/src/keboola_agent_cli/services/billing_service.py @@ -41,8 +41,17 @@ def _build_credit_row( """Project a parsed ``ProjectCredits`` payload into the CLI-facing row. Derives minutes from credits (never the reverse -- the API's native unit - is credits) and ``purchased`` as ``consumed + remaining``, since the - billing endpoint reports the current balance, not the lifetime total. + is credits) and ``total`` as ``consumed + remaining``. + + ``total`` is deliberately NOT called ``purchased``. It is a client-side + sum over the CURRENT balance, so it equals the amount purchased only if + every credit ever added is either still available or already consumed -- + it would silently mis-report against expired, revoked, or promo credits. + Naming it ``purchased`` would be actively misleading here, because credit + PURCHASES are the one thing this command cannot see: those records live + behind ``/pay-as-you-go/billing/*``, which no project token can reach + (issue #594). Anyone reconciling against Stripe invoices must not treat + this figure as a purchase total. """ stats = credits_.stats component_jobs_consumed = ( @@ -62,7 +71,7 @@ def _build_credit_row( "project_id": project.project_id, "consumed": credits_.consumed, "remaining": credits_.remaining, - "purchased": credits_.consumed + credits_.remaining, + "total": credits_.consumed + credits_.remaining, "consumed_minutes": credits_.consumed * MINUTES_PER_CREDIT, "remaining_minutes": credits_.remaining * MINUTES_PER_CREDIT, "component_jobs_consumed": component_jobs_consumed, @@ -90,7 +99,7 @@ def get_credits(self, aliases: list[str] | None = None) -> dict[str, Any]: ``{"credits": [row, ...], "errors": [entry, ...]}``, both sorted by ``project_alias`` for deterministic output. Each row has ``project_alias``, ``project_id``, ``consumed``, ``remaining``, - ``purchased``, ``consumed_minutes``, ``remaining_minutes``, + ``total``, ``consumed_minutes``, ``remaining_minutes``, ``component_jobs_consumed``, ``workspace_jobs``. """ projects = self.resolve_projects(aliases) diff --git a/tests/test_billing_cli.py b/tests/test_billing_cli.py index 64c12b0b..7f3f07eb 100644 --- a/tests/test_billing_cli.py +++ b/tests/test_billing_cli.py @@ -55,7 +55,7 @@ def _credit_row(alias: str = "prod") -> dict[str, Any]: "project_id": 1234, "consumed": 100.5, "remaining": 25.5, - "purchased": 126.0, + "total": 126.0, "consumed_minutes": 6030.0, "remaining_minutes": 1530.0, "component_jobs_consumed": 95.25, @@ -85,7 +85,7 @@ def test_human_mode_renders_table_with_balance_and_minutes(self, tmp_path: Path) assert "prod" in result.output assert "25.50" in result.output # remaining assert "100.50" in result.output # consumed - assert "126.00" in result.output # purchased + assert "126.00" in result.output # total assert "1530" in result.output # remaining minutes def test_project_flag_repeatable_forwarded_as_aliases(self, tmp_path: Path) -> None: diff --git a/tests/test_billing_service.py b/tests/test_billing_service.py index 5b0c063b..6f1019b4 100644 --- a/tests/test_billing_service.py +++ b/tests/test_billing_service.py @@ -98,7 +98,7 @@ def test_live_payload_produces_exact_row(self) -> None: assert row["project_id"] == 123 assert row["consumed"] == 100.5 assert row["remaining"] == 25.5 - assert row["purchased"] == 126.0 # 100.5 + 25.5 + assert row["total"] == 126.0 # 100.5 + 25.5 assert row["consumed_minutes"] == 100.5 * 60 assert row["remaining_minutes"] == 25.5 * 60 assert row["component_jobs_consumed"] == 95.25 @@ -343,7 +343,7 @@ def test_payload_without_stats_zeroes_breakdown(self) -> None: row = result["credits"][0] assert row["consumed"] == 10.0 assert row["remaining"] == 5.0 - assert row["purchased"] == 15.0 + assert row["total"] == 15.0 assert row["component_jobs_consumed"] == 0.0 assert row["workspace_jobs"] == [] @@ -359,6 +359,6 @@ def test_completely_empty_payload_does_not_raise(self) -> None: row = result["credits"][0] assert row["consumed"] == 0.0 assert row["remaining"] == 0.0 - assert row["purchased"] == 0.0 + assert row["total"] == 0.0 assert row["component_jobs_consumed"] == 0.0 assert row["workspace_jobs"] == [] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 837f3400..fb44139e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -5400,14 +5400,14 @@ def test_billing_credits_returns_well_formed_envelope(self) -> None: "project_id", "consumed", "remaining", - "purchased", + "total", "consumed_minutes", "remaining_minutes", "component_jobs_consumed", "workspace_jobs", ): assert key in row, f"missing key {key!r} in PAYG credit row: {row}" - assert row["purchased"] == row["consumed"] + row["remaining"] + assert row["total"] == row["consumed"] + row["remaining"] assert row["remaining_minutes"] == row["remaining"] * 60 else: # Graceful-degradation path: the project must be reported as diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index d48d894a..56ca3bbd 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -1248,7 +1248,7 @@ def test_billing_credits_returns_service_envelope_unchanged(tmp_path: Path) -> N "project_id": 123, "consumed": 100.5, "remaining": 25.5, - "purchased": 126.0, + "total": 126.0, "consumed_minutes": 6030.0, "remaining_minutes": 1530.0, "component_jobs_consumed": 95.25,