feat(billing): expose Stripe price IDs in plan API, add Stripe Setup Wizard and sync endpoints

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 15:14:42 +00:00
parent b6a0648366
commit 223539bebb
7 changed files with 1089 additions and 12 deletions
+193
View File
@@ -262,6 +262,199 @@ async def billing_success(request: Request) -> Any:
return _templates.TemplateResponse("billing_success.html", {"request": request}) return _templates.TemplateResponse("billing_success.html", {"request": request})
# ---------------------------------------------------------------------------
# Admin: Stripe status + sync helpers
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> None:
"""Raise 403 if the current session user is not an admin."""
user = request.session.get("user") or {}
if not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
@router.get("/stripe/status", summary="Check Stripe connection and plan sync status (admin only)")
@require_login
async def stripe_status(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Return Stripe connection health and per-plan price-ID sync status.
Returns a JSON object with:
- ``configured``: whether STRIPE_SECRET_KEY is set
- ``connection``: ``"ok"`` or an error string (live/test mode label)
- ``mode``: ``"live"`` | ``"test"`` | ``null``
- ``plans``: list of plan objects with ``plan_id``, ``name``,
``stripe_price_id_monthly``, ``stripe_price_id_yearly``, ``synced``
Raises:
403: Not admin.
503: Stripe not configured.
"""
_require_admin(request)
if not settings.stripe_secret_key:
return {
"configured": False,
"connection": "not_configured",
"mode": None,
"plans": [],
}
client = _get_stripe()
# Probe Stripe with a lightweight account fetch
mode: str | None = None
connection_status = "ok"
try:
account = client.accounts.retrieve("me") # type: ignore[arg-type]
mode = "live" if getattr(account, "livemode", True) is not False else "test"
# stripe returns livemode=False in test mode
livemode = getattr(account, "livemode", None)
if livemode is True:
mode = "live"
elif livemode is False:
mode = "test"
else:
mode = "test" if settings.stripe_secret_key.startswith("sk_test_") else "live"
except Exception as exc:
connection_status = str(exc)
mode = "test" if settings.stripe_secret_key.startswith("sk_test_") else "live"
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
plan_statuses = []
for plan in plans:
has_monthly = bool(plan.stripe_price_id_monthly)
has_yearly = bool(plan.stripe_price_id_yearly)
is_paid = plan.price_monthly > 0 or plan.price_yearly > 0
synced = (not is_paid) or (has_monthly and (not plan.price_yearly or has_yearly))
plan_statuses.append(
{
"plan_id": plan.plan_id,
"name": plan.name,
"price_monthly": plan.price_monthly,
"price_yearly": plan.price_yearly,
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
"synced": synced,
}
)
return {
"configured": True,
"connection": connection_status,
"mode": mode,
"webhook_secret_configured": bool(settings.stripe_webhook_secret),
"plans": plan_statuses,
"webhook_endpoint": str(request.base_url).rstrip("/") + "/api/billing/webhook",
}
@router.post("/stripe/sync-plans", summary="Auto-create Stripe products and prices for all plans (admin only)")
@require_login
async def stripe_sync_plans(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Create Stripe Product + Price objects for every paid plan that is missing them.
For each paid plan (``price_monthly > 0``) that lacks a ``stripe_price_id_monthly``,
this endpoint:
1. Creates a Stripe *Product* named after the plan.
2. Creates a Stripe *Price* for the monthly amount.
3. Optionally creates a yearly Price if ``price_yearly > 0``.
4. Persists the resulting ``price_id`` values back into ``SubscriptionPlan``.
Already-synced plans (those that already have ``stripe_price_id_monthly``) are
skipped — existing prices in Stripe are never modified.
Raises:
403: Not admin.
503: Stripe not configured.
"""
_require_admin(request)
client = _get_stripe()
if not client:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing is not configured.")
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
results: list[dict[str, Any]] = []
for plan in plans:
is_paid = plan.price_monthly > 0 or plan.price_yearly > 0
if not is_paid:
results.append({"plan_id": plan.plan_id, "name": plan.name, "status": "skipped_free"})
continue
already_has_monthly = bool(plan.stripe_price_id_monthly)
already_has_yearly = bool(plan.stripe_price_id_yearly)
if already_has_monthly and (not plan.price_yearly or already_has_yearly):
results.append({"plan_id": plan.plan_id, "name": plan.name, "status": "already_synced"})
continue
try:
# Create (or look up) the Stripe Product for this plan
product = client.products.create(
params={
"name": plan.name,
"metadata": {"docuelevate_plan_id": plan.plan_id},
}
)
changed = False
# Monthly price
if not already_has_monthly and plan.price_monthly > 0:
monthly_price = client.prices.create(
params={
"product": product.id,
"unit_amount": int(round(plan.price_monthly * 100)),
"currency": "usd",
"recurring": {"interval": "month"},
"metadata": {"docuelevate_plan_id": plan.plan_id, "billing_cycle": "monthly"},
}
)
plan.stripe_price_id_monthly = monthly_price.id
changed = True
# Yearly price
if not already_has_yearly and plan.price_yearly > 0:
yearly_price = client.prices.create(
params={
"product": product.id,
"unit_amount": int(round(plan.price_yearly * 100)),
"currency": "usd",
"recurring": {"interval": "year"},
"metadata": {"docuelevate_plan_id": plan.plan_id, "billing_cycle": "yearly"},
}
)
plan.stripe_price_id_yearly = yearly_price.id
changed = True
if changed:
db.commit()
logger.info(
"Stripe sync: created product/prices for plan %s (product %s)",
plan.plan_id,
product.id,
)
results.append(
{
"plan_id": plan.plan_id,
"name": plan.name,
"status": "created",
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
}
)
except Exception as exc:
db.rollback()
logger.error("Stripe sync failed for plan %s: %s", plan.plan_id, exc)
results.append({"plan_id": plan.plan_id, "name": plan.name, "status": "error", "detail": str(exc)})
return {"results": results}
def _handle_stripe_event(db: Session, event: Any) -> None: def _handle_stripe_event(db: Session, event: Any) -> None:
"""Dispatch Stripe event to the appropriate handler. """Dispatch Stripe event to the appropriate handler.
+6
View File
@@ -75,6 +75,8 @@ class PlanUpsert(BaseModel):
sort_order: int = 0 sort_order: int = 0
features: list[str] = [] features: list[str] = []
api_access: bool = False api_access: bool = False
stripe_price_id_monthly: str | None = None
stripe_price_id_yearly: str | None = None
class ReorderBody(BaseModel): class ReorderBody(BaseModel):
@@ -121,6 +123,8 @@ def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]:
"sort_order": plan.sort_order, "sort_order": plan.sort_order,
"features": features, "features": features,
"api_access": plan.api_access, "api_access": plan.api_access,
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
"created_at": plan.created_at.isoformat() if plan.created_at else None, "created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None, "updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
} }
@@ -151,6 +155,8 @@ def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None:
plan.sort_order = body.sort_order plan.sort_order = body.sort_order
plan.features = json.dumps(body.features) plan.features = json.dumps(body.features)
plan.api_access = body.api_access plan.api_access = body.api_access
plan.stripe_price_id_monthly = body.stripe_price_id_monthly or None
plan.stripe_price_id_yearly = body.stripe_price_id_yearly or None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8 -1
View File
@@ -1,4 +1,4 @@
"""View route for the admin Plan Designer page.""" """View routes for admin plan management pages."""
from fastapi import Request from fastapi import Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -16,3 +16,10 @@ templates = Jinja2Templates(directory="frontend/templates")
async def plan_designer(request: Request) -> HTMLResponse: async def plan_designer(request: Request) -> HTMLResponse:
"""Admin Plan Designer page.""" """Admin Plan Designer page."""
return templates.TemplateResponse("admin_plans.html", {"request": request}) return templates.TemplateResponse("admin_plans.html", {"request": request})
@router.get("/admin/stripe-wizard", response_class=HTMLResponse)
@require_login
async def stripe_wizard(request: Request) -> HTMLResponse:
"""Admin Stripe Setup Wizard page."""
return templates.TemplateResponse("admin_stripe_wizard.html", {"request": request})
+73 -11
View File
@@ -8,7 +8,8 @@ This guide covers how to configure Stripe billing and local user sign-up in Docu
- [Stripe Billing Integration](#stripe-billing-integration) - [Stripe Billing Integration](#stripe-billing-integration)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Setting Up Plans](#setting-up-plans) - [Stripe Setup Wizard (recommended)](#stripe-setup-wizard-recommended)
- [Setting Up Plans Manually](#setting-up-plans-manually)
- [Webhook Configuration](#webhook-configuration) - [Webhook Configuration](#webhook-configuration)
- [Billing Flows](#billing-flows) - [Billing Flows](#billing-flows)
- [Compliance Notes](#compliance-notes) - [Compliance Notes](#compliance-notes)
@@ -75,7 +76,6 @@ DocuElevate integrates with [Stripe](https://stripe.com) to handle subscription
### Prerequisites ### Prerequisites
- A Stripe account (sign up at [stripe.com](https://stripe.com)) - A Stripe account (sign up at [stripe.com](https://stripe.com))
- Products and prices created in the Stripe Dashboard for each paid plan
- A publicly reachable webhook endpoint (or use [Stripe CLI](https://stripe.com/docs/stripe-cli) for local testing) - A publicly reachable webhook endpoint (or use [Stripe CLI](https://stripe.com/docs/stripe-cli) for local testing)
### Configuration ### Configuration
@@ -90,16 +90,75 @@ STRIPE_CANCEL_URL=https://app.example.com/pricing # Optional overr
> **Security:** Never commit your Stripe secret key. Store it in your environment or secrets manager. > **Security:** Never commit your Stripe secret key. Store it in your environment or secrets manager.
### Setting Up Plans ### Stripe Setup Wizard (recommended)
After starting DocuElevate, go to **Admin → Plans** to configure each plan: DocuElevate includes a built-in **Stripe Setup Wizard** at `/admin/stripe-wizard` that guides you through the complete setup in three steps:
1. Open the **Plan Designer** for a paid tier (e.g. Starter, Professional). 1. **Verify API Keys** — checks that your Stripe secret key is configured and the connection to Stripe is working.
2. Enter the **Stripe Price ID (monthly)** from your Stripe Dashboard (e.g. `price_1OtAbc...`). 2. **Sync Plans to Stripe** — automatically creates Stripe **Products** and **Prices** for every paid plan defined in DocuElevate, then stores the resulting `price_id` values back in the database. Free plans are skipped; plans that already have a price ID are left unchanged.
3. Optionally enter the **Stripe Price ID (yearly)** for annual billing. 3. **Configure Webhook** — shows the exact webhook endpoint URL to register in the Stripe Dashboard and which events to subscribe to.
4. Save the plan.
Stripe Price IDs look like `price_1OtAbcDefGhIjKlMnOpQrSt`. Find them in **Products** in your Stripe Dashboard. You can also reach the wizard from the **Admin → Plans** page via the **Stripe Setup** button.
#### Auto-sync API
The sync step is also available as an API endpoint for automation:
```bash
curl -X POST https://your-app.example.com/api/billing/stripe/sync-plans \
-H "Cookie: <admin-session-cookie>"
```
Response:
```json
{
"results": [
{
"plan_id": "starter",
"name": "Starter",
"status": "created",
"stripe_price_id_monthly": "price_1OtAbc...",
"stripe_price_id_yearly": "price_1OtDef..."
},
{
"plan_id": "free",
"name": "Free",
"status": "skipped_free"
}
]
}
```
Possible `status` values:
| Status | Meaning |
|--------|---------|
| `created` | Stripe product and price(s) were created and saved |
| `already_synced` | Plan already had a price ID — no changes made |
| `skipped_free` | Free plan (price is $0) — no Stripe price needed |
| `error` | Stripe API call failed — `detail` contains the error message |
#### Stripe connection status API
```bash
curl https://your-app.example.com/api/billing/stripe/status
```
Returns connection health, API mode (test/live), webhook secret status, and per-plan sync status.
### Setting Up Plans Manually
If you prefer to enter price IDs yourself rather than using the wizard:
1. Create **Products** and **Prices** in the [Stripe Dashboard](https://dashboard.stripe.com/products).
2. Go to **Admin → Plans** in DocuElevate and click the **Edit** (pencil) button for a paid plan.
3. Scroll to the **Stripe Integration** section in the plan editor.
4. Enter the **Stripe Price ID (monthly)** (e.g. `price_1OtAbc...`).
5. Optionally enter the **Stripe Price ID (yearly)** for annual billing.
6. Save the plan.
> **Stable link:** DocuElevate stores the Stripe `customer_id` in the `UserProfile.stripe_customer_id` column and matches it on every webhook event. This is the stable link between Stripe billing profiles and DocuElevate accounts. It is set automatically when a user completes their first checkout.
### Webhook Configuration ### Webhook Configuration
@@ -110,6 +169,7 @@ Stripe webhooks allow DocuElevate to sync subscription status in real time.
1. Go to **Developers → Webhooks** in the Stripe Dashboard. 1. Go to **Developers → Webhooks** in the Stripe Dashboard.
2. Click **Add endpoint**. 2. Click **Add endpoint**.
3. Set the endpoint URL to: `https://your-app-domain.com/api/billing/webhook` 3. Set the endpoint URL to: `https://your-app-domain.com/api/billing/webhook`
(The Stripe Setup Wizard shows the exact URL for your deployment.)
4. Select the following events: 4. Select the following events:
- `checkout.session.completed` - `checkout.session.completed`
- `customer.subscription.updated` - `customer.subscription.updated`
@@ -126,8 +186,10 @@ stripe login
# Forward webhooks to your local server # Forward webhooks to your local server
stripe listen --forward-to http://localhost:8000/api/billing/webhook stripe listen --forward-to http://localhost:8000/api/billing/webhook
# Trigger a test event # Trigger test events
stripe trigger checkout.session.completed stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
``` ```
### Billing Flows ### Billing Flows
@@ -139,7 +201,7 @@ stripe trigger checkout.session.completed
3. DocuElevate calls `POST /api/billing/create-checkout-session`. 3. DocuElevate calls `POST /api/billing/create-checkout-session`.
4. User is redirected to Stripe Checkout. 4. User is redirected to Stripe Checkout.
5. After payment, Stripe fires `checkout.session.completed`. 5. After payment, Stripe fires `checkout.session.completed`.
6. DocuElevate webhook handler activates the subscription tier. 6. DocuElevate webhook handler activates the subscription tier and stores the Stripe `customer_id`.
7. User is redirected to `/api/billing/success`. 7. User is redirected to `/api/billing/success`.
#### Manage or cancel subscription #### Manage or cancel subscription
+43
View File
@@ -14,6 +14,12 @@
<p class="text-sm text-gray-500 mt-1">Manage subscription plans shown on the public pricing page.</p> <p class="text-sm text-gray-500 mt-1">Manage subscription plans shown on the public pricing page.</p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/admin/stripe-wizard"
class="inline-flex items-center px-4 py-2 border border-indigo-300 rounded-md text-sm font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
title="Open the Stripe Setup Wizard to configure API keys and sync plans"
>
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> Stripe Setup
</a>
<button <button
@click="seedDefaults()" @click="seedDefaults()"
:disabled="seeding" :disabled="seeding"
@@ -265,6 +271,40 @@
</div> </div>
</div> </div>
<!-- Stripe Integration -->
<div class="px-6 py-5 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Stripe Integration</h3>
<a href="/admin/stripe-wizard" target="_blank"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded"
aria-label="Open Stripe Setup Wizard in a new tab"
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>Stripe Wizard</a>
</div>
<p class="text-xs text-gray-500">
Enter the Stripe Price IDs for this plan, or use the
<a href="/admin/stripe-wizard" target="_blank" class="text-indigo-600 hover:text-indigo-800 underline">Stripe Setup Wizard</a>
to auto-create them. Free plans do not need Stripe Price IDs.
</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="f-stripe-monthly" class="block text-sm font-medium text-gray-700 mb-1">
Stripe Price ID (monthly)
</label>
<input id="f-stripe-monthly" type="text" x-model="form.stripe_price_id_monthly"
placeholder="price_1OtAbc…"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-stripe-yearly" class="block text-sm font-medium text-gray-700 mb-1">
Stripe Price ID (yearly)
</label>
<input id="f-stripe-yearly" type="text" x-model="form.stripe_price_id_yearly"
placeholder="price_1OtAbc… (optional)"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
</div>
</div>
<!-- Volume Limits --> <!-- Volume Limits -->
<div class="px-6 py-5 space-y-4"> <div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Volume Limits</h3> <h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Volume Limits</h3>
@@ -465,6 +505,8 @@ function planDesigner() {
sort_order: 0, sort_order: 0,
features: [], features: [],
api_access: false, api_access: false,
stripe_price_id_monthly: null,
stripe_price_id_yearly: null,
}, },
get yearlySavingPct() { get yearlySavingPct() {
@@ -501,6 +543,7 @@ function planDesigner() {
overage_percent: 20, allow_overage_billing: false, overage_price_per_doc: null, overage_percent: 20, allow_overage_billing: false, overage_price_per_doc: null,
overage_price_per_ocr_page: null, is_active: true, is_highlighted: false, overage_price_per_ocr_page: null, is_active: true, is_highlighted: false,
badge_text: null, cta_text: 'Get started', sort_order: this.plans.length, features: [], api_access: false, badge_text: null, cta_text: 'Get started', sort_order: this.plans.length, features: [], api_access: false,
stripe_price_id_monthly: null, stripe_price_id_yearly: null,
}; };
this.modalOpen = true; this.modalOpen = true;
}, },
+414
View File
@@ -0,0 +1,414 @@
{% extends "base.html" %}
{% block title %}Stripe Setup Wizard — DocuElevate Admin{% endblock %}
{% block content %}
<main id="main-content" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
x-data="stripeWizard()"
x-init="init()">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">
<i class="fab fa-stripe text-indigo-600 mr-2" aria-hidden="true"></i>Stripe Setup Wizard
</h1>
<p class="text-sm text-gray-500 mt-1">Configure Stripe billing for DocuElevate in three steps.</p>
</div>
<a href="/admin/plans"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
>
<i class="fas fa-arrow-left mr-2 text-gray-400" aria-hidden="true"></i>Back to Plans
</a>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-400">
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
<p class="mt-2 text-sm">Checking Stripe status…</p>
</div>
<!-- Main wizard content -->
<div x-show="!loading" x-cloak>
<!-- Step progress bar -->
<nav aria-label="Wizard steps" class="mb-8">
<ol class="flex items-center space-x-2">
<template x-for="(step, i) in steps" :key="i">
<li class="flex items-center">
<button
@click="currentStep = i"
class="flex items-center focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 rounded"
:aria-current="currentStep === i ? 'step' : null"
>
<span
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold border-2 transition"
:class="currentStep === i
? 'bg-indigo-600 text-white border-indigo-600'
: stepDone(i)
? 'bg-green-100 text-green-700 border-green-400'
: 'bg-white text-gray-400 border-gray-300'"
x-text="stepDone(i) ? '✓' : (i + 1)"
></span>
<span class="ml-2 text-sm font-medium hidden sm:block"
:class="currentStep === i ? 'text-indigo-700' : 'text-gray-500'"
x-text="step.title"></span>
</button>
<template x-if="i < steps.length - 1">
<div class="flex-1 h-px bg-gray-200 mx-3 hidden sm:block" style="min-width:2rem;"></div>
</template>
</li>
</template>
</ol>
</nav>
<!-- ── Step 0: Verify API Keys ──────────────────────────────────────── -->
<section x-show="currentStep === 0" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 1 — Verify Stripe API Keys</h2>
<p class="text-sm text-gray-600">
Set your Stripe keys in your environment (or <code>.env</code> file) then click
<strong>Check Connection</strong> to verify.
</p>
</div>
<!-- Config snippet -->
<div class="bg-gray-50 rounded-md p-4 text-sm font-mono text-gray-700 border border-gray-200 space-y-1" role="note">
<p>STRIPE_SECRET_KEY=<span class="text-indigo-600">sk_test_…</span></p>
<p>STRIPE_PUBLISHABLE_KEY=<span class="text-indigo-600">pk_test_…</span></p>
<p>STRIPE_WEBHOOK_SECRET=<span class="text-indigo-600">whsec_…</span></p>
</div>
<!-- Status display -->
<div x-show="status !== null">
<!-- Not configured -->
<div x-show="status && !status.configured"
class="rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700" role="alert">
<i class="fas fa-times-circle mr-1" aria-hidden="true"></i>
<strong>STRIPE_SECRET_KEY</strong> is not set.
Add it to your environment and restart the server.
</div>
<!-- Configured — connection result -->
<div x-show="status && status.configured">
<div x-show="status && status.connection === 'ok'"
class="rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700" role="status">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
Connected to Stripe
<span class="font-semibold" x-text="status && status.mode === 'test' ? '(Test mode)' : '(Live mode)'"></span>.
</div>
<div x-show="status && status.connection !== 'ok'"
class="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<span x-text="'Connection error: ' + (status && status.connection)"></span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
@click="checkStatus()"
:disabled="checking"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px"
>
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
<span x-show="!checking">Check Connection</span>
<span x-show="checking"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Checking…</span>
</button>
<button
x-show="status && status.configured && status.connection === 'ok'"
@click="currentStep = 1"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
style="min-height:44px"
>
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
</button>
</div>
</section>
<!-- ── Step 1: Sync Plans ───────────────────────────────────────────── -->
<section x-show="currentStep === 1" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 2 — Sync Plans to Stripe</h2>
<p class="text-sm text-gray-600">
DocuElevate can auto-create Stripe <strong>Products</strong> and <strong>Prices</strong> for each
paid plan. Free plans are skipped. Plans that already have a Stripe price ID are left unchanged.
</p>
</div>
<!-- Plan grid -->
<div x-show="status && status.plans && status.plans.length > 0">
<table class="min-w-full text-sm divide-y divide-gray-200" aria-label="Plan sync status">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Price ID</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Yearly Price ID</th>
<th scope="col" class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<template x-for="plan in (status && status.plans || [])" :key="plan.plan_id">
<tr>
<td class="px-3 py-2">
<span class="font-medium text-gray-900" x-text="plan.name"></span>
<span class="block text-xs text-gray-400" x-text="plan.plan_id"></span>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600">
<span x-show="plan.stripe_price_id_monthly" x-text="plan.stripe_price_id_monthly" class="text-green-700"></span>
<span x-show="!plan.stripe_price_id_monthly && plan.price_monthly > 0" class="text-red-500">not set</span>
<span x-show="!plan.stripe_price_id_monthly && plan.price_monthly === 0" class="text-gray-400"></span>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600">
<span x-show="plan.stripe_price_id_yearly" x-text="plan.stripe_price_id_yearly" class="text-green-700"></span>
<span x-show="!plan.stripe_price_id_yearly && plan.price_yearly > 0" class="text-red-500">not set</span>
<span x-show="!plan.stripe_price_id_yearly && plan.price_yearly === 0" class="text-gray-400"></span>
</td>
<td class="px-3 py-2 text-center">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="plan.synced ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'"
x-text="plan.synced ? 'Synced' : 'Needs sync'"
></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Sync result -->
<div x-show="syncResults !== null" class="space-y-2" aria-live="polite">
<template x-for="r in (syncResults || [])" :key="r.plan_id">
<div class="flex items-center gap-2 text-sm">
<i class="fas"
:class="r.status === 'created' ? 'fa-check-circle text-green-500'
: r.status === 'already_synced' ? 'fa-check text-gray-400'
: r.status === 'skipped_free' ? 'fa-minus text-gray-300'
: 'fa-times-circle text-red-500'"
aria-hidden="true"></i>
<span class="font-medium" x-text="r.name"></span>
<span class="text-gray-500"
x-text="r.status === 'created' ? '— created in Stripe'
: r.status === 'already_synced' ? '— already synced'
: r.status === 'skipped_free' ? '— free plan, skipped'
: ('— error: ' + r.detail)"></span>
</div>
</template>
</div>
<div class="flex items-center gap-3">
<button
@click="currentStep = 0"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
style="min-height:44px"
>
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button
@click="syncPlans()"
:disabled="syncing || !(status && status.configured)"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px"
>
<i class="fas fa-sync-alt mr-2" aria-hidden="true"></i>
<span x-show="!syncing">Sync Plans to Stripe</span>
<span x-show="syncing"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Syncing…</span>
</button>
<button
x-show="syncResults !== null"
@click="currentStep = 2; checkStatus()"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
style="min-height:44px"
>
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
</button>
</div>
</section>
<!-- ── Step 2: Webhook ──────────────────────────────────────────────── -->
<section x-show="currentStep === 2" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 3 — Configure Stripe Webhook</h2>
<p class="text-sm text-gray-600">
Webhooks let Stripe notify DocuElevate of subscription changes in real time.
Add the endpoint below in your
<a href="https://dashboard.stripe.com/webhooks" target="_blank" rel="noopener noreferrer"
class="text-indigo-600 hover:text-indigo-800 underline focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded">
Stripe Dashboard → Developers → Webhooks
</a>.
</p>
</div>
<!-- Webhook URL -->
<div>
<label for="webhook-url" class="block text-sm font-medium text-gray-700 mb-1">Webhook Endpoint URL</label>
<div class="flex items-center gap-2">
<input
id="webhook-url"
type="text"
:value="status && status.webhook_endpoint"
readonly
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm bg-gray-50 font-mono focus:outline-none"
aria-label="Webhook endpoint URL to register in Stripe"
/>
<button
@click="copyWebhook()"
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
style="min-height:44px"
aria-label="Copy webhook URL to clipboard"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- Events list -->
<div>
<p class="text-sm font-medium text-gray-700 mb-2">Subscribe to these events:</p>
<ul class="list-none space-y-1 text-sm font-mono text-gray-600">
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>checkout.session.completed</li>
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>customer.subscription.updated</li>
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>customer.subscription.deleted</li>
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>invoice.payment_failed</li>
</ul>
</div>
<!-- Webhook secret instructions -->
<div class="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800" role="note">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
After adding the endpoint, copy the <strong>Signing secret</strong> from Stripe and set
<code class="bg-blue-100 px-1 rounded">STRIPE_WEBHOOK_SECRET=whsec_…</code> in your environment.
This protects against spoofed webhook events.
</div>
<!-- Webhook secret status -->
<div x-show="status">
<div x-show="status && status.webhook_secret_set"
class="rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700" role="status">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
<strong>STRIPE_WEBHOOK_SECRET</strong> is configured. Webhook signatures are verified.
</div>
<div x-show="status && !status.webhook_secret_set"
class="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<strong>STRIPE_WEBHOOK_SECRET</strong> is not set. Webhook events are accepted without
signature verification. Set it in your environment for production security.
</div>
</div>
<!-- Local testing -->
<details class="border border-gray-200 rounded-md">
<summary class="px-4 py-3 text-sm font-medium text-gray-700 cursor-pointer hover:bg-gray-50">
<i class="fas fa-terminal mr-1" aria-hidden="true"></i> Local testing with Stripe CLI
</summary>
<div class="px-4 pb-4 pt-2 bg-gray-50 rounded-b-md">
<pre class="text-xs text-gray-700 overflow-x-auto space-y-1"><code># Install Stripe CLI and log in
stripe login
# Forward webhooks to your local server
stripe listen --forward-to http://localhost:8000/api/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted</code></pre>
</div>
</details>
<div class="flex items-center gap-3">
<button
@click="currentStep = 1"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
style="min-height:44px"
>
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<a href="/admin/plans"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
style="min-height:44px"
>
<i class="fas fa-check mr-2" aria-hidden="true"></i> Done — Go to Plan Designer
</a>
</div>
</section>
</div><!-- /!loading -->
</main>
<script>
function stripeWizard() {
return {
loading: true,
checking: false,
syncing: false,
currentStep: 0,
status: null,
syncResults: null,
copied: false,
steps: [
{ title: 'Verify API Keys' },
{ title: 'Sync Plans' },
{ title: 'Configure Webhook' },
],
stepDone(i) {
if (i === 0) return this.status && this.status.configured && this.status.connection === 'ok';
if (i === 1) return this.syncResults !== null;
return false;
},
async init() {
await this.checkStatus();
this.loading = false;
},
async checkStatus() {
this.checking = true;
try {
const resp = await fetch('/api/billing/stripe/status');
if (!resp.ok) throw new Error('Failed to fetch Stripe status');
this.status = await resp.json();
// Determine if webhook secret is set by checking the status returned from the API
this.status.webhook_secret_set = this.status.webhook_secret_configured ?? false;
} catch (e) {
this.status = { configured: false, connection: e.message, mode: null, plans: [] };
} finally {
this.checking = false;
}
},
async syncPlans() {
this.syncing = true;
this.syncResults = null;
try {
const resp = await fetch('/api/billing/stripe/sync-plans', { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Sync failed');
}
const data = await resp.json();
this.syncResults = data.results || [];
// Refresh status to update price IDs shown in the table
await this.checkStatus();
} catch (e) {
this.syncResults = [{ plan_id: '_error', name: 'Error', status: 'error', detail: e.message }];
} finally {
this.syncing = false;
}
},
copyWebhook() {
const url = this.status && this.status.webhook_endpoint;
if (!url) return;
navigator.clipboard.writeText(url).then(() => {
this.copied = true;
setTimeout(() => { this.copied = false; }, 2000);
}).catch(() => {});
},
};
}
</script>
{% endblock %}
+352
View File
@@ -484,3 +484,355 @@ def test_billing_success_page(bill_client):
resp = bill_client.get("/api/billing/success") resp = bill_client.get("/api/billing/success")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"subscription" in resp.content.lower() or b"success" in resp.content.lower() assert b"subscription" in resp.content.lower() or b"success" in resp.content.lower()
# ---------------------------------------------------------------------------
# Tests: GET /api/billing/stripe/status (admin only)
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_stripe_status_not_admin(bill_client):
"""GET /api/billing/stripe/status returns 403 for non-admin."""
with patch("app.api.billing.settings") as mock_settings:
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = "whsec_test"
# bill_client session has no is_admin flag
resp = bill_client.get("/api/billing/stripe/status")
assert resp.status_code == 403
@pytest.mark.integration
def test_stripe_status_not_configured(bill_client):
"""GET /api/billing/stripe/status returns configured=False when key is missing."""
with (
patch("app.api.billing.settings") as mock_settings,
patch(
"app.api.billing._require_admin",
return_value=None,
),
):
mock_settings.stripe_secret_key = None
mock_settings.stripe_webhook_secret = None
resp = bill_client.get("/api/billing/stripe/status")
assert resp.status_code == 200
data = resp.json()
assert data["configured"] is False
assert data["connection"] == "not_configured"
@pytest.mark.integration
def test_stripe_status_configured_ok(bill_client, starter_plan):
"""GET /api/billing/stripe/status returns plan list with sync status when configured."""
mock_account = MagicMock()
mock_account.livemode = False # test mode
mock_client = MagicMock()
mock_client.accounts.retrieve.return_value = mock_account
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.settings") as mock_settings,
patch("app.api.billing._require_admin", return_value=None),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = "whsec_test"
resp = bill_client.get("/api/billing/stripe/status")
assert resp.status_code == 200
data = resp.json()
assert data["configured"] is True
assert data["connection"] == "ok"
assert data["mode"] == "test"
assert data["webhook_secret_configured"] is True
assert "plans" in data
assert "webhook_endpoint" in data
# starter_plan has price IDs set → synced
plan_entry = next((p for p in data["plans"] if p["plan_id"] == "starter"), None)
assert plan_entry is not None
assert plan_entry["stripe_price_id_monthly"] == "price_monthly_starter"
assert plan_entry["synced"] is True
@pytest.mark.integration
def test_stripe_status_plan_not_synced(bill_client, bill_session):
"""GET /api/billing/stripe/status shows synced=False for paid plan without price IDs."""
plan = SubscriptionPlan(
plan_id="unsynced",
name="Unsynced",
price_monthly=5.0,
price_yearly=50.0,
trial_days=0,
stripe_price_id_monthly=None,
stripe_price_id_yearly=None,
)
bill_session.add(plan)
bill_session.commit()
mock_account = MagicMock()
mock_account.livemode = False
mock_client = MagicMock()
mock_client.accounts.retrieve.return_value = mock_account
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing.settings") as mock_settings,
patch("app.api.billing._require_admin", return_value=None),
):
mock_settings.stripe_secret_key = "sk_test_fake"
mock_settings.stripe_webhook_secret = None
resp = bill_client.get("/api/billing/stripe/status")
assert resp.status_code == 200
data = resp.json()
assert data["webhook_secret_configured"] is False
entry = next((p for p in data["plans"] if p["plan_id"] == "unsynced"), None)
assert entry is not None
assert entry["synced"] is False
# ---------------------------------------------------------------------------
# Tests: POST /api/billing/stripe/sync-plans (admin only)
# ---------------------------------------------------------------------------
@pytest.mark.integration
def test_stripe_sync_plans_not_configured(bill_client):
"""POST /api/billing/stripe/sync-plans returns 503 when billing not configured."""
with (
patch("app.api.billing._get_stripe", return_value=None),
patch("app.api.billing._require_admin", return_value=None),
):
resp = bill_client.post("/api/billing/stripe/sync-plans")
assert resp.status_code == 503
@pytest.mark.integration
def test_stripe_sync_plans_not_admin(bill_client):
"""POST /api/billing/stripe/sync-plans returns 403 for non-admin."""
mock_client = MagicMock()
with patch("app.api.billing._get_stripe", return_value=mock_client):
resp = bill_client.post("/api/billing/stripe/sync-plans")
assert resp.status_code == 403
@pytest.mark.integration
def test_stripe_sync_plans_creates_prices(bill_client, bill_session):
"""POST /api/billing/stripe/sync-plans creates Stripe products and prices for paid plans."""
plan = SubscriptionPlan(
plan_id="pro",
name="Professional",
price_monthly=19.0,
price_yearly=190.0,
trial_days=0,
stripe_price_id_monthly=None,
stripe_price_id_yearly=None,
)
bill_session.add(plan)
bill_session.commit()
mock_client = MagicMock()
mock_product = MagicMock()
mock_product.id = "prod_test123"
mock_monthly_price = MagicMock()
mock_monthly_price.id = "price_monthly_pro_new"
mock_yearly_price = MagicMock()
mock_yearly_price.id = "price_yearly_pro_new"
mock_client.products.create.return_value = mock_product
mock_client.prices.create.side_effect = [mock_monthly_price, mock_yearly_price]
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing._require_admin", return_value=None),
):
resp = bill_client.post("/api/billing/stripe/sync-plans")
assert resp.status_code == 200
data = resp.json()
assert "results" in data
result = next((r for r in data["results"] if r["plan_id"] == "pro"), None)
assert result is not None
assert result["status"] == "created"
assert result["stripe_price_id_monthly"] == "price_monthly_pro_new"
assert result["stripe_price_id_yearly"] == "price_yearly_pro_new"
# Verify DB was updated
bill_session.expire_all()
db_plan = bill_session.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == "pro").first()
assert db_plan.stripe_price_id_monthly == "price_monthly_pro_new"
assert db_plan.stripe_price_id_yearly == "price_yearly_pro_new"
# Verify correct amounts were passed to Stripe
price_calls = mock_client.prices.create.call_args_list
assert price_calls[0][1]["params"]["unit_amount"] == 1900 # $19.00 → 1900 cents
assert price_calls[1][1]["params"]["unit_amount"] == 19000 # $190.00 → 19000 cents
assert price_calls[0][1]["params"]["recurring"]["interval"] == "month"
assert price_calls[1][1]["params"]["recurring"]["interval"] == "year"
@pytest.mark.integration
def test_stripe_sync_plans_skips_free(bill_client, bill_session):
"""POST /api/billing/stripe/sync-plans skips free plans."""
free_plan = SubscriptionPlan(
plan_id="free_test",
name="Free",
price_monthly=0.0,
price_yearly=0.0,
trial_days=0,
stripe_price_id_monthly=None,
stripe_price_id_yearly=None,
)
bill_session.add(free_plan)
bill_session.commit()
mock_client = MagicMock()
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing._require_admin", return_value=None),
):
resp = bill_client.post("/api/billing/stripe/sync-plans")
assert resp.status_code == 200
data = resp.json()
result = next((r for r in data["results"] if r["plan_id"] == "free_test"), None)
assert result is not None
assert result["status"] == "skipped_free"
# Products.create should NOT have been called for a free plan
mock_client.products.create.assert_not_called()
@pytest.mark.integration
def test_stripe_sync_plans_skips_already_synced(bill_client, starter_plan):
"""POST /api/billing/stripe/sync-plans skips plans that already have price IDs."""
mock_client = MagicMock()
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing._require_admin", return_value=None),
):
resp = bill_client.post("/api/billing/stripe/sync-plans")
assert resp.status_code == 200
data = resp.json()
result = next((r for r in data["results"] if r["plan_id"] == "starter"), None)
assert result is not None
assert result["status"] == "already_synced"
mock_client.products.create.assert_not_called()
@pytest.mark.integration
def test_stripe_sync_plans_handles_stripe_error(bill_client, bill_session):
"""POST /api/billing/stripe/sync-plans returns error status when Stripe API fails."""
plan = SubscriptionPlan(
plan_id="errplan",
name="Error Plan",
price_monthly=9.99,
price_yearly=0.0,
trial_days=0,
stripe_price_id_monthly=None,
stripe_price_id_yearly=None,
)
bill_session.add(plan)
bill_session.commit()
mock_client = MagicMock()
mock_client.products.create.side_effect = Exception("Stripe connection error")
with (
patch("app.api.billing._get_stripe", return_value=mock_client),
patch("app.api.billing._require_admin", return_value=None),
):
resp = bill_client.post("/api/billing/stripe/sync-plans")
assert resp.status_code == 200
data = resp.json()
result = next((r for r in data["results"] if r["plan_id"] == "errplan"), None)
assert result is not None
assert result["status"] == "error"
assert "Stripe connection error" in result["detail"]
# ---------------------------------------------------------------------------
# Tests: plans API now exposes stripe_price_id fields
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_plan_to_response_includes_stripe_ids(bill_session, starter_plan):
"""_plan_to_response includes stripe_price_id_monthly and stripe_price_id_yearly."""
from app.api.plans import _plan_to_response
result = _plan_to_response(starter_plan)
assert result["stripe_price_id_monthly"] == "price_monthly_starter"
assert result["stripe_price_id_yearly"] == "price_yearly_starter"
@pytest.mark.integration
def test_plan_api_returns_stripe_ids(bill_client, starter_plan):
"""GET /api/plans/{plan_id} returns Stripe price IDs in the response."""
resp = bill_client.get("/api/plans/starter")
assert resp.status_code == 200
data = resp.json()
assert data["stripe_price_id_monthly"] == "price_monthly_starter"
assert data["stripe_price_id_yearly"] == "price_yearly_starter"
@pytest.mark.integration
def test_plan_api_update_sets_stripe_ids(bill_client, bill_engine, starter_plan):
"""PUT /api/plans/{plan_id} can update Stripe price IDs."""
from app.api.plans import _require_admin
from app.main import app
def override_admin():
return {"email": "admin@test.com", "is_admin": True}
app.dependency_overrides[_require_admin] = override_admin
try:
resp = bill_client.put(
"/api/plans/starter",
json={
"name": "Starter",
"price_monthly": 9.0,
"price_yearly": 90.0,
"stripe_price_id_monthly": "price_new_monthly",
"stripe_price_id_yearly": "price_new_yearly",
"features": [],
},
)
finally:
app.dependency_overrides.pop(_require_admin, None)
assert resp.status_code == 200
data = resp.json()
assert data["stripe_price_id_monthly"] == "price_new_monthly"
assert data["stripe_price_id_yearly"] == "price_new_yearly"
@pytest.mark.integration
def test_plan_api_update_clears_stripe_ids(bill_client, bill_engine, starter_plan):
"""PUT /api/plans/{plan_id} clears Stripe price IDs when empty string is passed."""
from app.api.plans import _require_admin
from app.main import app
def override_admin():
return {"email": "admin@test.com", "is_admin": True}
app.dependency_overrides[_require_admin] = override_admin
try:
resp = bill_client.put(
"/api/plans/starter",
json={
"name": "Starter",
"price_monthly": 9.0,
"price_yearly": 90.0,
"stripe_price_id_monthly": "",
"stripe_price_id_yearly": "",
"features": [],
},
)
finally:
app.dependency_overrides.pop(_require_admin, None)
assert resp.status_code == 200
data = resp.json()
assert data["stripe_price_id_monthly"] is None
assert data["stripe_price_id_yearly"] is None