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..09b861a0d 100644 --- a/services/ingestion/app/stripe_billing/helpers.py +++ b/services/ingestion/app/stripe_billing/helpers.py @@ -356,6 +356,49 @@ 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 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 + + 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/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 96cd07435..75f90eb99 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, ) @@ -361,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: @@ -379,6 +414,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 +429,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/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_billing_router.py b/services/ingestion/tests/test_stripe_billing_router.py index 3912b79f6..3ce54a60a 100644 --- a/services/ingestion/tests/test_stripe_billing_router.py +++ b/services/ingestion/tests/test_stripe_billing_router.py @@ -84,13 +84,66 @@ def _fake_create(**kwargs): assert response.session_id == "cs_test_123" assert response.plan == "list" assert captured["line_items"][0]["price"] == "price_growth_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() @@ -395,6 +448,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, { @@ -402,7 +460,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", @@ -414,8 +472,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, @@ -424,7 +482,7 @@ class _InvoicePage: }, { "id": "in_2", - "amount_due": 199900, + "amount_due": founding_cents, "amount_paid": 0, "currency": "usd", "status": "open", 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 eb3a0b303..3e28ac79e 100644 --- a/services/ingestion/tests/test_stripe_routes_1342.py +++ b/services/ingestion/tests/test_stripe_routes_1342.py @@ -132,7 +132,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: @@ -342,6 +342,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 ────────── @@ -477,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", @@ -501,6 +643,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"}}]}, }, ) @@ -511,6 +654,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( @@ -544,6 +689,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 ────────────────── 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..aac81045f 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,48 @@ 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_catalog_item_quantity(self, monkeypatch): + monkeypatch.setenv("STRIPE_PRICE_LIST_MONTHLY", "price_list_m") + subscription = { + "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_unknown"}}]}, + "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 204988030..bb5e6c19c 100644 --- a/tests/billing/test_stripe_billing.py +++ b/tests/billing/test_stripe_billing.py @@ -570,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 = { @@ -585,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", }, } @@ -598,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") @@ -713,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 ─────────────────