Skip to content
Open
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions docs/operations/FSMA_RAILWAY_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions services/ingestion/app/stripe_billing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
176 changes: 139 additions & 37 deletions services/ingestion/app/stripe_billing/plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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"))
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

for env_var, plan_id, period in configured:
if os.getenv(env_var) == price_id:
return (plan_id, period)
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
return None
Loading
Loading