diff --git a/.env.example b/.env.example index 0e75027c1..9fa8fa171 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,24 @@ 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= +# 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 # 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..fd2c5197b 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/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 + + 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 prices (growth + scale). + configured.append((DEPRECATED_LIST_PRICE_ENV_MONTHLY, "list", "monthly")) + configured.append(("STRIPE_PRICE_SCALE_MONTHLY", "list", "monthly")) + + 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..96cd07435 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", @@ -421,7 +457,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: @@ -633,7 +669,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/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") diff --git a/services/ingestion/tests/test_stripe_billing_router.py b/services/ingestion/tests/test_stripe_billing_router.py index ca5f079e0..3912b79f6 100644 --- a/services/ingestion/tests/test_stripe_billing_router.py +++ b/services/ingestion/tests/test_stripe_billing_router.py @@ -46,6 +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.delenv("STRIPE_PRICE_LIST_MONTHLY", raising=False) monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") captured_funnel: dict[str, object] = {} @@ -81,10 +82,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" @@ -96,6 +97,7 @@ async def test_create_checkout_reuses_existing_customer_id_and_records_session(m tenant_id = "tenant-checkout-1" monkeypatch.setattr(stripe_billing, "_redis_client", lambda: fake_redis) monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_123") + monkeypatch.delenv("STRIPE_PRICE_LIST_MONTHLY", raising=False) monkeypatch.setenv("STRIPE_PRICE_GROWTH_MONTHLY", "price_growth_monthly") stripe_billing._store_subscription_mapping( @@ -179,7 +181,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 +547,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..be3fb32fd 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,174 @@ 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")), + (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): + _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_ANNUAL", + PILOT_PRICE_ENV_STANDARD, + PILOT_PRICE_ENV_CONVERT_30, + ], + ) + 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 + + 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 +452,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..eb3a0b303 100644 --- a/services/ingestion/tests/test_stripe_routes_1342.py +++ b/services/ingestion/tests/test_stripe_routes_1342.py @@ -86,20 +86,39 @@ 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 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 set(plan.keys()) == { - "id", - "name", - "price_monthly", - "price_annual", - "features", - "limits", - } + 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"]) + + 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 +172,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 +357,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 +374,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 +403,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 +470,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 +539,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" @@ -660,6 +679,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/tests/billing/test_stripe_billing.py b/tests/billing/test_stripe_billing.py index 904ac2296..204988030 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_GROWTH_MONTHLY": "price_growth_m"}) - def test_resolve_growth_monthly(self): - """Should return the growth plan, price ID, and monthly amount.""" + @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"}, 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.""" 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,86 @@ 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() - # Only monthly env is set; annual is not (patch.dict restores it). - os.environ.pop("STRIPE_PRICE_GROWTH_ANNUAL", None) + @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("growth", "annual") + _resolve_price_id("founding", "annual") + assert exc_info.value.status_code == 400 + assert "annual" in exc_info.value.detail.lower() + + @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("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_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): + 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", + "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 ──────────────────────────────────── @@ -242,7 +340,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 +594,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" @@ -565,7 +663,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", }, } @@ -574,7 +672,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