Bundled products reconciler replaces trial node

Task 5259 · Done · Coder · created · updated

## Original requirement (epic, verbatim) Fold Trial mode into Billing plans: - Trial becomes a regular Plan, that is pre-seeded but may be changed by Operator as they please - All functions of Trial become configurable parameters of the plan - Trial node: each plan may have multiple products that are coming with the plan. One of them is a Jaah-provided node - operator can pick what kind of node - same as with Trial now and the same controls (reuse) as with user adding products. They may be arbitrary number of products attached - When user is signing up he is picking the plan from offered plans that are self-served. Operator can change this plan to any other configured in the system that are active/offered, including those that are not Self-served. E.g. Operator may choose a different flavor of a Trial, such as one without a node - Operator can override Plan parameters per organization, instance of the plan - One of the goals is to make it more flexible with less specialized configuration knobs Settled inputs: BA PRD (Task 5248: R1–R9, D-1…D-9, A-1…A-11, NC-1…NC-9) and Architecture (Task 5249: DA-1…DA-6, §1–§13). Use `task_read` on 5248/5249 for the full documents; this body carries everything this child needs. This child is C4 of 9 (C1…C9) of epic "Fold Trial Mode Into Billing Plans". It builds on C1's tables and C2's `plan_terms` (effective, desired_nodes, bundle_diff, describe, wake_bundles). Binding operator decisions: D-1 bundled products included in plan price · D-2 leaving a plan → today's trial-node keep-or-stop rule generalized · D-3 paid sign-up plan paid after approval · D-4 old knobs move onto plans and are deleted; empty plan value = unlimited/off, not platform default · D-5 attachable products: Shared node (tier), Dedicated node (size), Jaah AI · D-6 per-org overrides: limits, duration/end date, bundled products, price · D-7 any plan with a duration pauses work like Trial when it ends · D-8 operator-chosen Default plan knob (seeded Trial); any plan retirable except the current default · D-9 Jaah AI bundle credits at plan start + every billing month, may be zero, no rollover · DA-1 paid subscription ends → Default plan · DA-2 plan products edit applies to all members without a products override · DA-3 duration edit is live: end = plan_started_at + duration_days unless overridden · DA-4 plan move clears ALL overrides incl. caps · DA-5 operator-created orgs get bundled products · DA-6 returning to a plan is fresh every time. Quality bar: every UI/notice string follows docs/standards/ui-copy-standards.md. A literally-compliant but confusing result fails this child. Everything is live on dev.jaah.ai after merge — no feature flag (AC9.1, R29). ## This child — scope (HOW, from Architecture Task 5249 §4 + §10 bundles API) New `apps/api/app/plan_bundles.py` replacing `trial_node.py` + `trial_hostings.py` (both deleted in this child). - Desired set (`plan_terms.desired_nodes`): empty when terms.ended; org.lifecycle != 'active' (a disabled org keeps instances); operator-plane org (NC-8); or pending_plan_key set. Else each node line → (fingerprint, ordinal) for ordinal 1..quantity. Applies to every active tenant org, self-service or operator-created (DA-5). - One pass per org: (1) DECIDE under org row FOR UPDATE: run adoption FIRST (below), then bundle_diff; claim missing instances via INSERT … ON CONFLICT DO NOTHING on the partial unique index (state provisioning); mark unwanted live/provisioning/failed instances `released` with released_at = terms.ends_at if caused by plan end else now(); un-release a released instance desired again (A-7 carry-over; restart a stopped node). Commit. (2) ACT without lock: provision each claimed instance; every later write is compare-and-set (`WHERE state='provisioning'`). (3) `failed` never retried by cron — only operator Retry. One instance failing never blocks approval or others (each commits alone). Operator + tenant-safe sentences move from trial_node.py unchanged. - Provisioning: Shared = today's `_place_on_pool` (lock_pool → pick_shared_host → vnodes.place_virtual_node) + NodeHosting(kind='bundled', billing='free', status='active', shared_price_id=<tier row>). Dedicated = NodeHosting(kind='bundled', billing='free', instance_type, storage_gb) + new `hosting_provisioning.provision_free(session, hosting)` running `_launch_dedicated` with no order stage and no Stripe call; credential `rental_aws_credential_id`. Retry per instance with today's `claim_retry` rules (`_leftover` kept). Cap `PROVISION_PER_PASS = 10`. - Jaah AI credit (D-9): while not ended and pending_plan_key NULL, period n start = plan_started_at + n calendar months (UTC, day clamped). One grant per org per period = Σ credit_cents × quantity × 10 000 micro-USD via `token_billing.ledger.credit(kind=<existing free-credit kind used by signup credit>, idempotency_key=f"plan-credit:{org_id}:{plan_started_at.isoformat()}:{n}", description=f"Included with {plan_name}")`. Current period only, no backfill; zero ⇒ no entry; mid-period raise takes effect next period; plan move re-stamps → new key space; nothing clawed back. - Driver: cron `plan-bundles` interval 120s, full pass over active tenant orgs, wakeable via `plan_terms.wake_bundles()` (make it real now). No dirty flag. `rental-lifecycle` registry: `trial_hostings.run` → `plan_bundles.lifecycle_rule`. - Released lifecycle (§4.6): live with plan end ahead (ends_at−3d ≤ now < ends_at) ⇒ warn `rental.bundle_ending` deduped per (instance, ends_at); released ∧ now ≥ released_at ⇒ warn "stops on <released_at+grace>"; ≥ released_at + rental_grace_days ⇒ stop, hosting stopped, stop_cause='plan_end'; hosting stopped ≥ stopped_at + rental_retention_days ⇒ hostings.delete, instance `ended`; Keep paid ⇒ bundled hosting terminated, instance `kept`. Copy `notices.trial_copy` → `bundle_copy` ("Your plan no longer includes Shared node · Tier S. It stops on <date> unless you keep it."), email via existing notices outbox. - Keep = node-order path `bundle_keep` (generalized trial_convert): `hostings.start(..., existing_node_id, kind='shared'|'dedicated')` at catalogue price; extend `_choose`/`start` so Dedicated accepts existing_node_id (no reservation, no launch). `/api/trial-node/convert` routes to it until C9. - Adoption in code every pass until C9 (§4.7), FIRST inside the locked step: each org_trial_nodes row with no instance ⇒ instance shared_node:shared ordinal 1; state: node carried by a paid Shared hosting (active/past_due/cancelling) ⇒ kept (and, when the org's plan has a Shared line and no products override, set products_overridden=true with no lines + one history row); node gone ⇒ live (reads `removed`, Retry only); node present ⇒ live; else the row's provisioning/failed. Each kind='trial' hosting ⇒ kind='bundled' (stop_cause trial_end → plan_end) and linked; a second free hosting on the same node_id ⇒ terminated. - Remove `trial_node.on_approved`'s call in `org_approval` in this same change; remove C2's trial_ends_at dual-write; delete knobs `trial_length_days`, `trial_node_placement`, `trial_node_aws_credential_id`, `trial_node_instance_type`, `trial_node_mnh_host_id` and `hosting_provisioning._FALLBACK_CREDENTIAL_KNOB` (fallback reads `rental_aws_credential_id`, which must be visible under Billing > Hosting — AC8.3). - Keep `/me` `MeOrgRef.trial_node` (read by `apps/web/src/pages/Nodes.tsx` placeholder/failed rows) served, computed from bundles, until C9; the member-facing tenant-safe failure sentence must never disappear. - Reword the `rental_aws_credential_id` help in `operator_settings.py` (today "Unset, the trial node's account.") to not mention the trial node. - Make the `kind == 'bundled'` label branches in `hosting_lifecycle.py` and `billing/hostings.py` here too (C3 may land later), so adopted hostings never render as "host ()". - Bundles API (pinned for C7/C8): `GET /api/orgs/{id}/bundles` (operator) | `GET /api/org/bundles` (tenant, tenant-safe note) → BundleOut[] = {id, kind, label, ordinal, status, note, node_id, hosting_id, stops_at, keep:{offered, monthly_cents}}; `POST /api/orgs/{id}/bundles/{bundle_id}/retry` (operator); `POST /api/bundles/{bundle_id}/keep` (tenant Owner/Admin) → node order response. Status vocabulary (§4.8): provisioning (≤30 min) · failed (or provisioning >30 min; Retry) · ready (live, node exists) · removed (live, node gone; Retry) · stopping (released, hosting active; stops_at; Keep) · stopped (released, hosting stopped; Keep) · kept · terminated (ended; listed 7 days). ## Acceptance criteria (PRD, verbatim) carried by this child - AC1.3 Renaming Trial, or creating a second plan "Trial — no node" with identical parameters minus the node, behaves identically to the original for an org assigned to it (except the node). Verify: on dev create plan "Trial lite" (duration 14d, no products); assign to a test org; observe its end date, caps on the org's Limits tab, and no node created. - AC3.4 A plan with zero products is valid ("a Trial without a node"). Verify: AC1.3's "Trial lite". - AC4.1 On approval of an org whose plan is free, and on a priced plan's subscription becoming paid/active (A-6), each bundled node line provisions `quantity` nodes of its configured kind; a Jaah AI line credits its amount (zero = no ledger entry) at plan start and on each monthly anniversary while on the plan (D-9). Verify: on dev approve a test org on a plan with 1× Shared + Jaah AI $5 → Nodes page shows a node reaching ready; Jaah AI balance shows +$5 with a ledger line naming the plan. - AC4.3 (part: API) Failure is visible and retryable by the operator exactly like today's trial node (operator sentence on the org page, tenant-safe sentence for members, Retry from `failed`/`not provisioned` only); one product failing does not block the org's approval or the other products. - AC4.4 Idempotency: approval/payment/retry never creates more instances than `quantity` (today's "one trial node ever" guarantee generalized per product line). Verify: automated test double-firing approval and retry; and on dev, double Retry → one node. - AC4.5 Leaving a plan (operator change, Stripe change, downgrade) or the plan's duration ending (D-7): bundled instances the new plan doesn't include (A-7) get today's trial-node lifecycle — warnings in-app and by email, "Keep" offered as a paid product at catalogue price (generalized `trial_convert`), stop at end + `rental_grace_days`, terminate after `rental_retention_days`. Instances matching a new plan's product carry over untouched. - AC4.6 Jaah AI monthly credit stops on leaving the plan; already-granted credit is not clawed back. Verify: automated test over the month boundary + plan change. - AC8.1 (part: 5 knobs) Deleted from Settings and from `operator_settings.py`: `trial_length_days`, `trial_node_placement`, `trial_node_aws_credential_id`, `trial_node_instance_type`, `trial_node_mnh_host_id`. The Settings sections "Trial" and "Trial node" disappear. - AC8.3 The AWS credential previously on `trial_node_aws_credential_id` still governs where hosted machines launch when no hosting credential is set — moved to its living home (Billing > Hosting), not lost. Verify: the knob is present under Billing > Hosting with the migrated value. - DA-2 plan products edit applies to all members without a products override; DA-5 operator-created orgs get bundles; DA-6 fresh every time. ## Out of scope: Do NOT change `pick_shared_host`/placement/isolation (NC-3), `rental_prices` (NC-2), ledger/top-up mechanics (NC-5), notification channels (NC-9). Do NOT touch Stripe entitlement (C3), sign-up (C5) or web UI (C7/C8 consume your API). Do NOT add Alembic revisions; do NOT drop org_trial_nodes or 'trial' CHECK values (C9). Epic out of scope (PRD §5, verbatim): - NC-1 Stripe money model: monthly USD, Stripe owns the price; no new intervals/currencies, no proration redesign. `billing/plans.py::push_price` mechanics unchanged except generalizing the per-org custom price beyond Enterprise. - NC-2 The hosting price catalogue (`rental_prices`), tiers/sizes and their editor — read by bundles, not changed. - NC-3 Placement/isolation of Shared nodes (`pick_shared_host`, ADR-0130) — reused as is. - NC-4 Abuse quotas, cron minimum period, sign-up knobs, `rental_grace_days`/`rental_retention_days`, hosting offering switches (A-9) stay global knobs. - NC-5 Jaah AI wallet/top-up/refund mechanics (`billing/jaah_credit.py`, `token_billing`) — bundles only add a monthly credit entry; no change to top-ups, auto-recharge or refunds. - NC-6 No change to the approval policy itself (manual review, auto-approve domains and their daily cap). - NC-7 No new product types beyond D-5 (no storage-only, GitHub runner, or self-hosted products as bundles). - NC-8 Operator orgs (org zero / own machines) keep their exemptions; bundles never provision into them. - NC-9 No forge/Slack/notification-channel redesign; reuse existing billing notices for warnings. Deferred (do NOT build): abuse quotas as plan parameters; Jaah AI credit rollover; annual plans; a configurable bundle provision throttle; tenant self-service switching between free plans. Done means: - Tests: an org with an existing trial node gets ZERO new provisions on the first pass (adoption order); double approval + double retry ⇒ ≤ quantity instances; carry-over of same fingerprint across a plan move (no release); released→stop→ended timeline with grace/retention; Dedicated provision makes zero gateway calls; credit granted once per period, idempotent across passes, zero line ⇒ no entry, stops after plan move, no clawback. - `ls apps/api/app/trial_node.py apps/api/app/trial_hostings.py` → both absent; `grep -rn "trial_length_days\|trial_node_placement\|trial_node_aws_credential_id\|trial_node_instance_type\|trial_node_mnh_host_id" apps/` → only migrations — cite. - On dev: approve a test org on a plan with 1× Shared + Jaah AI $5 → node reaches ready, +$5 ledger line "Included with <plan>"; create "Trial lite" with no products, assign → no node, same end/caps; rename Trial → org behaviour unchanged. - AC4.4 on dev: two back-to-back `POST /api/orgs/{id}/bundles/{bundle_id}/retry` calls on a failed bundle → one node (the UI double-click is C7's). - AC4.5 on dev: an org moved off a bundling plan gets the warning email in the notices outbox; with `rental_grace_days`=0 and no Keep, the node stops on the next `rental-lifecycle` pass — cite. - AC8.3: Billing > Hosting on dev shows the AWS credential knob with the migrated value — cite. - AC9.2: after this child is live on dev, re-snapshot every tenant org's `/limits`, `/plan` and bundle status and diff against C1's baseline artifact (read it with `task_read_artifact` on C1's task) — diff empty except vocabulary renames (trial node → bundle status); cite it. - `./dc check full` green; PR merged into develop; the change is live on dev.jaah.ai with no flag (AC9.1). - Rendered report cites file:line per AC above and the observed output of each oracle.

Outcome

plan_bundles.py replaces trial_node/trial_hostings: bundles, credit, keep-or-stop lifecycle, bundles API, plan-bundles cron (0376); 5 knobs deleted. Full gate green; #4636 + AC9.2 fix #4640 merged, live on dev, AC9.2 diff vocabulary-only.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Awaiting response
  • Awaiting response
  • Awaiting response
  • Runner resumed
  • Runner ended (done)