From 04afc747c207805a0ba9d305cf01932bd6f8531a Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:23:14 -0700 Subject: [PATCH 1/6] feat(billing): list/founding plan catalog and price env vars (A-2155) --- .env.example | 16 + docs/operations/FSMA_RAILWAY_DEPLOYMENT.md | 7 +- .../ingestion/app/stripe_billing/__init__.py | 16 + .../ingestion/app/stripe_billing/plans.py | 176 ++++++-- .../ingestion/app/stripe_billing/routes.py | 62 ++- .../tests/test_stripe_billing_router.py | 46 +- .../ingestion/tests/test_stripe_plans_1342.py | 416 +++++++++++++----- .../tests/test_stripe_routes_1342.py | 78 ++-- tests/billing/test_stripe_billing.py | 174 ++++++-- 9 files changed, 738 insertions(+), 253 deletions(-) diff --git a/.env.example b/.env.example index 0e75027c1..a41325466 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,22 @@ REGENGINE_SYSTEM_METRICS_TENANT_ID= # less, and no tenant binding. Never expose this value to browser code. REGENGINE_TENANT_PROVISIONER_KEY= +# Stripe price IDs (set in Railway by humans; leave empty in example) +STRIPE_PRICE_LIST_MONTHLY= +STRIPE_PRICE_FOUNDING_MONTHLY= +STRIPE_PRICE_PILOT_STANDARD= +STRIPE_PRICE_PILOT_CONVERT_30= +# Deprecated aliases (alias period only; prefer LIST above): +# STRIPE_PRICE_GROWTH_MONTHLY= +# STRIPE_PRICE_GROWTH_ANNUAL= +# STRIPE_PRICE_SCALE_MONTHLY= +# STRIPE_PRICE_SCALE_ANNUAL= +STRIPE_PRICE_METERED_LABEL_EVENTS= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PORTAL_RETURN_URL= +# STRIPE_PORTAL_CONFIGURATION_ID= + # Dedicated Vercel-to-Railway client-IP attestation key. Set the exact same # server-only value in the Vercel frontend and Railway backend; keep it # distinct from CSRF/auth secrets and at least 32 bytes. diff --git a/docs/operations/FSMA_RAILWAY_DEPLOYMENT.md b/docs/operations/FSMA_RAILWAY_DEPLOYMENT.md index 41873b657..5debde71b 100644 --- a/docs/operations/FSMA_RAILWAY_DEPLOYMENT.md +++ b/docs/operations/FSMA_RAILWAY_DEPLOYMENT.md @@ -57,8 +57,11 @@ Use [`docs/ENV_SETUP_CHECKLIST.md`] for full inventory. This section is only the - `REDIS_URL` - `STRIPE_SECRET_KEY` - `STRIPE_WEBHOOK_SECRET` -- `STRIPE_PRICE_GROWTH_MONTHLY` -- `STRIPE_PRICE_SCALE_MONTHLY` +- `STRIPE_PRICE_LIST_MONTHLY` (per-facility list price; annual is OPEN, so no annual var yet) +- `STRIPE_PRICE_FOUNDING_MONTHLY` (per-facility founding design partner price) +- `STRIPE_PRICE_PILOT_STANDARD` (one-time pilot fee) +- `STRIPE_PRICE_PILOT_CONVERT_30` (one-time pilot convert fee) +- `STRIPE_PRICE_METERED_LABEL_EVENTS` (per-label metered add-on) - `ADMIN_SERVICE_URL` (public URL of `admin-service`) - `CORS_ORIGINS` (include `https://regengine.co`) - `REGENGINE_INFLOW_WORKBENCH_PATH` (optional fallback only; Alembic v073 stores production workbench data in Postgres) diff --git a/services/ingestion/app/stripe_billing/__init__.py b/services/ingestion/app/stripe_billing/__init__.py index 6cebb8e03..962c85c9e 100644 --- a/services/ingestion/app/stripe_billing/__init__.py +++ b/services/ingestion/app/stripe_billing/__init__.py @@ -73,10 +73,18 @@ DEFAULT_PORTAL_RETURN_URL, DEFAULT_SUCCESS_URL, METERED_ADDONS, + PILOT_CONVERT_WINDOW_DAYS, + PILOT_DURATION_DAYS, + PILOT_FEE_CONVERT_30_CENTS, + PILOT_FEE_STANDARD_CENTS, + PILOT_PRICE_ENV_CONVERT_30, + PILOT_PRICE_ENV_STANDARD, PLAN_ALIASES, PLANS, _normalize_billing_period, _normalize_plan_id, + _plan_for_stripe_price, + _resolve_pilot_price_id, _resolve_price_id, ) from .routes import ( @@ -153,6 +161,12 @@ "PLANS", "PLAN_ALIASES", "METERED_ADDONS", + "PILOT_FEE_STANDARD_CENTS", + "PILOT_FEE_CONVERT_30_CENTS", + "PILOT_DURATION_DAYS", + "PILOT_CONVERT_WINDOW_DAYS", + "PILOT_PRICE_ENV_STANDARD", + "PILOT_PRICE_ENV_CONVERT_30", # State "_buffer_pending_subscription_update", "_customer_lookup_key", @@ -182,6 +196,8 @@ # Plans (functions) "_normalize_billing_period", "_normalize_plan_id", + "_plan_for_stripe_price", + "_resolve_pilot_price_id", "_resolve_price_id", # Customers "_create_customer_for_tenant", diff --git a/services/ingestion/app/stripe_billing/plans.py b/services/ingestion/app/stripe_billing/plans.py index 01d1b500a..77c7c2766 100644 --- a/services/ingestion/app/stripe_billing/plans.py +++ b/services/ingestion/app/stripe_billing/plans.py @@ -7,55 +7,96 @@ from fastapi import HTTPException +# Transitional aliases for the old flat-tier catalog. Under per-facility +# pricing the facility count is the subscription quantity, so ``scale`` no +# longer needs a separate 5-facility SKU; both legacy tiers collapse to +# ``list``. ``premium`` was never a self-serve SKU — keep it sales-only. PLAN_ALIASES = { - "starter": "growth", - "professional": "scale", - "base": "growth", - "standard": "scale", + "growth": "list", + "base": "list", + "starter": "list", + "scale": "list", + "standard": "list", + "professional": "list", + "premium": "enterprise", +} + +# Deprecated env var for the pre-A-2155 growth monthly price. Accepted as a +# fallback only during the alias period; new deploys set the LIST var. +DEPRECATED_LIST_PRICE_ENV_MONTHLY = "STRIPE_PRICE_GROWTH_MONTHLY" + +# Pilot pricing is one-time, not a recurring self-serve plan. Amounts live +# outside the PLANS catalog on purpose: checkout quantity and webhook credit +# handling ship in follow-up PRs. +PILOT_FEE_STANDARD_CENTS = 200000 +PILOT_FEE_CONVERT_30_CENTS = 150000 +PILOT_DURATION_DAYS = 90 +PILOT_CONVERT_WINDOW_DAYS = 30 + +PILOT_PRICE_ENV_STANDARD = "STRIPE_PRICE_PILOT_STANDARD" +PILOT_PRICE_ENV_CONVERT_30 = "STRIPE_PRICE_PILOT_CONVERT_30" + +_PILOT_PRICE_ENV_BY_VARIANT = { + "standard": PILOT_PRICE_ENV_STANDARD, + "convert_30": PILOT_PRICE_ENV_CONVERT_30, } # ── Plan Definitions ────────────────────────────────────────────── +# A-2155/A-2158 packaging. List and founding are billed per facility: the +# subscription quantity carries the facility count (wired up in a follow-up +# PR), so ``limits.facilities`` is -1 ("quantity-driven") rather than a +# hardcoded seat count. CTE / event caps remain OPEN for approval — keep the +# prior growth-level 50000 until product signs off; do not invent new caps. PLANS: dict[str, dict[str, Any]] = { - "growth": { - "id": "growth", - "name": "Growth", + "list": { + "id": "list", + "name": "RegEngine List", "price_monthly": 999, - "price_annual": 832, - "stripe_price_env_monthly": "STRIPE_PRICE_GROWTH_MONTHLY", - "stripe_price_env_annual": "STRIPE_PRICE_GROWTH_ANNUAL", + "price_annual": None, + "stripe_price_env_monthly": "STRIPE_PRICE_LIST_MONTHLY", + "stripe_price_env_annual": None, "features": [ "FSMA 204 traceability workspace", "Supplier onboarding + FTL scoping", "CSV upload + API ingestion", "Compliance scoring + FDA-ready export", "Recall simulation + drill workflows", + "EPCIS 1.2 export (Preview)", "Email support", ], "limits": { - "facilities": 1, + # Per-facility pricing: facility count is the subscription + # quantity, not a hardcoded tier limit. + "facilities": -1, + # Prior growth cap. CTE caps are OPEN per A-2155. "events_per_month": 50000, }, }, - "scale": { - "id": "scale", - "name": "Scale", - "price_monthly": 1999, - "price_annual": 1666, - "stripe_price_env_monthly": "STRIPE_PRICE_SCALE_MONTHLY", - "stripe_price_env_annual": "STRIPE_PRICE_SCALE_ANNUAL", + "founding": { + "id": "founding", + "name": "RegEngine Founding Design Partner", + "price_monthly": 499, + "price_annual": None, + "stripe_price_env_monthly": "STRIPE_PRICE_FOUNDING_MONTHLY", + "stripe_price_env_annual": None, "features": [ - "Everything in Growth", - "Multi-facility operations", - "Expanded API + webhook limits", - "Priority onboarding support", - "Retailer-specific readiness benchmarks", - "Priority support", + "Everything in RegEngine List", + "Founding design partner pricing", + "Supplier onboarding + FTL scoping", + "CSV upload + API ingestion", + "Compliance scoring + FDA-ready export", + "Recall simulation + drill workflows", + "EPCIS 1.2 export (Preview)", + "Design partner support", ], "limits": { - "facilities": 5, - "events_per_month": 250000, + # Per-facility pricing: facility count is the subscription + # quantity, not a hardcoded tier limit. + "facilities": -1, + # Prior growth cap. CTE caps are OPEN per A-2155. + "events_per_month": 50000, }, }, "enterprise": { @@ -66,11 +107,12 @@ "stripe_price_env_monthly": None, "stripe_price_env_annual": None, "features": [ - "Everything in Scale", + "Everything in RegEngine List", "Dedicated implementation plan", "Custom SLA + security review support", "Advanced integration and data architecture", "Executive sponsor + quarterly roadmap reviews", + "EPCIS 1.2 export (Preview)", ], "limits": { "facilities": -1, @@ -112,6 +154,29 @@ def _normalize_billing_period(period: str) -> str: return normalized +def _resolve_pilot_price_id(variant: str) -> str: + """Resolve the one-time pilot Price ID for ``standard`` or ``convert_30``. + + Pilot is deliberately not a recurring ``PLANS`` entry — this helper only + returns the Stripe Price ID. Checkout quantity / webhook credit behavior + lands in later PRs. + """ + env_var = _PILOT_PRICE_ENV_BY_VARIANT.get(variant) + if env_var is None: + raise HTTPException( + status_code=400, + detail=f"Invalid pilot variant: {variant}. Expected 'standard' or 'convert_30'.", + ) + + price_id = os.getenv(env_var) + if not price_id: + raise HTTPException( + status_code=500, + detail=f"Stripe price ID is not configured for pilot variant '{variant}'. Set {env_var}.", + ) + return price_id + + def _resolve_price_id(plan_id: str, billing_period: str) -> tuple[dict[str, Any], str, int]: normalized_plan = _normalize_plan_id(plan_id) plan = PLANS.get(normalized_plan) @@ -124,21 +189,39 @@ def _resolve_price_id(plan_id: str, billing_period: str) -> tuple[dict[str, Any] detail="Enterprise plans require a sales consultation. Contact sales@regengine.co", ) + # Annual pricing is OPEN pending approval (C.Lee). List/founding have no + # annual Price and must not silently fall back to monthly: a 400 is + # clearer than a 500 and tells the caller annual is not offered yet. + if billing_period == "annual" and plan.get("price_annual") is None: + raise HTTPException( + status_code=400, + detail=( + f"Annual pricing is not configured for plan '{normalized_plan}' " + "(annual pricing is OPEN pending approval). " + "Use monthly billing or contact sales@regengine.co." + ), + ) + price_env_var = plan[f"stripe_price_env_{billing_period}"] price_id = os.getenv(price_env_var) if price_env_var else None + # Alias period only: the old growth monthly Price still resolves to the + # list plan so existing Stripe subscriptions/links keep working until the + # STRIPE_PRICE_LIST_MONTHLY values are set in the environment. + if not price_id and normalized_plan == "list" and billing_period == "monthly": + price_id = os.getenv(DEPRECATED_LIST_PRICE_ENV_MONTHLY) + # #2928: never substitute another period's price ID here. Billing an # annual request against the monthly Price silently enrolls the customer # on the wrong interval while the response and stored mapping both say # "annual" — an unconfigured price must fail loudly instead. if not price_id: - raise HTTPException( - status_code=500, - detail=( - f"Stripe price ID is not configured for plan '{normalized_plan}' ({billing_period}). " - f"Set {price_env_var}." - ), + detail = ( + f"Stripe price ID is not configured for plan '{normalized_plan}' ({billing_period}). Set {price_env_var}." ) + if normalized_plan == "list" and billing_period == "monthly": + detail += f" Deprecated fallback {DEPRECATED_LIST_PRICE_ENV_MONTHLY} is also unset (alias period only)." + raise HTTPException(status_code=500, detail=detail) amount = plan[f"price_{billing_period}"] if amount is None: @@ -156,14 +239,33 @@ def _plan_for_stripe_price(price_id: str) -> Optional[tuple[str, str]]: return the plan/period the price belongs to. Returns ``None`` when the price matches no configured catalog entry. Never raises — the webhook path must fail closed on catalog gaps, not 500. + + The deprecated growth monthly env still maps to list during the alias + period. Old annual growth/scale envs are intentionally *not* mapped: + annual pricing is OPEN, so we must not invent an annual entitlement. + + Pilot Price IDs map to ``("pilot", "one_time")`` even though pilot has + no recurring ``PLANS`` entry. The webhook PR decides whether that grants + any status; this helper only identifies the Price. """ if not price_id: return None + + configured: list[tuple[str, str, str]] = [] for plan_id, plan in PLANS.items(): for period in ("monthly", "annual"): env_var = plan.get(f"stripe_price_env_{period}") - if not env_var: - continue - if os.getenv(env_var) == price_id: - return (plan_id, period) + if env_var: + configured.append((env_var, plan_id, period)) + + # Alias period only — deprecated monthly list price. + configured.append((DEPRECATED_LIST_PRICE_ENV_MONTHLY, "list", "monthly")) + + # One-time pilot prices (not recurring plans). + configured.append((PILOT_PRICE_ENV_STANDARD, "pilot", "one_time")) + configured.append((PILOT_PRICE_ENV_CONVERT_30, "pilot", "one_time")) + + for env_var, plan_id, period in configured: + if os.getenv(env_var) == price_id: + return (plan_id, period) return None diff --git a/services/ingestion/app/stripe_billing/routes.py b/services/ingestion/app/stripe_billing/routes.py index 244f7dfcd..93371b75a 100644 --- a/services/ingestion/app/stripe_billing/routes.py +++ b/services/ingestion/app/stripe_billing/routes.py @@ -76,21 +76,57 @@ def _allow_subscription_read_fallback() -> bool: description="Returns all available subscription plans with features and pricing.", ) async def list_plans() -> dict[str, list[dict[str, Any]]]: - """List all subscription plans.""" - return { - "plans": [ - { - "id": plan["id"], - "name": plan["name"], - "price_monthly": plan["price_monthly"], - "price_annual": plan["price_annual"], - "features": plan["features"], - "limits": plan["limits"], - } - for plan in PLANS.values() - ] + """List all subscription plans. + + Compatibility contract for the pricing page: the ``list`` plan carries + ``ga_monthly`` / ``partner_monthly`` so the page can render the approved + A-2155 amounts without a backend change. Annual fields are null until + C.Lee approves annual pricing (OPEN) — do not invent annual amounts. + Claude owns the pricing-page follow-up to consume the new ids/fields. + """ + list_plan = PLANS["list"] + compat_amounts = { + "ga_monthly": list_plan["price_monthly"], + "partner_monthly": PLANS["founding"]["price_monthly"], + "ga_annual": None, + "partner_annual": None, } + plans: list[dict[str, Any]] = [] + for plan in PLANS.values(): + payload = { + "id": plan["id"], + "name": plan["name"], + "price_monthly": plan["price_monthly"], + "price_annual": plan["price_annual"], + "features": plan["features"], + "limits": plan["limits"], + "product_status": "Beta", + } + if plan["id"] == "list": + payload.update(compat_amounts) + plans.append(payload) + + # Synthetic compatibility entry for the page's historical ``base`` tier + # id. It mirrors the list plan's monthly amounts until Claude updates the + # page. ``standard``/``premium`` are intentionally omitted: there is no + # approved self-serve Standard/Premium dollar SKU, and null-priced + # placeholders would be skipped by the page anyway. + plans.append( + { + "id": "base", + "name": list_plan["name"], + "price_monthly": list_plan["price_monthly"], + "price_annual": list_plan["price_annual"], + "features": list_plan["features"], + "limits": list_plan["limits"], + "product_status": "Beta", + **compat_amounts, + } + ) + + return {"plans": plans} + @router.post( "/checkout", diff --git a/services/ingestion/tests/test_stripe_billing_router.py b/services/ingestion/tests/test_stripe_billing_router.py index ca5f079e0..7083715ee 100644 --- a/services/ingestion/tests/test_stripe_billing_router.py +++ b/services/ingestion/tests/test_stripe_billing_router.py @@ -81,10 +81,10 @@ def _fake_create(**kwargs): ) assert response.session_id == "cs_test_123" - assert response.plan == "growth" + assert response.plan == "list" assert captured["line_items"][0]["price"] == "price_growth_monthly" assert captured["metadata"]["tenant_id"] == "tenant-1" - assert captured["metadata"]["plan_id"] == "growth" + assert captured["metadata"]["plan_id"] == "list" assert captured["customer_email"] == "ops@example.com" assert captured_funnel["tenant_id"] == "tenant-1" assert captured_funnel["event_name"] == "checkout_started" @@ -179,7 +179,7 @@ async def _fake_create_tenant(name: str) -> str: mapping = fake_redis.hgetall(stripe_billing._tenant_subscription_key("tenant-123")) assert mapping["tenant_id"] == "tenant-123" - assert mapping["plan_id"] == "growth" + assert mapping["plan_id"] == "list" assert mapping["subscription_id"] == "sub_1" assert mapping["customer_id"] == "cus_1" assert fake_redis.get(stripe_billing._subscription_lookup_key("sub_1")) == "tenant-123" @@ -545,3 +545,43 @@ def test_patching_package_stripe_propagates_to_usage_reporting(monkeypatch: pyte sentinel = object() monkeypatch.setattr(stripe_billing, "stripe", sentinel) assert stripe_billing._usage_reporting_mod.stripe is sentinel + + +@pytest.mark.asyncio +async def test_list_plans_exposes_pricing_page_compat_amounts() -> None: + """The pricing page consumes ga/partner monthly fields on list and base. + + Annual is OPEN (null) until approved; standard/premium have no approved + self-serve dollar SKU and must not be emitted as invented tiers. + """ + result = await stripe_billing.list_plans() + by_id = {plan["id"]: plan for plan in result["plans"]} + + list_plan = by_id["list"] + assert list_plan["price_monthly"] == 999 + assert list_plan["price_annual"] is None + assert list_plan["ga_monthly"] == 999 + assert list_plan["partner_monthly"] == 499 + assert list_plan["ga_annual"] is None + assert list_plan["partner_annual"] is None + + # Synthetic compatibility entry for the page's historical `base` id. + base = by_id["base"] + assert base["price_monthly"] == 999 + assert base["price_annual"] is None + assert base["ga_monthly"] == 999 + assert base["partner_monthly"] == 499 + + founding = by_id["founding"] + assert founding["price_monthly"] == 499 + assert founding["price_annual"] is None + + assert "growth" not in by_id + assert "scale" not in by_id + assert "standard" not in by_id + assert "premium" not in by_id + + for plan in result["plans"]: + assert plan["product_status"] == "Beta" + features = [feature.lower() for feature in plan["features"]] + assert any("epcis" in feature and "preview" in feature for feature in features) diff --git a/services/ingestion/tests/test_stripe_plans_1342.py b/services/ingestion/tests/test_stripe_plans_1342.py index cae678c52..c8eba1146 100644 --- a/services/ingestion/tests/test_stripe_plans_1342.py +++ b/services/ingestion/tests/test_stripe_plans_1342.py @@ -1,29 +1,21 @@ -"""Unit tests for ``app.stripe_billing.plans`` — issue #1342. - -Raises coverage from 80% to 100% by exercising the error branches -in ``_normalize_billing_period`` and ``_resolve_price_id``, -including the unconfigured-price failures. Happy-path coverage -already comes from the Stripe webhook / router test suites; this -file fills the error-surface gaps they leave behind. - -Pinned branches: - - ``_normalize_billing_period``: raises HTTPException(400) on - anything other than "monthly"/"annual" (lowercased + trimmed). - - ``_resolve_price_id``: - * Unknown plan → HTTPException(400) with the raw plan_id - embedded in the detail. - * ``enterprise`` plan → HTTPException(400) directing to sales; - never leaks a Stripe price ID. - * Missing monthly env → HTTPException(500) with plan + period - in detail. - * Missing annual env → HTTPException(500) naming the missing - env var (#2928: the old annual→monthly fallback billed the - monthly Price under an "annual" label and was removed). - * Plan with None amount (after a successful env lookup) → - HTTPException(400) "does not support self-serve checkout". - - ``_normalize_plan_id``: maps every alias in ``PLAN_ALIASES`` to - the canonical plan; unknown ids are returned unchanged (to be - rejected by ``_resolve_price_id``). +"""Unit tests for ``app.stripe_billing.plans`` — issue #1342 / A-2155. + +Pins the approved list/founding packaging and the transitional alias period: + + - Canonical ``PLANS`` keys are ``list``, ``founding``, ``enterprise``; + growth/scale are aliases → ``list`` and premium → ``enterprise``. + - List is $999/mo per facility, founding is $499/mo per facility. + Annual is OPEN (``price_annual is None``) and must 400, never fall + back to the monthly Price. + - List monthly may fall back to the deprecated + ``STRIPE_PRICE_GROWTH_MONTHLY`` env for the alias period only. + - Pilot is one-time, not a recurring plan: constants/helpers only. + - Every plan feature list advertises the EPCIS export as Preview. + +Raises coverage from 80% to 100% by exercising the error branches in +``_normalize_billing_period``, ``_resolve_price_id``, +``_resolve_pilot_price_id`` and ``_plan_for_stripe_price``. No live +Stripe calls are made. """ from __future__ import annotations @@ -40,13 +32,38 @@ from app.stripe_billing import plans as mod # noqa: E402 from app.stripe_billing.plans import ( # noqa: E402 + DEPRECATED_LIST_PRICE_ENV_MONTHLY, + PILOT_CONVERT_WINDOW_DAYS, + PILOT_DURATION_DAYS, + PILOT_FEE_CONVERT_30_CENTS, + PILOT_FEE_STANDARD_CENTS, + PILOT_PRICE_ENV_CONVERT_30, + PILOT_PRICE_ENV_STANDARD, PLAN_ALIASES, PLANS, _normalize_billing_period, _normalize_plan_id, + _plan_for_stripe_price, + _resolve_pilot_price_id, _resolve_price_id, ) + +def _clear_catalog_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Remove every price env this module reads so tests are deterministic.""" + for name in ( + "STRIPE_PRICE_LIST_MONTHLY", + "STRIPE_PRICE_FOUNDING_MONTHLY", + DEPRECATED_LIST_PRICE_ENV_MONTHLY, + "STRIPE_PRICE_GROWTH_ANNUAL", + "STRIPE_PRICE_SCALE_MONTHLY", + "STRIPE_PRICE_SCALE_ANNUAL", + PILOT_PRICE_ENV_STANDARD, + PILOT_PRICE_ENV_CONVERT_30, + ): + monkeypatch.delenv(name, raising=False) + + # --------------------------------------------------------------------------- # _normalize_plan_id — alias map + passthrough # --------------------------------------------------------------------------- @@ -62,16 +79,26 @@ def test_every_alias_maps_to_canonical_plan(self, alias, canonical): # automatically tightens the test. assert _normalize_plan_id(alias) == canonical + @pytest.mark.parametrize( + "alias", + ["growth", "base", "starter", "scale", "standard", "professional"], + ) + def test_legacy_self_serve_tiers_alias_to_list(self, alias): + assert _normalize_plan_id(alias) == "list" + + def test_premium_goes_to_sales_not_a_dollar_sku(self): + # There is no approved Premium self-serve SKU; route it to sales. + assert _normalize_plan_id("premium") == "enterprise" + assert "premium" not in PLANS + def test_unknown_plan_is_returned_unchanged(self): # Bad IDs are not rejected here — they pass through so the # caller (``_resolve_price_id``) can raise a uniform error. assert _normalize_plan_id("mystery-plan") == "mystery-plan" def test_canonical_ids_pass_through(self): - # "growth" isn't in PLAN_ALIASES (it's already canonical) — - # the ``.get(..., plan_id)`` default keeps it intact. - assert _normalize_plan_id("growth") == "growth" - assert _normalize_plan_id("scale") == "scale" + assert _normalize_plan_id("list") == "list" + assert _normalize_plan_id("founding") == "founding" assert _normalize_plan_id("enterprise") == "enterprise" @@ -114,7 +141,7 @@ def test_empty_string_raises_http_400(self): class TestResolvePriceIdErrors: - def test_unknown_plan_raises_400_with_plan_id_echoed(self, monkeypatch): + def test_unknown_plan_raises_400_with_plan_id_echoed(self): with pytest.raises(HTTPException) as ei: _resolve_price_id("mystery-plan", "monthly") assert ei.value.status_code == 400 @@ -123,72 +150,94 @@ def test_unknown_plan_raises_400_with_plan_id_echoed(self, monkeypatch): def test_enterprise_plan_raises_400_to_sales(self, monkeypatch): # Security / revenue contract: enterprise plans must NOT leak # a Stripe price ID to the checkout flow; customers have to - # go through sales. If this 400 ever turns into a 200 the - # whole enterprise tier is self-serve by accident. - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_123") - with pytest.raises(HTTPException) as ei: - _resolve_price_id("enterprise", "monthly") - assert ei.value.status_code == 400 - assert "sales" in ei.value.detail.lower() + # go through sales. Premium must land here too. + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_123") + for plan_id in ("enterprise", "premium"): + with pytest.raises(HTTPException) as ei: + _resolve_price_id(plan_id, "monthly") + assert ei.value.status_code == 400 + assert "sales" in ei.value.detail.lower() - def test_missing_monthly_env_raises_500(self, monkeypatch): - monkeypatch.delenv("STRIPE_PRICE_GROWTH_MONTHLY", raising=False) - monkeypatch.delenv("STRIPE_PRICE_GROWTH_ANNUAL", raising=False) + def test_missing_list_monthly_env_raises_500_naming_new_var(self, monkeypatch): + _clear_catalog_env(monkeypatch) with pytest.raises(HTTPException) as ei: - _resolve_price_id("growth", "monthly") + _resolve_price_id("list", "monthly") assert ei.value.status_code == 500 - assert "growth" in ei.value.detail + assert "list" in ei.value.detail assert "monthly" in ei.value.detail + assert "STRIPE_PRICE_LIST_MONTHLY" in ei.value.detail + # The deprecated fallback is documented in the detail for operators. + assert DEPRECATED_LIST_PRICE_ENV_MONTHLY in ei.value.detail - def test_missing_annual_env_raises_500_and_never_uses_monthly(self, monkeypatch): - # #2928 regression: this used to fall back to the monthly price - # ID while still reporting the annual amount / period, so the - # customer was enrolled on a monthly-interval subscription that - # every RegEngine surface called "annual". An unconfigured annual - # price must now 500 and name the env var the operator forgot. - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_fallback_m") - monkeypatch.delenv("STRIPE_PRICE_GROWTH_ANNUAL", raising=False) + def test_missing_founding_monthly_env_raises_500(self, monkeypatch): + _clear_catalog_env(monkeypatch) with pytest.raises(HTTPException) as ei: - _resolve_price_id("growth", "annual") + _resolve_price_id("founding", "monthly") assert ei.value.status_code == 500 - assert "growth" in ei.value.detail - assert "annual" in ei.value.detail - assert "STRIPE_PRICE_GROWTH_ANNUAL" in ei.value.detail - # The monthly price ID must never leak into the annual path. - assert "price_fallback_m" not in ei.value.detail - - @pytest.mark.parametrize("plan_id", ["growth", "scale"]) - @pytest.mark.parametrize("requested,other", [("annual", "monthly"), ("monthly", "annual")]) - def test_never_substitutes_the_other_periods_price_id(self, monkeypatch, plan_id, requested, other): - # #2928: the price ID returned for a period may only come from - # that period's env var, in either direction — otherwise the - # billing_period stamped into Stripe metadata and persisted to - # ``billing:tenant:{tenant_id}`` diverges from the interval of - # the Price that Stripe actually bills. - plan = PLANS[plan_id] - monkeypatch.setenv(plan[f"stripe_price_env_{other}"], f"price_{plan_id}_{other}") - monkeypatch.delenv(plan[f"stripe_price_env_{requested}"], raising=False) + assert "founding" in ei.value.detail + assert "STRIPE_PRICE_FOUNDING_MONTHLY" in ei.value.detail + + @pytest.mark.parametrize("plan_id", ["list", "founding"]) + def test_annual_raises_400_and_never_falls_back_to_monthly(self, monkeypatch, plan_id): + # Annual is OPEN pending approval: monthly env being set must not + # make an annual checkout succeed against the monthly Price. + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_m") + monkeypatch.setenv("STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_m") with pytest.raises(HTTPException) as ei: - _resolve_price_id(plan_id, requested) - assert ei.value.status_code == 500 - assert plan[f"stripe_price_env_{requested}"] in ei.value.detail + _resolve_price_id(plan_id, "annual") + assert ei.value.status_code == 400 + assert "annual" in ei.value.detail.lower() + assert "not configured" in ei.value.detail.lower() + assert "price_list_m" not in ei.value.detail + assert "price_founding_m" not in ei.value.detail + + def test_list_monthly_falls_back_to_deprecated_growth_env(self, monkeypatch): + _clear_catalog_env(monkeypatch) + monkeypatch.setenv(DEPRECATED_LIST_PRICE_ENV_MONTHLY, "price_growth_legacy") + plan, price_id, amount = _resolve_price_id("list", "monthly") + assert plan is PLANS["list"] + assert price_id == "price_growth_legacy" + assert amount == 999 - def test_missing_annual_and_monthly_env_raises_500(self, monkeypatch): - monkeypatch.delenv("STRIPE_PRICE_GROWTH_MONTHLY", raising=False) - monkeypatch.delenv("STRIPE_PRICE_GROWTH_ANNUAL", raising=False) + def test_alias_plan_routes_through_resolution(self, monkeypatch): + # Every legacy alias resolves against the canonical list plan, + # including the deprecated monthly env fallback. + _clear_catalog_env(monkeypatch) + monkeypatch.setenv(DEPRECATED_LIST_PRICE_ENV_MONTHLY, "price_alias_m") + for alias in ("growth", "base", "starter", "scale", "standard", "professional"): + plan, price_id, amount = _resolve_price_id(alias, "monthly") + assert plan is PLANS["list"] + assert price_id == "price_alias_m" + assert amount == 999 + + def test_premium_routes_to_sales(self): with pytest.raises(HTTPException) as ei: - _resolve_price_id("growth", "annual") - assert ei.value.status_code == 500 + _resolve_price_id("premium", "monthly") + assert ei.value.status_code == 400 + assert "sales" in ei.value.detail.lower() - def test_alias_plan_routes_through_resolution(self, monkeypatch): - # "starter" is an alias for "growth" — both the env lookup - # and the amount read must happen against the canonical plan, - # not the alias. - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_alias_m") - plan, price_id, amount = _resolve_price_id("starter", "monthly") - assert plan is PLANS["growth"] - assert price_id == "price_alias_m" - assert amount == PLANS["growth"]["price_monthly"] + def test_never_substitutes_the_other_periods_price_id(self, monkeypatch): + # #2928 regression guard, exercised with a synthetic plan so the + # rule stays pinned even though list/founding have no annual Price. + fake_plan = { + "id": "fakeplan", + "price_monthly": 1000, + "price_annual": 10000, + "stripe_price_env_monthly": "FAKE_PLAN_MONTHLY", + "stripe_price_env_annual": "FAKE_PLAN_ANNUAL", + } + monkeypatch.setitem(PLANS, "fakeplan", fake_plan) + monkeypatch.setenv("FAKE_PLAN_MONTHLY", "price_fake_m") + monkeypatch.delenv("FAKE_PLAN_ANNUAL", raising=False) + try: + with pytest.raises(HTTPException) as ei: + _resolve_price_id("fakeplan", "annual") + assert ei.value.status_code == 500 + assert "FAKE_PLAN_ANNUAL" in ei.value.detail + # The monthly price ID must never leak into the annual path. + assert "price_fake_m" not in ei.value.detail + finally: + PLANS.pop("fakeplan", None) # --------------------------------------------------------------------------- @@ -198,11 +247,9 @@ def test_alias_plan_routes_through_resolution(self, monkeypatch): class TestResolvePriceIdNoneAmount: def test_plan_with_none_price_raises_400(self, monkeypatch): - # Line 130: when the plan has a null amount, the env might - # still be set (test/dev situations) and the lookup gets - # past the 500 branch — we still must refuse self-serve - # checkout. Simulate by patching PLANS with a None-amount - # plan whose env is set. + # When the plan has a null amount, the env might still be set + # (test/dev situations) and the lookup gets past the 500 branch — + # we still must refuse self-serve checkout. fake_plan = { "id": "fakeplan", "price_monthly": None, @@ -222,24 +269,170 @@ def test_plan_with_none_price_raises_400(self, monkeypatch): # --------------------------------------------------------------------------- -# _resolve_price_id — happy path (both billing periods) +# _resolve_price_id — happy path # --------------------------------------------------------------------------- class TestResolvePriceIdHappy: - def test_monthly_returns_plan_price_and_amount(self, monkeypatch): - monkeypatch.setenv("STRIPE_PRICE_SCALE_MONTHLY", "price_scale_m") - plan, price_id, amount = _resolve_price_id("scale", "monthly") - assert plan is PLANS["scale"] - assert price_id == "price_scale_m" - assert amount == PLANS["scale"]["price_monthly"] + def test_list_monthly_returns_plan_price_and_amount(self, monkeypatch): + _clear_catalog_env(monkeypatch) + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_m") + plan, price_id, amount = _resolve_price_id("list", "monthly") + assert plan is PLANS["list"] + assert price_id == "price_list_m" + assert amount == 999 + + def test_founding_monthly_returns_plan_price_and_amount(self, monkeypatch): + _clear_catalog_env(monkeypatch) + monkeypatch.setenv("STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_m") + plan, price_id, amount = _resolve_price_id("founding", "monthly") + assert plan is PLANS["founding"] + assert price_id == "price_founding_m" + assert amount == 499 + + def test_list_env_wins_over_deprecated_growth_env(self, monkeypatch): + # During the alias period the new LIST var is authoritative. + _clear_catalog_env(monkeypatch) + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_new") + monkeypatch.setenv(DEPRECATED_LIST_PRICE_ENV_MONTHLY, "price_growth_old") + _, price_id, _ = _resolve_price_id("list", "monthly") + assert price_id == "price_list_new" + - def test_annual_returns_plan_price_and_amount(self, monkeypatch): - monkeypatch.setenv("STRIPE_PRICE_SCALE_ANNUAL", "price_scale_a") - plan, price_id, amount = _resolve_price_id("scale", "annual") - assert plan is PLANS["scale"] - assert price_id == "price_scale_a" - assert amount == PLANS["scale"]["price_annual"] +# --------------------------------------------------------------------------- +# _plan_for_stripe_price — reverse map +# --------------------------------------------------------------------------- + + +class TestPlanForStripePrice: + @pytest.mark.parametrize( + "env_var,value,expected", + [ + ("STRIPE_PRICE_LIST_MONTHLY", "price_list_m", ("list", "monthly")), + ("STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_m", ("founding", "monthly")), + (PILOT_PRICE_ENV_STANDARD, "price_pilot_std", ("pilot", "one_time")), + (PILOT_PRICE_ENV_CONVERT_30, "price_pilot_conv", ("pilot", "one_time")), + (DEPRECATED_LIST_PRICE_ENV_MONTHLY, "price_growth_legacy", ("list", "monthly")), + ], + ) + def test_reverse_maps_configured_prices(self, monkeypatch, env_var, value, expected): + _clear_catalog_env(monkeypatch) + monkeypatch.setenv(env_var, value) + assert _plan_for_stripe_price(value) == expected + + @pytest.mark.parametrize( + "env_var", + ["STRIPE_PRICE_GROWTH_ANNUAL", "STRIPE_PRICE_SCALE_MONTHLY", "STRIPE_PRICE_SCALE_ANNUAL"], + ) + def test_old_annual_and_scale_envs_do_not_invent_entitlements(self, monkeypatch, env_var): + # Only growth monthly survives as an alias. Old annual/scale Prices + # must not reverse-map to an annual or scale entitlement. + _clear_catalog_env(monkeypatch) + monkeypatch.setenv(env_var, "price_legacy_only") + assert _plan_for_stripe_price("price_legacy_only") is None + + def test_pilot_is_not_a_recurring_plan(self): + assert "pilot" not in PLANS + + def test_empty_and_unknown_prices_return_none(self, monkeypatch): + _clear_catalog_env(monkeypatch) + assert _plan_for_stripe_price("") is None + assert _plan_for_stripe_price("price_unknown") is None + + +# --------------------------------------------------------------------------- +# Pilot helpers — one-time constants only, no recurring plan +# --------------------------------------------------------------------------- + + +class TestPilotHelpers: + def test_pilot_constants(self): + assert PILOT_FEE_STANDARD_CENTS == 200000 + assert PILOT_FEE_CONVERT_30_CENTS == 150000 + assert PILOT_DURATION_DAYS == 90 + assert PILOT_CONVERT_WINDOW_DAYS == 30 + + @pytest.mark.parametrize( + "variant,env_var,value", + [ + ("standard", PILOT_PRICE_ENV_STANDARD, "price_pilot_std"), + ("convert_30", PILOT_PRICE_ENV_CONVERT_30, "price_pilot_conv"), + ], + ) + def test_resolves_pilot_price_by_variant(self, monkeypatch, variant, env_var, value): + _clear_catalog_env(monkeypatch) + monkeypatch.setenv(env_var, value) + assert _resolve_pilot_price_id(variant) == value + + def test_invalid_variant_raises_400(self): + with pytest.raises(HTTPException) as ei: + _resolve_pilot_price_id("bogus") + assert ei.value.status_code == 400 + assert "standard" in ei.value.detail + assert "convert_30" in ei.value.detail + + @pytest.mark.parametrize( + "variant,env_var", + [ + ("standard", PILOT_PRICE_ENV_STANDARD), + ("convert_30", PILOT_PRICE_ENV_CONVERT_30), + ], + ) + def test_missing_pilot_env_raises_500_naming_var(self, monkeypatch, variant, env_var): + _clear_catalog_env(monkeypatch) + with pytest.raises(HTTPException) as ei: + _resolve_pilot_price_id(variant) + assert ei.value.status_code == 500 + assert env_var in ei.value.detail + + +# --------------------------------------------------------------------------- +# Catalog shape — approved A-2155 packaging +# --------------------------------------------------------------------------- + + +class TestCatalogShape: + def test_canonical_plan_keys(self): + assert set(PLANS) == {"list", "founding", "enterprise"} + + def test_list_values(self): + plan = PLANS["list"] + assert plan["name"] == "RegEngine List" + assert plan["price_monthly"] == 999 + assert plan["price_annual"] is None + assert plan["stripe_price_env_monthly"] == "STRIPE_PRICE_LIST_MONTHLY" + assert plan["stripe_price_env_annual"] is None + assert plan["limits"]["facilities"] == -1 # quantity-driven + assert plan["limits"]["events_per_month"] == 50000 # CTE caps OPEN + + def test_founding_values(self): + plan = PLANS["founding"] + assert plan["name"] == "RegEngine Founding Design Partner" + assert plan["price_monthly"] == 499 + assert plan["price_annual"] is None + assert plan["stripe_price_env_monthly"] == "STRIPE_PRICE_FOUNDING_MONTHLY" + assert plan["stripe_price_env_annual"] is None + assert plan["limits"]["facilities"] == -1 # quantity-driven + assert plan["limits"]["events_per_month"] == 50000 # CTE caps OPEN + + def test_enterprise_has_null_pricing_and_unlimited_limits(self): + plan = PLANS["enterprise"] + assert plan["price_monthly"] is None + assert plan["price_annual"] is None + assert plan["stripe_price_env_monthly"] is None + assert plan["stripe_price_env_annual"] is None + assert plan["limits"] == {"facilities": -1, "events_per_month": -1} + + def test_every_plan_features_include_epcis_export_preview(self): + for plan_id, plan in PLANS.items(): + features = [feature.lower() for feature in plan["features"]] + assert any("epcis" in feature and "preview" in feature for feature in features), ( + f"Plan '{plan_id}' is missing the EPCIS export Preview line" + ) + + def test_removed_tiers_are_not_canonical_plans(self): + for legacy in ("growth", "scale", "base", "starter", "standard", "professional"): + assert legacy not in PLANS # --------------------------------------------------------------------------- @@ -255,18 +448,3 @@ def test_default_urls_point_to_regengine_co(self): assert mod.DEFAULT_SUCCESS_URL.startswith("https://regengine.co/") assert mod.DEFAULT_CANCEL_URL.startswith("https://regengine.co/") assert mod.DEFAULT_PORTAL_RETURN_URL.startswith("https://regengine.co/") - - def test_growth_and_scale_have_pricing(self): - # Enterprise is supposed to be null-priced (handled by sales); - # the other two must keep stripe-compatible positive ints. - for pid in ("growth", "scale"): - p = PLANS[pid] - assert isinstance(p["price_monthly"], int) and p["price_monthly"] > 0 - assert isinstance(p["price_annual"], int) and p["price_annual"] > 0 - - def test_enterprise_has_null_pricing(self): - p = PLANS["enterprise"] - assert p["price_monthly"] is None - assert p["price_annual"] is None - assert p["stripe_price_env_monthly"] is None - assert p["stripe_price_env_annual"] is None diff --git a/services/ingestion/tests/test_stripe_routes_1342.py b/services/ingestion/tests/test_stripe_routes_1342.py index 3c4244112..b1fee8239 100644 --- a/services/ingestion/tests/test_stripe_routes_1342.py +++ b/services/ingestion/tests/test_stripe_routes_1342.py @@ -86,20 +86,36 @@ def _stub_emit_funnel_event(monkeypatch: pytest.MonkeyPatch) -> None: class TestListPlans: @pytest.mark.asyncio async def test_returns_all_plans_with_expected_shape(self) -> None: - """Every plan dict contains id/name/price/features/limits — the - front-end relies on this contract.""" + """Every plan dict contains the core contract plus product_status; + the list/base compatibility entries also carry ga/partner amounts.""" result = await routes_mod.list_plans() assert "plans" in result - assert len(result["plans"]) == len(plans_mod.PLANS) + by_id = {plan["id"]: plan for plan in result["plans"]} + assert {"list", "founding", "enterprise", "base"} <= set(by_id) for plan in result["plans"]: - assert set(plan.keys()) == { + assert { "id", "name", "price_monthly", "price_annual", "features", "limits", - } + "product_status", + } <= set(plan.keys()) + assert plan["product_status"] == "Beta" + assert any("epcis" in f.lower() and "preview" in f.lower() for f in plan["features"]) + + list_plan = by_id["list"] + assert list_plan["ga_monthly"] == 999 + assert list_plan["partner_monthly"] == 499 + assert list_plan["ga_annual"] is None + assert list_plan["partner_annual"] is None + + base = by_id["base"] + assert base["price_monthly"] == 999 + assert base["price_annual"] is None + assert base["ga_monthly"] == 999 + assert base["partner_monthly"] == 499 # ── create_checkout — error branches not touched by the happy-path test ──── @@ -153,7 +169,7 @@ class _Session: ) # Still returns a session — checkout is not blocked by Redis outage. assert response.session_id == "cs_test_123" - assert response.plan == "growth" + assert response.plan == "list" @pytest.mark.asyncio async def test_existing_customer_id_sets_customer_kwarg( @@ -338,16 +354,15 @@ def _stub_customer_lookups(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(customers_mod, "_record_checkout_session_hint", lambda **_: None) @pytest.mark.asyncio - async def test_annual_checkout_uses_the_annual_price_and_amount(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Both price envs configured: annual must win end to end.""" - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") - monkeypatch.setenv("STRIPE_PRICE_GROWTH_ANNUAL", "price_growth_annual") + async def test_monthly_checkout_uses_list_price_and_amount(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The list monthly Price and the reported amount must agree.""" + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly") captured: dict[str, Any] = {} class _Session: - id = "cs_annual" - url = "https://checkout.stripe.com/c/pay/cs_annual" + id = "cs_monthly" + url = "https://checkout.stripe.com/c/pay/cs_monthly" def _create(**kwargs: Any) -> _Session: captured.update(kwargs) @@ -356,26 +371,26 @@ def _create(**kwargs: Any) -> _Session: monkeypatch.setattr(stripe.checkout.Session, "create", _create) response = await routes_mod.create_checkout( - CheckoutRequest(plan_id="growth", billing_period="annual"), + CheckoutRequest(plan_id="list", billing_period="monthly"), x_tenant_id=None, - principal=_principal(tenant_id="tenant-annual"), + principal=_principal(tenant_id="tenant-monthly"), ) - assert captured["line_items"] == [{"price": "price_growth_annual", "quantity": 1}] + assert captured["line_items"] == [{"price": "price_list_monthly", "quantity": 1}] # Metadata travels to the webhook, which persists billing_period. - assert captured["metadata"]["billing_period"] == "annual" - assert captured["subscription_data"]["metadata"]["billing_period"] == "annual" - assert response.billing_period == "annual" - assert response.amount == plans_mod.PLANS["growth"]["price_annual"] + assert captured["metadata"]["billing_period"] == "monthly" + assert captured["subscription_data"]["metadata"]["billing_period"] == "monthly" + assert response.plan == "list" + assert response.billing_period == "monthly" + assert response.amount == plans_mod.PLANS["list"]["price_monthly"] + assert response.amount == 999 @pytest.mark.asyncio - async def test_unconfigured_annual_price_500s_without_creating_a_session( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """The regression itself: with only the monthly env set, an annual - checkout used to succeed against the monthly Price. It must now - fail before Stripe is ever called.""" - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") + async def test_annual_checkout_400s_without_creating_a_session(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Annual is OPEN: even with monthly configured, an annual request + must fail before Stripe is ever called rather than billing the + monthly Price under an "annual" label.""" + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly") monkeypatch.delenv("STRIPE_PRICE_GROWTH_ANNUAL", raising=False) def _must_not_run(**_kwargs: Any) -> Any: @@ -385,13 +400,14 @@ def _must_not_run(**_kwargs: Any) -> Any: with pytest.raises(Exception) as excinfo: await routes_mod.create_checkout( - CheckoutRequest(plan_id="growth", billing_period="annual"), + CheckoutRequest(plan_id="list", billing_period="annual"), x_tenant_id=None, principal=_principal(tenant_id="tenant-annual"), ) exc = excinfo.value - assert getattr(exc, "status_code", None) == 500 - assert "STRIPE_PRICE_GROWTH_ANNUAL" in getattr(exc, "detail", "") + assert getattr(exc, "status_code", None) == 400 + assert "annual" in getattr(exc, "detail", "").lower() + assert "not configured" in getattr(exc, "detail", "").lower() # ── get_subscription ─────────────────────────────────────────────────────── @@ -451,7 +467,7 @@ async def test_mapping_without_subscription_id_returns_stored_status(self, monke }, ) result = await routes_mod.get_subscription("tenant-mid-flow", _=None) - assert result.plan == "growth" + assert result.plan == "list" assert result.status == "checkout_pending" assert result.billing_period is None assert result.current_period_end == "2030-01-01T00:00:00+00:00" @@ -520,7 +536,7 @@ async def test_missing_stripe_config_keeps_stored_subscription_snapshot( ) result = await routes_mod.get_subscription("tenant-no-stripe", _=None) - assert result.plan == "growth" + assert result.plan == "list" assert result.status == "active" assert result.billing_period == "annual" assert result.current_period_end == "2030-01-01T00:00:00+00:00" diff --git a/tests/billing/test_stripe_billing.py b/tests/billing/test_stripe_billing.py index 904ac2296..d568dd15c 100644 --- a/tests/billing/test_stripe_billing.py +++ b/tests/billing/test_stripe_billing.py @@ -21,6 +21,7 @@ _handle_stripe_event, _normalize_billing_period, _normalize_plan_id, + _plan_for_stripe_price, _process_stripe_webhook, _resolve_price_id, ) @@ -61,19 +62,42 @@ def test_limits_have_required_keys(self): assert "facilities" in limits, f"Plan '{plan_id}' limits missing 'facilities'" assert "events_per_month" in limits, f"Plan '{plan_id}' limits missing 'events_per_month'" - def test_growth_plan_values(self): - """Verify growth plan has expected pricing tiers.""" - growth = PLANS["growth"] - assert growth["name"] == "Growth" - assert growth["price_monthly"] == 999 - assert growth["price_annual"] == 832 - - def test_scale_plan_values(self): - """Verify scale plan has expected pricing tiers.""" - scale = PLANS["scale"] - assert scale["name"] == "Scale" - assert scale["price_monthly"] == 1999 - assert scale["price_annual"] == 1666 + def test_list_plan_values(self): + """List is A-2155 $999/mo per facility; annual is OPEN (null).""" + plan = PLANS["list"] + assert plan["name"] == "RegEngine List" + assert plan["price_monthly"] == 999 + assert plan["price_annual"] is None + assert plan["stripe_price_env_monthly"] == "STRIPE_PRICE_LIST_MONTHLY" + assert plan["stripe_price_env_annual"] is None + + def test_founding_plan_values(self): + """Founding design partner is $499/mo per facility; annual is OPEN.""" + plan = PLANS["founding"] + assert plan["name"] == "RegEngine Founding Design Partner" + assert plan["price_monthly"] == 499 + assert plan["price_annual"] is None + assert plan["stripe_price_env_monthly"] == "STRIPE_PRICE_FOUNDING_MONTHLY" + assert plan["stripe_price_env_annual"] is None + + def test_removed_tiers_are_not_canonical_plans(self): + """Growth/Scale were replaced by per-facility List pricing.""" + assert set(PLANS) == {"list", "founding", "enterprise"} + + def test_list_and_founding_facilities_are_quantity_driven(self): + """Facilities are subscription quantity later, not a hardcoded tier.""" + for plan_id in ("list", "founding"): + assert PLANS[plan_id]["limits"]["facilities"] == -1 + # Prior growth cap — CTE caps are OPEN per A-2155. + assert PLANS[plan_id]["limits"]["events_per_month"] == 50000 + + def test_every_plan_advertises_epcis_export_preview(self): + """Every plan's features must include an EPCIS export Preview line.""" + for plan_id, plan in PLANS.items(): + features = [feature.lower() for feature in plan["features"]] + assert any("epcis" in feature and "preview" in feature for feature in features), ( + f"Plan '{plan_id}' is missing the EPCIS export Preview line" + ) def test_enterprise_plan_has_no_self_serve_pricing(self): """Enterprise plan prices should be None (sales-driven).""" @@ -88,11 +112,10 @@ def test_enterprise_plan_has_unlimited_limits(self): assert enterprise["limits"]["events_per_month"] == -1 def test_plan_aliases_resolve_correctly(self): - """Plan aliases should map to canonical plan IDs.""" - assert PLAN_ALIASES["starter"] == "growth" - assert PLAN_ALIASES["professional"] == "scale" - assert PLAN_ALIASES["base"] == "growth" - assert PLAN_ALIASES["standard"] == "scale" + """Legacy aliases collapse to List; premium goes to sales.""" + for alias in ("growth", "base", "starter", "scale", "standard", "professional"): + assert PLAN_ALIASES[alias] == "list" + assert PLAN_ALIASES["premium"] == "enterprise" # ── Normalization Helpers ──────────────────────────────────────── @@ -102,14 +125,18 @@ class TestNormalizationHelpers: """Tests for plan ID and billing period normalization.""" def test_normalize_plan_id_with_alias(self): - """Aliases should resolve to canonical plan IDs.""" - assert _normalize_plan_id("starter") == "growth" - assert _normalize_plan_id("professional") == "scale" + """Aliases should resolve to the new canonical plan IDs.""" + assert _normalize_plan_id("starter") == "list" + assert _normalize_plan_id("professional") == "list" + assert _normalize_plan_id("growth") == "list" + assert _normalize_plan_id("scale") == "list" + assert _normalize_plan_id("premium") == "enterprise" def test_normalize_plan_id_passthrough(self): """Canonical IDs should pass through unchanged.""" - assert _normalize_plan_id("growth") == "growth" - assert _normalize_plan_id("scale") == "scale" + assert _normalize_plan_id("list") == "list" + assert _normalize_plan_id("founding") == "founding" + assert _normalize_plan_id("enterprise") == "enterprise" def test_normalize_billing_period_valid(self): """Valid billing periods should normalize correctly.""" @@ -155,27 +182,36 @@ def test_configure_stripe_raises_without_key(self): class TestResolvePriceId: """Tests for _resolve_price_id.""" + @patch.dict("os.environ", {"STRIPE_PRICE_LIST_MONTHLY": "price_list_m"}) + def test_resolve_list_monthly(self): + """Should return the list plan, price ID, and $999 monthly amount.""" + plan, price_id, amount = _resolve_price_id("list", "monthly") + assert plan["id"] == "list" + assert price_id == "price_list_m" + assert amount == 999 + + @patch.dict("os.environ", {"STRIPE_PRICE_FOUNDING_MONTHLY": "price_founding_m"}) + def test_resolve_founding_monthly(self): + """Should return the founding plan, price ID, and $499 amount.""" + plan, price_id, amount = _resolve_price_id("founding", "monthly") + assert plan["id"] == "founding" + assert price_id == "price_founding_m" + assert amount == 499 + @patch.dict("os.environ", {"STRIPE_PRICE_GROWTH_MONTHLY": "price_growth_m"}) - def test_resolve_growth_monthly(self): - """Should return the growth plan, price ID, and monthly amount.""" + def test_resolve_growth_monthly_still_works_alias_period(self): + """Deprecated growth monthly env resolves to list during the alias + period, until Railway carries STRIPE_PRICE_LIST_MONTHLY.""" plan, price_id, amount = _resolve_price_id("growth", "monthly") - assert plan["id"] == "growth" + assert plan["id"] == "list" assert price_id == "price_growth_m" assert amount == 999 - @patch.dict("os.environ", {"STRIPE_PRICE_SCALE_ANNUAL": "price_scale_a"}) - def test_resolve_scale_annual(self): - """Should return scale plan with annual pricing.""" - plan, price_id, amount = _resolve_price_id("scale", "annual") - assert plan["id"] == "scale" - assert price_id == "price_scale_a" - assert amount == 1666 - @patch.dict("os.environ", {"STRIPE_PRICE_GROWTH_MONTHLY": "price_growth_m"}) def test_resolve_alias_redirects(self): - """Alias plan IDs should resolve to the canonical plan.""" + """Alias plan IDs should resolve to the canonical list plan.""" plan, price_id, amount = _resolve_price_id("starter", "monthly") - assert plan["id"] == "growth" + assert plan["id"] == "list" def test_resolve_enterprise_raises(self): """Enterprise plans should reject self-serve checkout.""" @@ -184,24 +220,66 @@ def test_resolve_enterprise_raises(self): assert exc_info.value.status_code == 400 assert "sales" in exc_info.value.detail.lower() + def test_resolve_premium_routes_to_sales(self): + """Premium has no self-serve dollar SKU; it maps to enterprise.""" + with pytest.raises(HTTPException) as exc_info: + _resolve_price_id("premium", "monthly") + assert exc_info.value.status_code == 400 + assert "sales" in exc_info.value.detail.lower() + def test_resolve_invalid_plan_raises(self): """Unknown plan IDs should raise 400.""" with pytest.raises(HTTPException) as exc_info: _resolve_price_id("nonexistent", "monthly") assert exc_info.value.status_code == 400 - @patch.dict("os.environ", {"STRIPE_PRICE_GROWTH_MONTHLY": "price_growth_m"}) - def test_annual_without_annual_price_raises_500(self): - """#2928: an unconfigured annual price must 500, not silently bill - the monthly price ID while reporting the annual amount.""" - import os + @patch.dict("os.environ", {"STRIPE_PRICE_LIST_MONTHLY": "price_list_m"}) + def test_list_annual_not_configured_raises_400(self): + """Annual is OPEN: list must 400, never fall back to monthly.""" + with pytest.raises(HTTPException) as exc_info: + _resolve_price_id("list", "annual") + assert exc_info.value.status_code == 400 + assert "annual" in exc_info.value.detail.lower() + assert "not configured" in exc_info.value.detail.lower() + + @patch.dict("os.environ", {"STRIPE_PRICE_FOUNDING_MONTHLY": "price_founding_m"}) + def test_founding_annual_not_configured_raises_400(self): + """Annual is OPEN: founding must 400, never fall back to monthly.""" + with pytest.raises(HTTPException) as exc_info: + _resolve_price_id("founding", "annual") + assert exc_info.value.status_code == 400 + assert "annual" in exc_info.value.detail.lower() - # Only monthly env is set; annual is not (patch.dict restores it). - os.environ.pop("STRIPE_PRICE_GROWTH_ANNUAL", None) + @patch.dict("os.environ", {}, clear=True) + def test_missing_list_monthly_env_raises_500_naming_new_var(self): + """A missing LIST env names the new var and the deprecated fallback.""" with pytest.raises(HTTPException) as exc_info: - _resolve_price_id("growth", "annual") + _resolve_price_id("list", "monthly") assert exc_info.value.status_code == 500 - assert "STRIPE_PRICE_GROWTH_ANNUAL" in exc_info.value.detail + assert "STRIPE_PRICE_LIST_MONTHLY" in exc_info.value.detail + assert "STRIPE_PRICE_GROWTH_MONTHLY" in exc_info.value.detail + + +class TestPlanForStripePrice: + """Reverse mapping must follow Stripe-side truth, not metadata claims.""" + + @pytest.mark.parametrize( + "env_var,price_id,expected", + [ + ("STRIPE_PRICE_LIST_MONTHLY", "price_list_m", ("list", "monthly")), + ("STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_m", ("founding", "monthly")), + ("STRIPE_PRICE_PILOT_STANDARD", "price_pilot_std", ("pilot", "one_time")), + ("STRIPE_PRICE_PILOT_CONVERT_30", "price_pilot_conv", ("pilot", "one_time")), + ("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_m", ("list", "monthly")), + ], + ) + def test_reverse_maps_configured_prices(self, env_var, price_id, expected): + with patch.dict("os.environ", {env_var: price_id}, clear=True): + assert _plan_for_stripe_price(price_id) == expected + + def test_old_annual_envs_do_not_invent_annual(self): + with patch.dict("os.environ", {"STRIPE_PRICE_GROWTH_ANNUAL": "price_growth_a"}, clear=True): + assert _plan_for_stripe_price("price_growth_a") is None # ── Checkout Session Creation ──────────────────────────────────── @@ -242,7 +320,7 @@ def test_checkout_creates_session(self, mock_stripe, mock_get_cust, mock_emit, m assert response.session_id == "cs_test_abc" assert response.checkout_url == "https://checkout.stripe.com/pay/cs_test_abc" - assert response.plan == "growth" + assert response.plan == "list" assert response.billing_period == "monthly" assert response.amount == 999 mock_stripe.checkout.Session.create.assert_called_once() @@ -496,7 +574,7 @@ async def test_checkout_completed_stores_mapping(self, mock_stripe, mock_redis_c call_args = mock_store.call_args assert call_args[0][0] == "tenant-abc" # tenant_id payload = call_args[0][1] - assert payload["plan_id"] == "growth" + assert payload["plan_id"] == "list" assert payload["status"] == "active" assert payload["subscription_id"] == "sub_test_456" assert payload["customer_id"] == "cus_test_789" @@ -574,7 +652,7 @@ async def test_checkout_plan_mismatch_fails_closed_to_stripe_truth( mock_store.assert_called_once() payload = mock_store.call_args[0][1] - assert payload["plan_id"] == "growth" # Stripe truth, not the claim + assert payload["plan_id"] == "list" # Stripe truth, not the claim assert payload["status"] == "active" @pytest.mark.asyncio From bbc9e78242d3c5b4ce77e99f23c62b53a40c67cf Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:29:33 -0700 Subject: [PATCH 2/6] feat(billing): checkout facility quantity for list/founding --- .../ingestion/app/stripe_billing/models.py | 13 +- .../ingestion/app/stripe_billing/routes.py | 42 ++++- .../tests/test_stripe_billing_router.py | 59 +++++- .../test_stripe_checkout_public_contract.py | 2 +- .../tests/test_stripe_routes_1342.py | 174 +++++++++++++++++- 5 files changed, 282 insertions(+), 8 deletions(-) diff --git a/services/ingestion/app/stripe_billing/models.py b/services/ingestion/app/stripe_billing/models.py index bea65ab57..81f95ba18 100644 --- a/services/ingestion/app/stripe_billing/models.py +++ b/services/ingestion/app/stripe_billing/models.py @@ -13,8 +13,15 @@ class CheckoutRequest(BaseModel): """Request to create a Stripe checkout session.""" - plan_id: str = Field(..., description="Plan: growth or scale") + plan_id: str = Field( + ..., + description=("list, founding (aliases growth/base/scale/standard → list), or enterprise (sales)"), + ) billing_period: str = Field("monthly", description="monthly or annual") + facility_count: Optional[int] = Field( + default=None, + description="Number of facilities to bill; defaults to 1 for list/founding", + ) tenant_id: Optional[str] = Field(default=None, description="Existing tenant ID if already provisioned") tenant_name: Optional[str] = Field(default=None, description="Tenant name for post-payment provisioning") customer_email: Optional[str] = Field(default=None, description="Payer email") @@ -30,6 +37,10 @@ class CheckoutResponse(BaseModel): plan: str billing_period: str amount: int + # Subscription quantity billed per facility (default 1). ``amount`` + # stays the *unit* monthly amount so it matches Stripe's price + # ``unit_amount`` display; clients multiply by this for the total. + facility_quantity: int = 1 currency: str = "usd" diff --git a/services/ingestion/app/stripe_billing/routes.py b/services/ingestion/app/stripe_billing/routes.py index 93371b75a..3c8138e41 100644 --- a/services/ingestion/app/stripe_billing/routes.py +++ b/services/ingestion/app/stripe_billing/routes.py @@ -57,6 +57,12 @@ # used by ``canonical_router.py`` / ``webhook_router_v2.py``. _get_db_session = get_db_session +# List/founding are billed per facility, with the facility count carried as +# the subscription quantity. Cap the self-serve checkout quantity so a typo +# (or a compromised client) cannot open a five-figure monthly subscription; +# product can raise this later. +MAX_CHECKOUT_FACILITY_QUANTITY = 100 + def _allow_subscription_read_fallback() -> bool: """Allow local/test callers to treat a missing billing state store as empty. @@ -157,6 +163,27 @@ async def create_checkout( billing_period = _plans_mod._normalize_billing_period(request.billing_period) plan, price_id, amount = _plans_mod._resolve_price_id(request.plan_id, billing_period) + + # List/founding are billed per facility: the facility count is the + # subscription quantity. There is no server-side facility-inventory helper + # in this package yet, so the only supported sources are an explicit + # ``facility_count`` on the request (validated below) or the default of 1. + # Never trust a client-supplied tenant inventory for billing quantity. + facility_quantity = 1 + if plan["id"] in {"list", "founding"}: + if request.facility_count is not None: + facility_quantity = request.facility_count + if facility_quantity < 1: + raise HTTPException(status_code=400, detail="facility_count must be at least 1") + if facility_quantity > MAX_CHECKOUT_FACILITY_QUANTITY: + raise HTTPException( + status_code=400, + detail=( + f"facility_count must be at most {MAX_CHECKOUT_FACILITY_QUANTITY}. " + "Contact sales@regengine.co for more facilities." + ), + ) + existing_customer_id: Optional[str] = None # Resolve tenant ONLY from authenticated context — ignore request body and @@ -186,6 +213,9 @@ async def create_checkout( metadata = { "plan_id": plan["id"], "billing_period": billing_period, + # Stripe metadata values must be strings. The webhook/PR3 credit path + # reads this to reconcile the subscription quantity with facilities. + "facility_quantity": str(facility_quantity), } if authenticated_tenant_id: # Outbound assignment: stamp the authenticated principal's tenant_id @@ -220,7 +250,7 @@ async def create_checkout( checkout_kwargs: dict[str, Any] = { "mode": "subscription", - "line_items": [{"price": price_id, "quantity": 1}], + "line_items": [{"price": price_id, "quantity": facility_quantity}], "success_url": safe_success_url, "cancel_url": safe_cancel_url, "allow_promotion_codes": True, @@ -273,6 +303,7 @@ async def create_checkout( tenant_id=authenticated_tenant_id, plan=plan["id"], amount=amount, + facility_quantity=facility_quantity, billing_period=billing_period, session_id=session.id, ) @@ -283,6 +314,7 @@ async def create_checkout( plan=plan["id"], billing_period=billing_period, amount=amount, + facility_quantity=facility_quantity, ) @@ -379,6 +411,12 @@ async def get_subscription( limits = PLANS.get(plan_id, {}).get("limits", {}) + # Per-facility plans store the billed facility count as the subscription + # quantity. Prefer it over the plan's static limit (which is -1 / + # "quantity-driven" for list and founding). + facility_quantity = _coerce_int(mapping.get("facility_quantity"), 0) + facilities_limit = facility_quantity if facility_quantity > 0 else limits.get("facilities", 0) + return SubscriptionStatus( tenant_id=resolved_tenant_id, plan=plan_id, @@ -388,7 +426,7 @@ async def get_subscription( events_used=0, events_limit=limits.get("events_per_month", 0), facilities_used=0, - facilities_limit=limits.get("facilities", 0), + facilities_limit=facilities_limit, ) diff --git a/services/ingestion/tests/test_stripe_billing_router.py b/services/ingestion/tests/test_stripe_billing_router.py index 7083715ee..ba8eda459 100644 --- a/services/ingestion/tests/test_stripe_billing_router.py +++ b/services/ingestion/tests/test_stripe_billing_router.py @@ -46,7 +46,7 @@ async def test_create_checkout_uses_stripe_and_normalizes_plan(monkeypatch): fake_redis = _FakeRedis() monkeypatch.setattr(stripe_billing, "_redis_client", lambda: fake_redis) monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_123") - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly") captured_funnel: dict[str, object] = {} captured = {} @@ -82,21 +82,74 @@ def _fake_create(**kwargs): assert response.session_id == "cs_test_123" assert response.plan == "list" - assert captured["line_items"][0]["price"] == "price_growth_monthly" + assert captured["line_items"][0]["price"] == "price_list_monthly" + # Omitted facility_count defaults to a single facility (quantity 1). + assert captured["line_items"][0]["quantity"] == 1 assert captured["metadata"]["tenant_id"] == "tenant-1" assert captured["metadata"]["plan_id"] == "list" + assert captured["metadata"]["facility_quantity"] == "1" + assert captured["subscription_data"]["metadata"]["facility_quantity"] == "1" + assert response.facility_quantity == 1 assert captured["customer_email"] == "ops@example.com" assert captured_funnel["tenant_id"] == "tenant-1" assert captured_funnel["event_name"] == "checkout_started" +@pytest.mark.parametrize( + ("plan_id", "env_var", "price_id", "unit_amount"), + [ + ("list", "STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly", 999), + ("founding", "STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_monthly", 499), + ], +) +@pytest.mark.asyncio +async def test_create_checkout_bills_facility_count_for_list_and_founding( + monkeypatch, plan_id: str, env_var: str, price_id: str, unit_amount: int +): + """facility_count is the subscription quantity for per-facility plans.""" + fake_redis = _FakeRedis() + monkeypatch.setattr(stripe_billing, "_redis_client", lambda: fake_redis) + monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_123") + monkeypatch.setenv(env_var, price_id) + captured: dict[str, object] = {} + + class _Session: + id = "cs_facility_count" + url = "https://checkout.stripe.com/c/pay/cs_facility_count" + + def _fake_create(**kwargs): + captured.update(kwargs) + return _Session() + + monkeypatch.setattr(stripe_billing.stripe.checkout.Session, "create", _fake_create) + + principal = IngestionPrincipal(tenant_id="tenant-1", scopes=["billing.read"], key_id="k1") + response = await stripe_billing.create_checkout( + stripe_billing.CheckoutRequest( + plan_id=plan_id, + billing_period="monthly", + facility_count=3, + ), + x_tenant_id=None, + principal=principal, + ) + + assert captured["line_items"] == [{"price": price_id, "quantity": 3}] + assert captured["metadata"]["facility_quantity"] == "3" + assert captured["subscription_data"]["metadata"]["facility_quantity"] == "3" + assert response.plan == plan_id + assert response.facility_quantity == 3 + # amount remains the unit price, not unit * quantity. + assert response.amount == unit_amount + + @pytest.mark.asyncio async def test_create_checkout_reuses_existing_customer_id_and_records_session(monkeypatch): fake_redis = _FakeRedis() tenant_id = "tenant-checkout-1" monkeypatch.setattr(stripe_billing, "_redis_client", lambda: fake_redis) monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_123") - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly") stripe_billing._store_subscription_mapping( tenant_id, diff --git a/services/ingestion/tests/test_stripe_checkout_public_contract.py b/services/ingestion/tests/test_stripe_checkout_public_contract.py index a09bc0194..e39d75a6b 100644 --- a/services/ingestion/tests/test_stripe_checkout_public_contract.py +++ b/services/ingestion/tests/test_stripe_checkout_public_contract.py @@ -47,7 +47,7 @@ def _isolated_checkout_security_state(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture def checkout_client(monkeypatch: pytest.MonkeyPatch) -> tuple[TestClient, list[dict[str, Any]]]: monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_checkout_contract") - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly") stripe_calls: list[dict[str, Any]] = [] diff --git a/services/ingestion/tests/test_stripe_routes_1342.py b/services/ingestion/tests/test_stripe_routes_1342.py index b1fee8239..1b0d628a6 100644 --- a/services/ingestion/tests/test_stripe_routes_1342.py +++ b/services/ingestion/tests/test_stripe_routes_1342.py @@ -129,7 +129,7 @@ def _configure_price_env(self, monkeypatch: pytest.MonkeyPatch) -> None: """``_resolve_price_id`` reads STRIPE_PRICE_{PLAN}_{PERIOD} from env and raises a 500 if unset. Pin one so the test reaches the error-branch code we actually want to exercise.""" - monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly") @pytest.fixture def base_request(self) -> CheckoutRequest: @@ -339,6 +339,147 @@ class _Session: assert response.session_id == "cs_hint_failed" +# ── create_checkout — per-facility quantity for list/founding ────────────── + + +class TestCheckoutFacilityQuantity: + """A-2155 PR2: list/founding checkout quantity = facility_count. + + The quantity is sent to Stripe as ``line_items[0].quantity`` and echoed + into both checkout-session and subscription metadata as a string so the + webhook (and the follow-up pilot-credit PR) can reconcile it. + """ + + @pytest.fixture(autouse=True) + def _stub_customer_lookups(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_monthly") + monkeypatch.setenv("STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_monthly") + monkeypatch.setattr(customers_mod, "_get_existing_customer_id", lambda _: None) + monkeypatch.setattr(customers_mod, "_record_checkout_session_hint", lambda **_: None) + + @staticmethod + def _capture_session(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + captured: dict[str, Any] = {} + + class _Session: + id = "cs_facility" + url = "https://checkout.stripe.com/c/pay/cs_facility" + + def _create(**kwargs: Any) -> _Session: + captured.update(kwargs) + return _Session() + + monkeypatch.setattr(stripe.checkout.Session, "create", _create) + return captured + + @pytest.mark.parametrize( + ("plan_id", "price_id", "unit_amount"), + [ + ("list", "price_list_monthly", 999), + ("founding", "price_founding_monthly", 499), + ], + ) + @pytest.mark.asyncio + async def test_facility_count_sets_stripe_quantity_and_metadata( + self, + monkeypatch: pytest.MonkeyPatch, + plan_id: str, + price_id: str, + unit_amount: int, + ) -> None: + captured = self._capture_session(monkeypatch) + + response = await routes_mod.create_checkout( + CheckoutRequest(plan_id=plan_id, billing_period="monthly", facility_count=7), + x_tenant_id=None, + principal=_principal(tenant_id="tenant-facility"), + ) + + assert captured["line_items"] == [{"price": price_id, "quantity": 7}] + assert captured["metadata"]["facility_quantity"] == "7" + assert captured["subscription_data"]["metadata"]["facility_quantity"] == "7" + assert response.plan == plan_id + assert response.facility_quantity == 7 + # ``amount`` stays the unit price, not the unit * quantity total. + assert response.amount == unit_amount + + @pytest.mark.asyncio + async def test_omitted_facility_count_defaults_to_one(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured = self._capture_session(monkeypatch) + + response = await routes_mod.create_checkout( + CheckoutRequest(plan_id="list", billing_period="monthly"), + x_tenant_id=None, + principal=_principal(tenant_id="tenant-default"), + ) + + assert captured["line_items"] == [{"price": "price_list_monthly", "quantity": 1}] + assert captured["metadata"]["facility_quantity"] == "1" + assert captured["subscription_data"]["metadata"]["facility_quantity"] == "1" + assert response.facility_quantity == 1 + + @pytest.mark.parametrize("facility_count", [0, -1, -100]) + @pytest.mark.asyncio + async def test_facility_count_below_one_is_rejected_before_stripe( + self, + monkeypatch: pytest.MonkeyPatch, + facility_count: int, + ) -> None: + def _must_not_run(**_kwargs: Any) -> Any: + raise AssertionError("Stripe must not be called for an invalid facility_count") + + monkeypatch.setattr(stripe.checkout.Session, "create", _must_not_run) + + with pytest.raises(Exception) as excinfo: + await routes_mod.create_checkout( + CheckoutRequest(plan_id="list", billing_period="monthly", facility_count=facility_count), + x_tenant_id=None, + principal=_principal(tenant_id="tenant-invalid"), + ) + exc = excinfo.value + assert getattr(exc, "status_code", None) == 400 + assert "at least 1" in getattr(exc, "detail", "") + + @pytest.mark.asyncio + async def test_facility_count_above_max_is_rejected_before_stripe(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _must_not_run(**_kwargs: Any) -> Any: + raise AssertionError("Stripe must not be called for facility_count above the cap") + + monkeypatch.setattr(stripe.checkout.Session, "create", _must_not_run) + + with pytest.raises(Exception) as excinfo: + await routes_mod.create_checkout( + CheckoutRequest( + plan_id="list", + billing_period="monthly", + facility_count=routes_mod.MAX_CHECKOUT_FACILITY_QUANTITY + 1, + ), + x_tenant_id=None, + principal=_principal(tenant_id="tenant-too-many"), + ) + exc = excinfo.value + assert getattr(exc, "status_code", None) == 400 + assert str(routes_mod.MAX_CHECKOUT_FACILITY_QUANTITY) in getattr(exc, "detail", "") + + @pytest.mark.asyncio + async def test_growth_alias_with_facility_count_uses_list_price(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Legacy ``growth`` callers keep working during the alias period and + still get per-facility quantity against the LIST price env.""" + captured = self._capture_session(monkeypatch) + + response = await routes_mod.create_checkout( + CheckoutRequest(plan_id="growth", billing_period="monthly", facility_count=3), + x_tenant_id=None, + principal=_principal(tenant_id="tenant-alias"), + ) + + assert captured["line_items"] == [{"price": "price_list_monthly", "quantity": 3}] + assert captured["metadata"]["plan_id"] == "list" + assert captured["metadata"]["facility_quantity"] == "3" + assert response.plan == "list" + assert response.facility_quantity == 3 + + # ── create_checkout — billing period must match the price ID used ────────── @@ -541,6 +682,37 @@ async def test_missing_stripe_config_keeps_stored_subscription_snapshot( assert result.billing_period == "annual" assert result.current_period_end == "2030-01-01T00:00:00+00:00" + @pytest.mark.asyncio + async def test_facility_quantity_from_mapping_sets_facilities_limit(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Per-facility plans carry the billed quantity in the mapping; it + replaces the plan's static ``-1`` (quantity-driven) limit.""" + monkeypatch.setattr( + state_mod, + "_get_subscription_mapping", + lambda _t: { + "plan_id": "list", + "status": "active", + "facility_quantity": "4", + }, + ) + + result = await routes_mod.get_subscription("tenant-facilities", _=None) + assert result.facilities_limit == 4 + + @pytest.mark.parametrize("stored", [None, "", "0", "-2", "not-a-number"]) + @pytest.mark.asyncio + async def test_missing_or_invalid_facility_quantity_falls_back_to_plan_limit( + self, monkeypatch: pytest.MonkeyPatch, stored: Optional[str] + ) -> None: + mapping: dict[str, Any] = {"plan_id": "list", "status": "active"} + if stored is not None: + mapping["facility_quantity"] = stored + monkeypatch.setattr(state_mod, "_get_subscription_mapping", lambda _t: mapping) + + result = await routes_mod.get_subscription("tenant-fallback", _=None) + # list limits are -1 ("quantity-driven") until a quantity is known. + assert result.facilities_limit == plans_mod.PLANS["list"]["limits"]["facilities"] + # ── stripe_webhooks / stripe_webhook_legacy thin wrappers ────────────────── From b3ad65c1c8762bcb2f162113ca703d7e0628f947 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:33:26 -0700 Subject: [PATCH 3/6] fix(billing): derive invoice fixture amounts from catalog PLANS Codex flagged the invoice list test still using a literal Scale $1,999 cents value. Both sample invoice amounts now come from PLANS list/founding price_monthly * 100, and the mapping plan_id is list. --- .../ingestion/tests/test_stripe_billing_router.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/services/ingestion/tests/test_stripe_billing_router.py b/services/ingestion/tests/test_stripe_billing_router.py index ba8eda459..1b8f979cc 100644 --- a/services/ingestion/tests/test_stripe_billing_router.py +++ b/services/ingestion/tests/test_stripe_billing_router.py @@ -446,6 +446,11 @@ def test_invoice_list_returns_paginated_results(monkeypatch: pytest.MonkeyPatch) monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_123") monkeypatch.setattr(authz, "consume_tenant_rate_limit", lambda **_kwargs: (True, 42)) + # Derive fixture cents from the live catalog so a retired Scale + # $1,999 cents literal cannot reappear after A-2155 list/founding pricing. + list_cents = int(stripe_billing.PLANS["list"]["price_monthly"]) * 100 + founding_cents = int(stripe_billing.PLANS["founding"]["price_monthly"]) * 100 + stripe_billing._store_subscription_mapping( tenant_id, { @@ -453,7 +458,7 @@ def test_invoice_list_returns_paginated_results(monkeypatch: pytest.MonkeyPatch) "session_id": "", "customer_id": "cus_invoice_1", "subscription_id": "sub_invoice_1", - "plan_id": "growth", + "plan_id": "list", "billing_period": "monthly", "status": "active", "customer_email": "billing@acme.example", @@ -465,8 +470,8 @@ class _InvoicePage: data = [ { "id": "in_1", - "amount_due": 99900, - "amount_paid": 99900, + "amount_due": list_cents, + "amount_paid": list_cents, "currency": "usd", "status": "paid", "created": 1700000000, @@ -475,7 +480,7 @@ class _InvoicePage: }, { "id": "in_2", - "amount_due": 199900, + "amount_due": founding_cents, "amount_paid": 0, "currency": "usd", "status": "open", From 96b7875e5dd441fd0f858d0012700f8e95bce09d Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:26:33 -0700 Subject: [PATCH 4/6] fix(billing): expect normalized list plan in get_usage test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog rename aliases growth→list; get_usage returns the canonical id, so the usage-routes assertion must expect list (A-2155). --- services/ingestion/tests/test_billing_usage_routes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/ingestion/tests/test_billing_usage_routes.py b/services/ingestion/tests/test_billing_usage_routes.py index f4f7446f6..6659238e6 100644 --- a/services/ingestion/tests/test_billing_usage_routes.py +++ b/services/ingestion/tests/test_billing_usage_routes.py @@ -146,7 +146,9 @@ async def test_get_usage_returns_current_period_row(monkeypatch): # No prior report yet against this row, so it's also fully unbilled. assert response.unbilled_unit_count == 42 assert response.metered_billing_enabled is True - assert response.plan == "growth" + # Stored mapping may still say growth (alias period); get_usage + # normalizes via PLAN_ALIASES to the canonical list plan id. + assert response.plan == "list" # billing_period_start is the first of the current UTC month. assert response.billing_period_start.endswith("-01") From 5984e5e0a80636257b17219815e2a5906fa0459e Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:58:42 -0800 Subject: [PATCH 5/6] fix(billing): persist facility quantity and clear review threads Persist verified facility_quantity on checkout and subscription.updated, refresh it in get_subscription, and forward facility_count through the frontend checkout proxy. Keep SCALE monthly reverse-mapped to list for in-flight sessions; leave annual/pilot unmapped. Harden portal empty-env fallback and tighten plan-list assertions. --- .env.example | 4 +- .../api/billing-checkout-route.test.ts | 8 ++ .../src/app/api/billing/checkout/route.ts | 9 ++- .../ingestion/app/stripe_billing/helpers.py | 23 ++++++ .../ingestion/app/stripe_billing/plans.py | 24 +++--- .../ingestion/app/stripe_billing/routes.py | 7 +- .../ingestion/app/stripe_billing/webhooks.py | 18 +++++ .../tests/test_stripe_routes_1342.py | 53 ++++++++++--- ...est_stripe_subscription_period_end_2951.py | 22 ++++++ tests/billing/test_stripe_billing.py | 76 +++++++++++++++++-- 10 files changed, 210 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index a41325466..9fa8fa171 100644 --- a/.env.example +++ b/.env.example @@ -126,7 +126,9 @@ STRIPE_PRICE_PILOT_CONVERT_30= STRIPE_PRICE_METERED_LABEL_EVENTS= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -STRIPE_PORTAL_RETURN_URL= +# Optional; omit or leave unset so routes fall back to DEFAULT_PORTAL_RETURN_URL. +# An empty assignment makes os.getenv return "" and breaks the billing portal. +# STRIPE_PORTAL_RETURN_URL=https://regengine.co/dashboard # STRIPE_PORTAL_CONFIGURATION_ID= # Dedicated Vercel-to-Railway client-IP attestation key. Set the exact same diff --git a/frontend/src/__tests__/api/billing-checkout-route.test.ts b/frontend/src/__tests__/api/billing-checkout-route.test.ts index 9149c13e7..aa44d50c3 100644 --- a/frontend/src/__tests__/api/billing-checkout-route.test.ts +++ b/frontend/src/__tests__/api/billing-checkout-route.test.ts @@ -55,6 +55,14 @@ describe('billing checkout route', () => { expect(payload.success_url).not.toContain('{CHECKOUT_SESSION_ID}'); }); + it('forwards facility_count to the billing backend', async () => { + await POST(makeRequest({ plan_id: 'list', billing_period: 'monthly', facility_count: 7 })); + + const fetchMock = fetch as unknown as ReturnType; + const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(payload.facility_count).toBe(7); + }); + it('does not inject an environment API key or caller-supplied tenant into public checkout', async () => { vi.stubEnv('REGENGINE_API_KEY', 'must-not-be-forwarded'); // pragma: allowlist secret vi.stubEnv('REGENGINE_API_KEY_TENANT_ID', TENANT_A); diff --git a/frontend/src/app/api/billing/checkout/route.ts b/frontend/src/app/api/billing/checkout/route.ts index fd5352da7..079ddd6a9 100644 --- a/frontend/src/app/api/billing/checkout/route.ts +++ b/frontend/src/app/api/billing/checkout/route.ts @@ -5,7 +5,7 @@ * attaches authenticated source-IP metadata for the shared abuse budget. * * POST /api/billing/checkout - * Body: { plan_id, billing_period, customer_email?, tenant_id?, tenant_name? } + * Body: { plan_id, billing_period, customer_email?, tenant_id?, tenant_name?, facility_count? } * Returns: { checkout_url, session_id, plan, billing_period, amount, currency } */ import { NextRequest, NextResponse } from 'next/server'; @@ -35,7 +35,7 @@ export async function POST(request: NextRequest) { if (retiredCredentialError) return retiredCredentialError; const body = await request.json(); - const { plan_id, billing_period, customer_email, tenant_id, tenant_name } = body; + const { plan_id, billing_period, customer_email, tenant_id, tenant_name, facility_count } = body; if (!plan_id) { return NextResponse.json({ error: 'plan_id is required' }, { status: 400 }); @@ -74,7 +74,7 @@ export async function POST(request: NextRequest) { const encodedPlanId = encodeURIComponent(String(plan_id)); // Build the request to the backend billing service - const checkoutPayload = { + const checkoutPayload: Record = { plan_id, billing_period: billing_period || 'monthly', customer_email: customer_email || undefined, @@ -83,6 +83,9 @@ export async function POST(request: NextRequest) { success_url: `${siteUrl}/signup?checkout=success&plan=${encodedPlanId}`, cancel_url: `${siteUrl}/pricing?checkout=cancelled`, }; + if (facility_count !== undefined && facility_count !== null && facility_count !== '') { + checkoutPayload.facility_count = facility_count; + } const headers = new Headers({ 'Content-Type': 'application/json' }); if (hasCallerCredential && authenticatedTenantId) { diff --git a/services/ingestion/app/stripe_billing/helpers.py b/services/ingestion/app/stripe_billing/helpers.py index d663523ed..728b0a207 100644 --- a/services/ingestion/app/stripe_billing/helpers.py +++ b/services/ingestion/app/stripe_billing/helpers.py @@ -356,6 +356,29 @@ def _extract_subscription_price_ids(subscription: Any) -> list[str]: return price_ids +def _extract_subscription_facility_quantity(subscription: Any) -> Optional[int]: + """Resolve the billed facility count for a Stripe subscription. + + Prefer the subscription item ``quantity`` (what Stripe bills). Fall back + to ``metadata.facility_quantity`` stamped at checkout when items omit a + quantity — never invent a default here; callers treat ``None`` as unknown. + """ + items = _stripe_get(subscription, "items", None) + data = _stripe_get(items, "data", None) if items is not None else None + if isinstance(data, (list, tuple)): + for item in data: + qty = _coerce_optional_int(_stripe_get(item, "quantity", None)) + if qty: + return qty + + metadata = _stripe_get(subscription, "metadata", None) or {} + if isinstance(metadata, dict): + qty = _coerce_optional_int(metadata.get("facility_quantity")) + if qty: + return qty + return None + + def _extract_paid_at(invoice_payload: dict[str, Any]) -> Optional[str]: status_transitions = invoice_payload.get("status_transitions") or {} paid_at = _coerce_optional_int(status_transitions.get("paid_at")) diff --git a/services/ingestion/app/stripe_billing/plans.py b/services/ingestion/app/stripe_billing/plans.py index 77c7c2766..fd2c5197b 100644 --- a/services/ingestion/app/stripe_billing/plans.py +++ b/services/ingestion/app/stripe_billing/plans.py @@ -240,13 +240,16 @@ def _plan_for_stripe_price(price_id: str) -> Optional[tuple[str, str]]: the price matches no configured catalog entry. Never raises — the webhook path must fail closed on catalog gaps, not 500. - The deprecated growth monthly env still maps to list during the alias - period. Old annual growth/scale envs are intentionally *not* mapped: - annual pricing is OPEN, so we must not invent an annual entitlement. - - Pilot Price IDs map to ``("pilot", "one_time")`` even though pilot has - no recurring ``PLANS`` entry. The webhook PR decides whether that grants - any status; this helper only identifies the Price. + The deprecated growth/scale *monthly* envs still map to list during the + alias period so in-flight checkouts on those Prices keep verifying. Old + annual growth/scale envs stay unmapped: annual pricing is OPEN, and + reverse-mapping them would invent an annual entitlement that + ``_resolve_price_id`` refuses to sell. + + Pilot Price IDs are intentionally *not* reverse-mapped here. Mapping them + would let ``checkout.session.completed`` treat a one-time pilot Price as + Stripe-side truth and persist an entitling status. Pilot identification + and credit handling belong in the dedicated pilot webhook PR. """ if not price_id: return None @@ -258,12 +261,9 @@ def _plan_for_stripe_price(price_id: str) -> Optional[tuple[str, str]]: if env_var: configured.append((env_var, plan_id, period)) - # Alias period only — deprecated monthly list price. + # Alias period only — deprecated monthly list prices (growth + scale). configured.append((DEPRECATED_LIST_PRICE_ENV_MONTHLY, "list", "monthly")) - - # One-time pilot prices (not recurring plans). - configured.append((PILOT_PRICE_ENV_STANDARD, "pilot", "one_time")) - configured.append((PILOT_PRICE_ENV_CONVERT_30, "pilot", "one_time")) + configured.append(("STRIPE_PRICE_SCALE_MONTHLY", "list", "monthly")) for env_var, plan_id, period in configured: if os.getenv(env_var) == price_id: diff --git a/services/ingestion/app/stripe_billing/routes.py b/services/ingestion/app/stripe_billing/routes.py index 3c8138e41..75f90eb99 100644 --- a/services/ingestion/app/stripe_billing/routes.py +++ b/services/ingestion/app/stripe_billing/routes.py @@ -393,10 +393,13 @@ async def get_subscription( refreshed_period_end = _helpers_mod._extract_subscription_period_end(subscription) if refreshed_period_end: current_period_end = refreshed_period_end + refreshed_qty = _helpers_mod._extract_subscription_facility_quantity(subscription) mapping["status"] = status if refreshed_period_end: mapping["current_period_end"] = refreshed_period_end + if refreshed_qty: + mapping["facility_quantity"] = str(int(refreshed_qty)) _state_mod._store_subscription_mapping(resolved_tenant_id, mapping) except HTTPException as exc: if getattr(exc, "status_code", None) != 500: @@ -495,7 +498,7 @@ async def create_portal_session_for_tenant( if request.return_url: return_url = _helpers_mod._validate_redirect_url(request.return_url, field="return_url") else: - server_default = os.getenv("STRIPE_PORTAL_RETURN_URL", DEFAULT_PORTAL_RETURN_URL) + server_default = (os.getenv("STRIPE_PORTAL_RETURN_URL") or DEFAULT_PORTAL_RETURN_URL) return_url = _helpers_mod._validate_redirect_url(server_default, field="return_url") try: @@ -707,7 +710,7 @@ async def create_portal_session_legacy( # doesn't accept a client override, but the env-var value is still # run through the allowlist so a misconfigured STRIPE_PORTAL_RETURN_URL # fails at request time rather than silently redirecting. - server_default = os.getenv("STRIPE_PORTAL_RETURN_URL", DEFAULT_PORTAL_RETURN_URL) + server_default = (os.getenv("STRIPE_PORTAL_RETURN_URL") or DEFAULT_PORTAL_RETURN_URL) return_url = _helpers_mod._validate_redirect_url(server_default, field="return_url") try: diff --git a/services/ingestion/app/stripe_billing/webhooks.py b/services/ingestion/app/stripe_billing/webhooks.py index 40ea08aa0..115b979c1 100644 --- a/services/ingestion/app/stripe_billing/webhooks.py +++ b/services/ingestion/app/stripe_billing/webhooks.py @@ -142,6 +142,9 @@ async def _handle_checkout_completed( # (or one we could not verify) entitles nothing. subscription_status = "incomplete" current_period_end = None + facility_quantity: Optional[int] = _helpers_mod._coerce_optional_int( + metadata.get("facility_quantity") + ) payment_status = str(session.get("payment_status") or "") if subscription_id: try: @@ -166,6 +169,10 @@ async def _handle_checkout_completed( # puts it — it lives on the subscription items now, not the # subscription — so the first mapping write no longer stores "". current_period_end = _helpers_mod._extract_subscription_period_end(subscription) + # Prefer the billed Stripe item quantity over checkout metadata. + stripe_qty = _helpers_mod._extract_subscription_facility_quantity(subscription) + if stripe_qty: + facility_quantity = stripe_qty # H2: ``metadata.plan_id`` is a client-supplied claim. The plan that # gets persisted — and that plan-feature gates later read — must be @@ -244,6 +251,8 @@ async def _handle_checkout_completed( ), "current_period_end": current_period_end or "", } + if facility_quantity is not None: + initial_mapping["facility_quantity"] = str(int(facility_quantity)) if event_created is not None: initial_mapping["last_event_created"] = str(int(event_created)) @@ -267,6 +276,7 @@ async def _handle_checkout_completed( existing["status"] = pending_status for key in ( "current_period_end", + "facility_quantity", "last_invoice_id", "last_payment_at", "last_payment_failure_at", @@ -300,6 +310,7 @@ def _update_subscription_status( status: str, *, current_period_end: Optional[str] = None, + facility_quantity: Optional[int] = None, last_invoice_id: Optional[str] = None, last_payment_at: Optional[str] = None, last_payment_failure_at: Optional[str] = None, @@ -336,6 +347,9 @@ def _update_subscription_status( "status": status, "event_created": int(event_created or 0), "current_period_end": current_period_end, + "facility_quantity": ( + str(int(facility_quantity)) if facility_quantity is not None else None + ), "last_invoice_id": last_invoice_id, "last_payment_at": last_payment_at, "last_payment_failure_at": last_payment_failure_at, @@ -414,6 +428,8 @@ def _update_subscription_status( existing.update(updated_fields) if current_period_end is not None: existing["current_period_end"] = current_period_end + if facility_quantity is not None: + existing["facility_quantity"] = str(int(facility_quantity)) if last_invoice_id is not None: existing["last_invoice_id"] = last_invoice_id if last_payment_at is not None: @@ -666,11 +682,13 @@ async def _handle_stripe_event(event: dict[str, Any]) -> None: if event_type in {"customer.subscription.deleted", "customer.subscription.updated"}: sub_status = data_object.get("status") status = "canceled" if event_type == "customer.subscription.deleted" else (sub_status or "active") + updated_qty = _helpers_mod._extract_subscription_facility_quantity(data_object) _update_subscription_status( subscription_id=data_object.get("id"), customer_id=data_object.get("customer"), status=status, + facility_quantity=updated_qty, event_created=event_created, ) diff --git a/services/ingestion/tests/test_stripe_routes_1342.py b/services/ingestion/tests/test_stripe_routes_1342.py index 1b0d628a6..8956dd54f 100644 --- a/services/ingestion/tests/test_stripe_routes_1342.py +++ b/services/ingestion/tests/test_stripe_routes_1342.py @@ -91,17 +91,20 @@ async def test_returns_all_plans_with_expected_shape(self) -> None: result = await routes_mod.list_plans() assert "plans" in result by_id = {plan["id"]: plan for plan in result["plans"]} - assert {"list", "founding", "enterprise", "base"} <= set(by_id) + assert set(by_id) == {"list", "founding", "enterprise", "base"} + core_keys = { + "id", + "name", + "price_monthly", + "price_annual", + "features", + "limits", + "product_status", + } + compat_keys = {"ga_monthly", "partner_monthly", "ga_annual", "partner_annual"} for plan in result["plans"]: - assert { - "id", - "name", - "price_monthly", - "price_annual", - "features", - "limits", - "product_status", - } <= set(plan.keys()) + expected = core_keys | compat_keys if plan["id"] in ("list", "base") else core_keys + assert set(plan.keys()) == expected assert plan["product_status"] == "Beta" assert any("epcis" in f.lower() and "preview" in f.lower() for f in plan["features"]) @@ -639,6 +642,7 @@ async def test_mapping_with_subscription_id_refreshes_from_stripe(self, monkeypa "status": "active", # Epoch seconds → helpers._format_period_end → ISO string. "current_period_end": 1_800_000_000, + "items": {"data": [{"quantity": 6, "price": {"id": "price_list_m"}}]}, }, ) @@ -649,6 +653,8 @@ async def test_mapping_with_subscription_id_refreshes_from_stripe(self, monkeypa assert "2027" in result.current_period_end # 1_800_000_000 ≈ 2027 # The refreshed state was persisted. assert stored["status"] == "active" + assert stored["facility_quantity"] == "6" + assert result.facilities_limit == 6 @pytest.mark.asyncio async def test_missing_stripe_config_keeps_stored_subscription_snapshot( @@ -848,6 +854,33 @@ def _create(customer_id: str, return_url: str) -> Any: ) assert captured["return_url"] == "https://app.regengine.co/settings" + @pytest.mark.asyncio + async def test_empty_portal_return_url_env_falls_back_to_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty STRIPE_PORTAL_RETURN_URL must not beat DEFAULT_PORTAL_RETURN_URL.""" + from app.stripe_billing.plans import DEFAULT_PORTAL_RETURN_URL + + monkeypatch.setenv("STRIPE_PORTAL_RETURN_URL", "") + monkeypatch.setattr(customers_mod, "_ensure_customer_mapping", lambda **_: "cus_empty") + captured: dict[str, Any] = {} + + class _Portal: + url = "https://billing.stripe.com/session/empty" + + def _create(customer_id: str, return_url: str) -> Any: + captured["return_url"] = return_url + return _Portal() + + monkeypatch.setattr(customers_mod, "_create_portal_session", _create) + + await routes_mod.create_portal_session_for_tenant( + self._req(), + x_tenant_id=None, + principal=_principal(), + ) + assert captured["return_url"] == DEFAULT_PORTAL_RETURN_URL + @pytest.mark.asyncio async def test_redis_error_on_ensure_customer_raises_503(self, monkeypatch: pytest.MonkeyPatch) -> None: def _boom(**_kwargs: Any) -> None: diff --git a/services/ingestion/tests/test_stripe_subscription_period_end_2951.py b/services/ingestion/tests/test_stripe_subscription_period_end_2951.py index a78c9474c..066a75fc3 100644 --- a/services/ingestion/tests/test_stripe_subscription_period_end_2951.py +++ b/services/ingestion/tests/test_stripe_subscription_period_end_2951.py @@ -192,3 +192,25 @@ async def test_missing_period_end_does_not_clobber_stored_value( f"a GET must not overwrite the stored renewal date, got " f"{stored.get('current_period_end')!r}" ) + + +class TestExtractSubscriptionFacilityQuantity: + def test_prefers_item_quantity(self): + subscription = { + "items": {"data": [{"quantity": 7, "price": {"id": "price_x"}}]}, + "metadata": {"facility_quantity": "2"}, + } + assert helpers_mod._extract_subscription_facility_quantity(subscription) == 7 + + def test_falls_back_to_metadata(self): + subscription = { + "items": {"data": [{"price": {"id": "price_x"}}]}, + "metadata": {"facility_quantity": "4"}, + } + assert helpers_mod._extract_subscription_facility_quantity(subscription) == 4 + + def test_returns_none_when_unknown(self): + assert helpers_mod._extract_subscription_facility_quantity({"status": "active"}) is None + assert helpers_mod._extract_subscription_facility_quantity( + {"items": {"data": [{"quantity": 0}]}} + ) is None diff --git a/tests/billing/test_stripe_billing.py b/tests/billing/test_stripe_billing.py index d568dd15c..bb5e6c19c 100644 --- a/tests/billing/test_stripe_billing.py +++ b/tests/billing/test_stripe_billing.py @@ -198,7 +198,7 @@ def test_resolve_founding_monthly(self): assert price_id == "price_founding_m" assert amount == 499 - @patch.dict("os.environ", {"STRIPE_PRICE_GROWTH_MONTHLY": "price_growth_m"}) + @patch.dict("os.environ", {"STRIPE_PRICE_GROWTH_MONTHLY": "price_growth_m"}, clear=True) def test_resolve_growth_monthly_still_works_alias_period(self): """Deprecated growth monthly env resolves to list during the alias period, until Railway carries STRIPE_PRICE_LIST_MONTHLY.""" @@ -268,9 +268,8 @@ class TestPlanForStripePrice: [ ("STRIPE_PRICE_LIST_MONTHLY", "price_list_m", ("list", "monthly")), ("STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_m", ("founding", "monthly")), - ("STRIPE_PRICE_PILOT_STANDARD", "price_pilot_std", ("pilot", "one_time")), - ("STRIPE_PRICE_PILOT_CONVERT_30", "price_pilot_conv", ("pilot", "one_time")), ("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_m", ("list", "monthly")), + ("STRIPE_PRICE_SCALE_MONTHLY", "price_scale_m", ("list", "monthly")), ], ) def test_reverse_maps_configured_prices(self, env_var, price_id, expected): @@ -278,8 +277,29 @@ def test_reverse_maps_configured_prices(self, env_var, price_id, expected): assert _plan_for_stripe_price(price_id) == expected def test_old_annual_envs_do_not_invent_annual(self): - with patch.dict("os.environ", {"STRIPE_PRICE_GROWTH_ANNUAL": "price_growth_a"}, clear=True): + with patch.dict( + "os.environ", + { + "STRIPE_PRICE_GROWTH_ANNUAL": "price_growth_a", + "STRIPE_PRICE_SCALE_ANNUAL": "price_scale_a", + }, + clear=True, + ): assert _plan_for_stripe_price("price_growth_a") is None + assert _plan_for_stripe_price("price_scale_a") is None + + def test_pilot_prices_are_not_reverse_mapped(self): + """Pilot is one-time; reverse-mapping would entitle via checkout webhook.""" + with patch.dict( + "os.environ", + { + "STRIPE_PRICE_PILOT_STANDARD": "price_pilot_std", + "STRIPE_PRICE_PILOT_CONVERT_30": "price_pilot_conv", + }, + clear=True, + ): + assert _plan_for_stripe_price("price_pilot_std") is None + assert _plan_for_stripe_price("price_pilot_conv") is None # ── Checkout Session Creation ──────────────────────────────────── @@ -550,7 +570,8 @@ async def test_checkout_completed_stores_mapping(self, mock_stripe, mock_redis_c mock_stripe.Subscription.retrieve.return_value = { "status": "active", "current_period_end": 1700000000, - "items": {"data": [{"price": {"id": "price_growth_m"}}]}, + "items": {"data": [{"price": {"id": "price_growth_m"}, "quantity": 7}]}, + "metadata": {"facility_quantity": "3"}, # item quantity wins } session = { @@ -565,6 +586,7 @@ async def test_checkout_completed_stores_mapping(self, mock_stripe, mock_redis_c "tenant_id": "tenant-abc", "plan_id": "growth", "billing_period": "monthly", + "facility_quantity": "3", }, } @@ -578,6 +600,7 @@ async def test_checkout_completed_stores_mapping(self, mock_stripe, mock_redis_c assert payload["status"] == "active" assert payload["subscription_id"] == "sub_test_456" assert payload["customer_id"] == "cus_test_789" + assert payload["facility_quantity"] == "7" @pytest.mark.asyncio @patch("services.ingestion.app.stripe_billing._create_tenant_via_admin") @@ -643,7 +666,7 @@ async def test_checkout_plan_mismatch_fails_closed_to_stripe_truth( "customer_details": {"email": "user@example.com"}, "metadata": { "tenant_id": "tenant-abc", - "plan_id": "scale", # claim richer than what was paid + "plan_id": "enterprise", # claim richer than what was paid "billing_period": "monthly", }, } @@ -693,6 +716,47 @@ async def test_checkout_plan_unverifiable_fails_closed(self, mock_stripe, mock_r assert payload["plan_id"] == "" assert payload["status"] == "incomplete" # gate denies; alert fired + @pytest.mark.asyncio + @patch.dict("os.environ", {"STRIPE_PRICE_LIST_MONTHLY": "price_list_m"}) + @patch("services.ingestion.app.stripe_billing._store_subscription_mapping") + @patch("services.ingestion.app.stripe_billing._redis_client") + @patch("services.ingestion.app.stripe_billing.stripe") + async def test_checkout_persists_facility_quantity_from_metadata_fallback( + self, mock_stripe, mock_redis_client, mock_store + ): + """When Stripe items omit quantity, checkout metadata still lands.""" + mock_client = MagicMock() + mock_client.get.side_effect = ["tenant-abc", None] + mock_client.getdel.return_value = None + mock_redis_client.return_value = mock_client + + mock_stripe.Subscription.retrieve.return_value = { + "status": "active", + "current_period_end": 1700000000, + "items": {"data": [{"price": {"id": "price_list_m"}}]}, + } + + session = { + "id": "cs_test_qty", + "subscription": "sub_qty", + "customer": "cus_qty", + "payment_status": "paid", + "customer_details": {"email": "user@example.com"}, + "metadata": { + "tenant_id": "tenant-abc", + "plan_id": "list", + "billing_period": "monthly", + "facility_quantity": "5", + }, + } + + await _handle_checkout_completed(session) + + payload = mock_store.call_args[0][1] + assert payload["facility_quantity"] == "5" + assert payload["plan_id"] == "list" + assert payload["status"] == "active" + # ── Webhook Event: customer.subscription.deleted ───────────────── From b9ec9c4160abe5bea635738f214df64a28838db1 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:23:19 -0800 Subject: [PATCH 6/6] fix(billing): facility qty from catalog item; sync reverse-map tests Only list/founding (incl. alias) item quantities count as facility quantity so metered add-ons cannot shrink facilities_limit. Align test_stripe_plans_1342 with the scale-monthly / fail-closed pilot reverse-map contract. --- .../ingestion/app/stripe_billing/helpers.py | 26 +++++++++++++++-- .../ingestion/tests/test_stripe_plans_1342.py | 16 ++++++---- .../tests/test_stripe_routes_1342.py | 1 + ...est_stripe_subscription_period_end_2951.py | 29 +++++++++++++++++-- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/services/ingestion/app/stripe_billing/helpers.py b/services/ingestion/app/stripe_billing/helpers.py index 728b0a207..09b861a0d 100644 --- a/services/ingestion/app/stripe_billing/helpers.py +++ b/services/ingestion/app/stripe_billing/helpers.py @@ -359,14 +359,34 @@ def _extract_subscription_price_ids(subscription: Any) -> list[str]: def _extract_subscription_facility_quantity(subscription: Any) -> Optional[int]: """Resolve the billed facility count for a Stripe subscription. - Prefer the subscription item ``quantity`` (what Stripe bills). Fall back - to ``metadata.facility_quantity`` stamped at checkout when items omit a - quantity — never invent a default here; callers treat ``None`` as unknown. + Prefer the quantity on the list/founding (or alias) price item — never the + first truthy item quantity, because metered add-ons share the subscription + and their quantity is not a facility count. Fall back to + ``metadata.facility_quantity`` stamped at checkout when no catalog item + quantity is present. Never invent a default; callers treat ``None`` as + unknown. """ + # Local import: plans does not import helpers, but keep this lazy so helper + # module import stays free of catalog coupling for unit tests that only + # stub stripe helpers. + from .plans import _plan_for_stripe_price + items = _stripe_get(subscription, "items", None) data = _stripe_get(items, "data", None) if items is not None else None if isinstance(data, (list, tuple)): for item in data: + price_id: Any = None + price = _stripe_get(item, "price", None) + if price is not None: + price_id = _stripe_get(price, "id", None) + if not price_id: + plan = _stripe_get(item, "plan", None) + price_id = _stripe_get(plan, "id", plan) if plan is not None else None + if not (isinstance(price_id, str) and price_id): + continue + mapped = _plan_for_stripe_price(price_id) + if not mapped or mapped[0] not in {"list", "founding"}: + continue qty = _coerce_optional_int(_stripe_get(item, "quantity", None)) if qty: return qty diff --git a/services/ingestion/tests/test_stripe_plans_1342.py b/services/ingestion/tests/test_stripe_plans_1342.py index c8eba1146..be3fb32fd 100644 --- a/services/ingestion/tests/test_stripe_plans_1342.py +++ b/services/ingestion/tests/test_stripe_plans_1342.py @@ -310,9 +310,8 @@ class TestPlanForStripePrice: [ ("STRIPE_PRICE_LIST_MONTHLY", "price_list_m", ("list", "monthly")), ("STRIPE_PRICE_FOUNDING_MONTHLY", "price_founding_m", ("founding", "monthly")), - (PILOT_PRICE_ENV_STANDARD, "price_pilot_std", ("pilot", "one_time")), - (PILOT_PRICE_ENV_CONVERT_30, "price_pilot_conv", ("pilot", "one_time")), (DEPRECATED_LIST_PRICE_ENV_MONTHLY, "price_growth_legacy", ("list", "monthly")), + ("STRIPE_PRICE_SCALE_MONTHLY", "price_scale_m", ("list", "monthly")), ], ) def test_reverse_maps_configured_prices(self, monkeypatch, env_var, value, expected): @@ -322,11 +321,16 @@ def test_reverse_maps_configured_prices(self, monkeypatch, env_var, value, expec @pytest.mark.parametrize( "env_var", - ["STRIPE_PRICE_GROWTH_ANNUAL", "STRIPE_PRICE_SCALE_MONTHLY", "STRIPE_PRICE_SCALE_ANNUAL"], + [ + "STRIPE_PRICE_GROWTH_ANNUAL", + "STRIPE_PRICE_SCALE_ANNUAL", + PILOT_PRICE_ENV_STANDARD, + PILOT_PRICE_ENV_CONVERT_30, + ], ) - def test_old_annual_and_scale_envs_do_not_invent_entitlements(self, monkeypatch, env_var): - # Only growth monthly survives as an alias. Old annual/scale Prices - # must not reverse-map to an annual or scale entitlement. + def test_old_annual_and_pilot_envs_do_not_invent_entitlements(self, monkeypatch, env_var): + # Scale monthly reverse-maps for in-flight checkouts; annual is OPEN and + # pilot is one-time — neither may invent a recurring entitlement here. _clear_catalog_env(monkeypatch) monkeypatch.setenv(env_var, "price_legacy_only") assert _plan_for_stripe_price("price_legacy_only") is None diff --git a/services/ingestion/tests/test_stripe_routes_1342.py b/services/ingestion/tests/test_stripe_routes_1342.py index 8956dd54f..3e28ac79e 100644 --- a/services/ingestion/tests/test_stripe_routes_1342.py +++ b/services/ingestion/tests/test_stripe_routes_1342.py @@ -618,6 +618,7 @@ async def test_mapping_without_subscription_id_returns_stored_status(self, monke @pytest.mark.asyncio async def test_mapping_with_subscription_id_refreshes_from_stripe(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_m") monkeypatch.setattr( state_mod, "_get_subscription_mapping", diff --git a/services/ingestion/tests/test_stripe_subscription_period_end_2951.py b/services/ingestion/tests/test_stripe_subscription_period_end_2951.py index 066a75fc3..aac81045f 100644 --- a/services/ingestion/tests/test_stripe_subscription_period_end_2951.py +++ b/services/ingestion/tests/test_stripe_subscription_period_end_2951.py @@ -195,16 +195,39 @@ async def test_missing_period_end_does_not_clobber_stored_value( class TestExtractSubscriptionFacilityQuantity: - def test_prefers_item_quantity(self): + def test_prefers_catalog_item_quantity(self, monkeypatch): + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_m") subscription = { - "items": {"data": [{"quantity": 7, "price": {"id": "price_x"}}]}, + "items": {"data": [{"quantity": 7, "price": {"id": "price_list_m"}}]}, "metadata": {"facility_quantity": "2"}, } assert helpers_mod._extract_subscription_facility_quantity(subscription) == 7 + def test_ignores_metered_addon_quantity(self, monkeypatch): + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_m") + monkeypatch.setenv("STRIPE_PRICE_METERED_LABEL_EVENTS", "price_metered") + subscription = { + "items": { + "data": [ + {"quantity": 1, "price": {"id": "price_metered"}}, + {"quantity": 7, "price": {"id": "price_list_m"}}, + ] + }, + "metadata": {"facility_quantity": "2"}, + } + assert helpers_mod._extract_subscription_facility_quantity(subscription) == 7 + + def test_falls_back_to_metadata_when_only_addon_qty(self, monkeypatch): + monkeypatch.setenv("STRIPE_PRICE_METERED_LABEL_EVENTS", "price_metered") + subscription = { + "items": {"data": [{"quantity": 99, "price": {"id": "price_metered"}}]}, + "metadata": {"facility_quantity": "4"}, + } + assert helpers_mod._extract_subscription_facility_quantity(subscription) == 4 + def test_falls_back_to_metadata(self): subscription = { - "items": {"data": [{"price": {"id": "price_x"}}]}, + "items": {"data": [{"price": {"id": "price_unknown"}}]}, "metadata": {"facility_quantity": "4"}, } assert helpers_mod._extract_subscription_facility_quantity(subscription) == 4