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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user