Legal-i commited on
Commit
e18015e
·
verified ·
1 Parent(s): 33fe64e

Stage 159 (β2): Stripe Checkout for plan upgrades

Browse files
Files changed (3) hide show
  1. infra/api/app.py +34 -0
  2. infra/api/schemas.py +16 -0
  3. infra/billing_stripe.py +157 -0
infra/api/app.py CHANGED
@@ -57,6 +57,7 @@ from .schemas import (
57
  AdminRecalibrateBody,
58
  AdminTriggerRunBody,
59
  ApiKeyMintBody,
 
60
  CalibrateBody,
61
  ConnectorTestBody,
62
  DecisionPatchBody,
@@ -1196,6 +1197,39 @@ def create_app(db_path: Optional[str] = None,
1196
  except KeyError as e:
1197
  raise ApiError("not_found", str(e), status=404)
1198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1199
  @app.get("/tenants/{tenant_id}/invoices/{period}",
1200
  tags=["tenants"])
1201
  async def get_tenant_invoice(
 
57
  AdminRecalibrateBody,
58
  AdminTriggerRunBody,
59
  ApiKeyMintBody,
60
+ BillingCheckoutBody,
61
  CalibrateBody,
62
  ConnectorTestBody,
63
  DecisionPatchBody,
 
1197
  except KeyError as e:
1198
  raise ApiError("not_found", str(e), status=404)
1199
 
1200
+ @app.post("/tenants/{tenant_id}/billing/checkout",
1201
+ tags=["tenants"])
1202
+ async def billing_checkout(
1203
+ tenant_id: str,
1204
+ body: BillingCheckoutBody,
1205
+ key: ApiKey = Depends(auth_dep),
1206
+ ):
1207
+ """Stage 159 (β2) — kick off a Stripe Checkout session for a
1208
+ plan upgrade. admin tier — only the tenant's admin can move
1209
+ the plan. Returns {checkout_url, session_id, plan}; the
1210
+ dashboard redirects to checkout_url.
1211
+ Returns 503 when the deployment has no Stripe API key OR no
1212
+ price_id env var for the requested plan, so the UI can
1213
+ switch to a "contact sales" affordance gracefully.
1214
+ """
1215
+ require_tenant_access(key, tenant_id)
1216
+ require_role(key, ROLE_ADMIN)
1217
+ from infra.billing_stripe import StripeUnavailable, create_checkout
1218
+ tenant = svc.get_tenant(tenant_id)
1219
+ if tenant is None:
1220
+ raise ApiError("not_found",
1221
+ f"unknown tenant {tenant_id!r}", status=404)
1222
+ try:
1223
+ return create_checkout(
1224
+ tenant_id,
1225
+ body.plan,
1226
+ success_url=body.success_url,
1227
+ cancel_url=body.cancel_url,
1228
+ stripe_customer_id=tenant.get("stripe_customer_id"),
1229
+ )
1230
+ except StripeUnavailable as e:
1231
+ raise ApiError("stripe_unavailable", str(e), status=503)
1232
+
1233
  @app.get("/tenants/{tenant_id}/invoices/{period}",
1234
  tags=["tenants"])
1235
  async def get_tenant_invoice(
infra/api/schemas.py CHANGED
@@ -257,6 +257,22 @@ class ConnectorTestBody(_Body):
257
  entity_type: Optional[str] = Field(default=None)
258
 
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  # --- decision triage (Stage 156) --------------------------------------
261
 
262
  class DecisionPatchBody(_Body):
 
257
  entity_type: Optional[str] = Field(default=None)
258
 
259
 
260
+ # --- billing checkout (Stage 159 β2) ----------------------------------
261
+
262
+ class BillingCheckoutBody(_Body):
263
+ """Kick off a Stripe Checkout session for a plan upgrade. Both
264
+ URLs are absolute — the dashboard passes its own /billing pages
265
+ so success / cancel land back inside the app, not on Stripe."""
266
+ model_config = _with_example({
267
+ "plan": "pro",
268
+ "success_url": "https://orgstate.example.com/billing?upgraded=1",
269
+ "cancel_url": "https://orgstate.example.com/billing",
270
+ })
271
+ plan: str = Field(..., min_length=1)
272
+ success_url: str = Field(..., min_length=1)
273
+ cancel_url: str = Field(..., min_length=1)
274
+
275
+
276
  # --- decision triage (Stage 156) --------------------------------------
277
 
278
  class DecisionPatchBody(_Body):
infra/billing_stripe.py CHANGED
@@ -313,6 +313,12 @@ DEFAULT_SIG_TOLERANCE_SECONDS = 300
313
  HANDLED_EVENT_TYPES = frozenset([
314
  "invoice.paid",
315
  "invoice.payment_failed",
 
 
 
 
 
 
316
  ])
317
 
318
 
@@ -443,6 +449,12 @@ def handle_event(svc, event: dict) -> dict:
443
  if event_type not in HANDLED_EVENT_TYPES:
444
  return {"status": "ignored", "type": event_type,
445
  "event_id": event_id}
 
 
 
 
 
 
446
  external_invoice_id = _extract_event_invoice_id(event)
447
  if external_invoice_id is None:
448
  raise StripeWebhookError(
@@ -470,3 +482,148 @@ def handle_event(svc, event: dict) -> dict:
470
  return svc.apply_stripe_webhook_event(
471
  invoice_row=row, event=event,
472
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  HANDLED_EVENT_TYPES = frozenset([
314
  "invoice.paid",
315
  "invoice.payment_failed",
316
+ # Stage 159 (β2) — Stripe Checkout for plan upgrades. Stripe
317
+ # delivers this after the customer completes the Checkout
318
+ # flow we kick off via create_checkout(). We use the event's
319
+ # client_reference_id (= tenant_id we set at session-create
320
+ # time) and metadata.orgstate_plan to bump the tenant's plan.
321
+ "checkout.session.completed",
322
  ])
323
 
324
 
 
449
  if event_type not in HANDLED_EVENT_TYPES:
450
  return {"status": "ignored", "type": event_type,
451
  "event_id": event_id}
452
+ # Stage 159 (β2) — checkout.session.completed has a different
453
+ # data shape than invoice.* events; branch before the invoice
454
+ # lookup below so we don't try to resolve a Checkout session id
455
+ # in the invoices table.
456
+ if event_type == "checkout.session.completed":
457
+ return _handle_checkout_completed(svc, event)
458
  external_invoice_id = _extract_event_invoice_id(event)
459
  if external_invoice_id is None:
460
  raise StripeWebhookError(
 
482
  return svc.apply_stripe_webhook_event(
483
  invoice_row=row, event=event,
484
  )
485
+
486
+
487
+ # ---- Stage 159 (β2): Stripe Checkout for plan upgrades -----------------
488
+
489
+ # Plan → env var holding the Stripe price_id. The operator sets
490
+ # STRIPE_PRICE_PRO and STRIPE_PRICE_ENTERPRISE on the deployment.
491
+ # When unset, create_checkout returns a clear "plan not configured"
492
+ # error so the dashboard can fall back to "contact sales" gracefully.
493
+ _PLAN_PRICE_ENV: dict = {
494
+ "pro": "STRIPE_PRICE_PRO",
495
+ "enterprise": "STRIPE_PRICE_ENTERPRISE",
496
+ }
497
+
498
+
499
+ class _CheckoutTransport:
500
+ """Minimal Stripe Checkout transport — separated from
501
+ _RealStripeTransport so the invoice + checkout call sites can be
502
+ tested independently."""
503
+
504
+ def __init__(self, api_key: str):
505
+ import stripe # type: ignore
506
+ self._stripe = stripe
507
+ stripe.api_key = api_key
508
+
509
+ def create_checkout_session(self, *, price_id: str,
510
+ success_url: str, cancel_url: str,
511
+ customer_id: Optional[str] = None,
512
+ client_reference_id: Optional[str] = None,
513
+ metadata: Optional[dict] = None,
514
+ mode: str = "subscription") -> dict:
515
+ kwargs: dict = {
516
+ "mode": mode,
517
+ "line_items": [{"price": price_id, "quantity": 1}],
518
+ "success_url": success_url,
519
+ "cancel_url": cancel_url,
520
+ }
521
+ if customer_id:
522
+ kwargs["customer"] = customer_id
523
+ if client_reference_id:
524
+ kwargs["client_reference_id"] = client_reference_id
525
+ if metadata:
526
+ kwargs["metadata"] = metadata
527
+ session = self._stripe.checkout.Session.create(**kwargs)
528
+ return {"id": session.id, "url": session.url}
529
+
530
+
531
+ def create_checkout(tenant_id: str, plan: str, *,
532
+ success_url: str, cancel_url: str,
533
+ stripe_customer_id: Optional[str] = None,
534
+ transport: Any = None,
535
+ api_key: Optional[str] = None) -> dict:
536
+ """Create a Stripe Checkout session for ``tenant_id`` upgrading to
537
+ ``plan`` ('pro' or 'enterprise'). Returns
538
+ ``{checkout_url, session_id, plan}``.
539
+
540
+ The session carries client_reference_id=tenant_id + metadata.
541
+ orgstate_plan=plan so the eventual checkout.session.completed
542
+ webhook can route the upgrade back to the right tenant.
543
+
544
+ Raises StripeUnavailable on missing api_key or unconfigured plan.
545
+ """
546
+ import os
547
+ if plan not in _PLAN_PRICE_ENV:
548
+ raise StripeUnavailable(
549
+ f"unknown plan {plan!r}; available: "
550
+ f"{sorted(_PLAN_PRICE_ENV.keys())}"
551
+ )
552
+ price_id = os.environ.get(_PLAN_PRICE_ENV[plan], "").strip()
553
+ if not price_id:
554
+ raise StripeUnavailable(
555
+ f"plan {plan!r} is not configured for this deployment — "
556
+ f"operator must set {_PLAN_PRICE_ENV[plan]} env var to a "
557
+ f"Stripe price_id (e.g. 'price_1Ab...')"
558
+ )
559
+ if transport is None:
560
+ if not api_key:
561
+ api_key = os.environ.get("STRIPE_API_KEY", "").strip()
562
+ if not api_key:
563
+ raise StripeUnavailable(
564
+ "STRIPE_API_KEY env var is unset — cannot create "
565
+ "Checkout session without credentials"
566
+ )
567
+ if not is_stripe_available():
568
+ raise StripeUnavailable(
569
+ "Stripe Checkout needs the `stripe` package — "
570
+ "`pip install stripe`."
571
+ )
572
+ transport = _CheckoutTransport(api_key)
573
+ session = transport.create_checkout_session(
574
+ price_id=price_id,
575
+ success_url=success_url,
576
+ cancel_url=cancel_url,
577
+ customer_id=stripe_customer_id or None,
578
+ client_reference_id=tenant_id,
579
+ metadata={
580
+ "orgstate_tenant_id": tenant_id,
581
+ "orgstate_plan": plan,
582
+ },
583
+ )
584
+ return {
585
+ "checkout_url": session["url"],
586
+ "session_id": session["id"],
587
+ "plan": plan,
588
+ }
589
+
590
+
591
+ def _handle_checkout_completed(svc, event: dict) -> dict:
592
+ """Apply a verified checkout.session.completed event to the DB.
593
+
594
+ Pulls tenant_id from client_reference_id (preferred) or
595
+ metadata.orgstate_tenant_id (fallback for sessions created
596
+ outside our flow). Pulls plan from metadata.orgstate_plan. Calls
597
+ svc.set_tenant_billing_config to bump plan_name +
598
+ stripe_customer_id atomically. Returns a status dict for ops
599
+ debugging; route layer always 200s so Stripe stops retrying.
600
+ """
601
+ event_id = event.get("id", "")
602
+ data_object = (event.get("data") or {}).get("object") or {}
603
+ tenant_id = (data_object.get("client_reference_id")
604
+ or (data_object.get("metadata") or {})
605
+ .get("orgstate_tenant_id"))
606
+ plan = (data_object.get("metadata") or {}).get("orgstate_plan")
607
+ customer_id = data_object.get("customer")
608
+ if not tenant_id or not plan:
609
+ return {"status": "skipped_no_tenant_or_plan",
610
+ "event_id": event_id,
611
+ "session_id": data_object.get("id")}
612
+ if plan not in _PLAN_PRICE_ENV:
613
+ return {"status": "skipped_unknown_plan",
614
+ "plan": plan, "event_id": event_id}
615
+ if svc.get_tenant(tenant_id) is None:
616
+ return {"status": "skipped_unknown_tenant",
617
+ "tenant_id": tenant_id, "event_id": event_id}
618
+ svc.set_tenant_billing_config(
619
+ tenant_id,
620
+ plan_name=plan,
621
+ stripe_customer_id=customer_id if customer_id else None,
622
+ )
623
+ return {
624
+ "status": "plan_upgraded",
625
+ "tenant_id": tenant_id,
626
+ "plan": plan,
627
+ "stripe_customer_id": customer_id,
628
+ "event_id": event_id,
629
+ }