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})
# ---------------------------------------------------------------------------
# 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:
"""Dispatch Stripe event to the appropriate handler.
+6
View File
@@ -75,6 +75,8 @@ class PlanUpsert(BaseModel):
sort_order: int = 0
features: list[str] = []
api_access: bool = False
stripe_price_id_monthly: str | None = None
stripe_price_id_yearly: str | None = None
class ReorderBody(BaseModel):
@@ -121,6 +123,8 @@ def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]:
"sort_order": plan.sort_order,
"features": features,
"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,
"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.features = json.dumps(body.features)
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.responses import HTMLResponse
@@ -16,3 +16,10 @@ templates = Jinja2Templates(directory="frontend/templates")
async def plan_designer(request: Request) -> HTMLResponse:
"""Admin Plan Designer page."""
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)
- [Prerequisites](#prerequisites)
- [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)
- [Billing Flows](#billing-flows)
- [Compliance Notes](#compliance-notes)
@@ -75,7 +76,6 @@ DocuElevate integrates with [Stripe](https://stripe.com) to handle subscription
### Prerequisites
- 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)
### 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.
### 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).
2. Enter the **Stripe Price ID (monthly)** from your Stripe Dashboard (e.g. `price_1OtAbc...`).
3. Optionally enter the **Stripe Price ID (yearly)** for annual billing.
4. Save the plan.
1. **Verify API Keys** — checks that your Stripe secret key is configured and the connection to Stripe is working.
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. **Configure Webhook** — shows the exact webhook endpoint URL to register in the Stripe Dashboard and which events to subscribe to.
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
@@ -110,6 +169,7 @@ Stripe webhooks allow DocuElevate to sync subscription status in real time.
1. Go to **Developers → Webhooks** in the Stripe Dashboard.
2. Click **Add endpoint**.
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:
- `checkout.session.completed`
- `customer.subscription.updated`
@@ -126,8 +186,10 @@ stripe login
# Forward webhooks to your local server
stripe listen --forward-to http://localhost:8000/api/billing/webhook
# Trigger a test event
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
```
### Billing Flows
@@ -139,7 +201,7 @@ stripe trigger checkout.session.completed
3. DocuElevate calls `POST /api/billing/create-checkout-session`.
4. User is redirected to Stripe Checkout.
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`.
#### 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>
</div>
<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
@click="seedDefaults()"
:disabled="seeding"
@@ -265,6 +271,40 @@
</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 -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Volume Limits</h3>
@@ -465,6 +505,8 @@ function planDesigner() {
sort_order: 0,
features: [],
api_access: false,
stripe_price_id_monthly: null,
stripe_price_id_yearly: null,
},
get yearlySavingPct() {
@@ -501,6 +543,7 @@ function planDesigner() {
overage_percent: 20, allow_overage_billing: false, overage_price_per_doc: null,
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,
stripe_price_id_monthly: null, stripe_price_id_yearly: null,
};
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")
assert resp.status_code == 200
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