Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/__tests__/api/billing-checkout-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
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);
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/app/api/billing/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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<string, unknown> = {
plan_id,
billing_period: billing_period || 'monthly',
customer_email: customer_email || undefined,
Expand All @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions services/ingestion/app/stripe_billing/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metadata fallback skips Stripe objects

Medium Severity

The facility_quantity metadata fallback in _extract_subscription_facility_quantity only runs when metadata is a plain dict. Live Subscription.retrieve and webhook objects use StripeObject metadata, so the fallback never fires when the catalog item is missing or unmapped and quantity is lost.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a743ac9. Configure here.

return None
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved


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"))
Expand Down
13 changes: 12 additions & 1 deletion services/ingestion/app/stripe_billing/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"


Expand Down
45 changes: 43 additions & 2 deletions services/ingestion/app/stripe_billing/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand All @@ -283,6 +314,7 @@ async def create_checkout(
plan=plan["id"],
billing_period=billing_period,
amount=amount,
facility_quantity=facility_quantity,
)


Expand Down Expand Up @@ -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:
Expand All @@ -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)
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved

Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
return SubscriptionStatus(
tenant_id=resolved_tenant_id,
plan=plan_id,
Expand All @@ -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,
)


Expand Down
18 changes: 18 additions & 0 deletions services/ingestion/app/stripe_billing/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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))

Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)

Expand Down
Loading
Loading