Comps engine and products feed

Task 6067 · Done · Coder · created · updated

Epic ask (verbatim): "file an epic to create Customers section where operator can see all customers and fully manage their parameters (move all from Directory/Org) and fully manage all of their billing and products. Operator should be able to add/change/remove any products (without or with billing), see all history, etc. UI must be compliant with best practices and Jaah Standard (feed-like, tabbed pages, no prose on page, etc.)" Settled inputs: PRD = Task 6049, Architecture = Task 6050 (`task_read` both; architecture §3.3 + §3.4 are your pinned contracts — follow them exactly). This is child **C6a**: the architecture's C6 split in two at planning (critic gate: one fence was too large for one review lane). C6a = the without-billing half + the products feed + ALL of C6's schema; **C6b** (a later sibling) adds every billed/Stripe path on top of your router. Requirement (verbatim, Req 4): "Products: the operator can add, change and remove ANY product for a customer (hostings/machines, plan features, add-ons, etc.). Each action can be done with billing (charged/prorated/refunded through Stripe) or without it (comped/manual, clearly marked as such)." Requirement (verbatim, Req 8): "Every operator write is audited and authorized on the operator plane. A tenant can never reach these endpoints." Operator decisions (binding): D2/D5 "Merge mechanics with Trial products" — a without-billing add is a free product line on the plan-bundles engine (`app/plan_bundles.py`, free hostings `kind=bundled, billing=free`, monthly Jaah AI credit) with an end **never / a date / the plan's end**; at its end the existing lifecycle runs: warn → stop after grace → delete, with **Keep**. Plan-included products are marked **Included**, operator comps **Comped**. D8: "without billing" remove exists only for Comped/Included products. Acceptance (verbatim from PRD; your half): - AC4.1 Each product row shows: name, size/details, state, how it is paid — **Billed** (price/period from Stripe), **Included** (from the plan), or **Comped** (operator, with its end) — and, for a comp, the end date or "Never". (API feed fields, including billed hostings already in `node_rentals`.) - AC4.3 Add without billing: the operator picks a product and an end (Never / a date / plan's end) → provisioned free through `plan_bundles` (`kind=bundled, billing=free`). Zero Stripe calls. Marked Comped with its end. When the end arrives, the existing lifecycle runs (warn → stop after grace → delete, with Keep). Oracle: API test with a Stripe gateway spy → zero gateway calls. - AC4.4 (your half) resize a comp (change the comp line, no Stripe); change a comp's end; feature switches appear in the feed with their effective value (toggle = existing `PUT /api/orgs/{org_id}/plan`). - AC4.6 (reshaped by D8) comp remove and Included remove → zero gateway calls. - AC8.1 operator-plane only (`require_operator`); reads `finance:view`, writes `finance:edit`; tenant 403/404; operator without `finance:edit` refused on writes. - AC8.2 one `record_write` per write with `data = {"org_id", "mode": "without_billing", "before", "after", "refs", "request_id"}`; idempotent by `request_id`. - AC8.3 without billing never hits Stripe — gateway-spy tests for every action in this child. HOW (pinned): - ONE migration (the only one in its wave): table `org_comps` (`_PlanProductLine` columns + `EntityMixin`, `OrgScoped`; `end_mode` never|date|plan, `ends_at` required iff date, `ended_at`, `ended_by_id`), additive to the plan/override list (never trips `products_overridden`); `org_bundled_products.comp_id uuid null` FK → `org_comps.id` SET NULL; AND `node_rentals.first_invoice_due_at timestamptz null` (unused until C6b — placed here so C6b needs no migration). - `plan_bundles.reconcile_org`: desired = plan/override lines (fingerprint-keyed) ∪ live comp lines (comp_id-keyed); `copy_end(copy)`: plan copy → `plan_terms.ends_of(...)`; comp copy → never/ends_at/plan end; `lifecycle_rule` and `_step` read `copy_end`; Keep (`bundle_keep`) per comp unchanged; Jaah AI credit comps grant monthly credit like a plan credit line. - New router `apps/api/app/routes/customers.py`: `GET /api/customers/{org_id}/products` → `{"items":[Product]}` with the §3.4 Product shape (`id`, `type` hosting|comp|included|switch|credit, `name`, `details`, `state`, `paid` billed|included|comped|none, `price` (Stripe mirror/catalogue only), `ends`, `invoice`, `rental_id`, `node_id`, `actions`); `GET .../catalog` (`hosting_catalog.offerings(session, org_id)` + credit products); `POST .../products` with `mode: "comped"` (+ `comp_end`) — insert `org_comps` + `plan_terms.wake_bundles`, zero gateway calls (C6b adds `mode: "billed"`; until then a billed body → 422); `PATCH .../products/comp/{id}` ops `end` and `resize`; `DELETE .../products/{comp|credit|included}/{id}?request_id=` (comp/credit: end now, copy released → machine terminated; included: `org_plan_admin.set_products` without that line; `money` given → 422). Per-product addressing `/api/customers/{org_id}/products/{type}/{id}` exactly as §3.4. Assert `org_id` ownership before every write. - ADR entry in `docs/adrs/`: "comps are plan-bundle lines with their own end". Credentials & reach: - Operator dev oracles: `jaah/debug/main/operator-login` on `https://app.dev.jaah.ai` — verified. - API tests, gateway-spy tests (fake gateway `billing/gateway.py` `install_fake`), `./dc check full`: worktree only — verified. Out of scope: Do NOT build any billed/Stripe path — billed add, operator invoice branch, overdue sweep, `invoice.voided`, billed Jaah AI credit, `hostings.delete(money=)`, removal-quote, resize_quote/confirm, to_billed, to_comped are C6b's; do NOT build any web UI (C7); do NOT change tenant routes `routes/org_billing.py`, `routes/hostings.py` or tenant Billing pages; do NOT widen `billing/events.py` `ALLOWED_EVENT_TYPES`; do NOT build the History table (C1) or roster (C4); no new pricing models or Stripe Products/Prices; no money arithmetic; the operator never enters card numbers; must not require act-as (Task 6047); no new permission keys for tenant roles. Done means: - AC4.3/AC4.6/AC8.3 — pytest with a gateway spy: comped add (hosting and Jaah AI credit), comp resize, comp end change, comp remove, Included remove → zero gateway calls each; paste test names + pass output in your report. - Comp/plan collision — pytest: a plan line + a comp of the same fingerprint → two machines, each ending on its own end. - Comp lifecycle — pytest: a `date` comp past its end → warn → stop after grace → delete via the existing rule; Keep on a comp copy follows `bundle_keep`. - AC4.1 — pytest: the feed returns billed hostings, Included copies (no `comp_id`) and Comped copies (with `comp_id` + `ends`) with the pinned fields; no price computed (only mirror/catalogue values). - AC8.1 — pytest: every route as tenant Owner → 403/404; operator without `finance:edit` → writes refused. - AC8.2 — pytest: one `record_write` row per write with the pinned `data` shape and `mode=without_billing`; same `request_id` twice → one comp. - On dev after Jaah Deploy: via the API as operator, comp-add a Shared hosting to a test customer with a date end → the products feed shows it Comped with that end. - `./dc check full` green; the PR's Jaah Deploy to dev succeeds.

Outcome

Comps engine (org_comps, per-copy end) + operator products feed/writes without billing; gateway-spy tests zero calls; full gate green; PR #5267 merged, dev deploy OK, dev oracle passed. comp_id CASCADE (not SET NULL, see report).

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)