diff --git a/app/api/billing.py b/app/api/billing.py index 75090d4c..b3d437c2 100644 --- a/app/api/billing.py +++ b/app/api/billing.py @@ -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. diff --git a/app/api/plans.py b/app/api/plans.py index 844b9326..71c6be7d 100644 --- a/app/api/plans.py +++ b/app/api/plans.py @@ -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 # --------------------------------------------------------------------------- diff --git a/app/views/plans.py b/app/views/plans.py index 05c00451..6f01c40a 100644 --- a/app/views/plans.py +++ b/app/views/plans.py @@ -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}) diff --git a/docs/BillingSetup.md b/docs/BillingSetup.md index 00fa043e..44200696 100644 --- a/docs/BillingSetup.md +++ b/docs/BillingSetup.md @@ -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: " +``` + +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 diff --git a/frontend/templates/admin_plans.html b/frontend/templates/admin_plans.html index e4e4cc6b..41897e84 100644 --- a/frontend/templates/admin_plans.html +++ b/frontend/templates/admin_plans.html @@ -14,6 +14,12 @@

Manage subscription plans shown on the public pricing page.

+ + Stripe Setup +
+ +
+
+

Stripe Integration

+ Stripe Wizard +
+

+ Enter the Stripe Price IDs for this plan, or use the + Stripe Setup Wizard + to auto-create them. Free plans do not need Stripe Price IDs. +

+
+
+ + +
+
+ + +
+
+
+

Volume Limits

@@ -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; }, diff --git a/frontend/templates/admin_stripe_wizard.html b/frontend/templates/admin_stripe_wizard.html new file mode 100644 index 00000000..099f812d --- /dev/null +++ b/frontend/templates/admin_stripe_wizard.html @@ -0,0 +1,414 @@ +{% extends "base.html" %} + +{% block title %}Stripe Setup Wizard — DocuElevate Admin{% endblock %} + +{% block content %} +
+ + +
+
+

+ Stripe Setup Wizard +

+

Configure Stripe billing for DocuElevate in three steps.

+
+ + Back to Plans + +
+ + +
+ +

Checking Stripe status…

+
+ + +
+ + + + + +
+
+

Step 1 — Verify Stripe API Keys

+

+ Set your Stripe keys in your environment (or .env file) then click + Check Connection to verify. +

+
+ + +
+

STRIPE_SECRET_KEY=sk_test_…

+

STRIPE_PUBLISHABLE_KEY=pk_test_…

+

STRIPE_WEBHOOK_SECRET=whsec_…

+
+ + +
+ + + + +
+
+ + Connected to Stripe + . +
+ +
+
+ +
+ + +
+
+ + +
+
+

Step 2 — Sync Plans to Stripe

+

+ DocuElevate can auto-create Stripe Products and Prices for each + paid plan. Free plans are skipped. Plans that already have a Stripe price ID are left unchanged. +

+
+ + +
+ + + + + + + + + + + + +
PlanMonthly Price IDYearly Price IDStatus
+
+ + +
+ +
+ +
+ + + +
+
+ + +
+
+

Step 3 — Configure Stripe Webhook

+

+ Webhooks let Stripe notify DocuElevate of subscription changes in real time. + Add the endpoint below in your + + Stripe Dashboard → Developers → Webhooks + . +

+
+ + +
+ +
+ + +
+
+ + +
+

Subscribe to these events:

+
    +
  • checkout.session.completed
  • +
  • customer.subscription.updated
  • +
  • customer.subscription.deleted
  • +
  • invoice.payment_failed
  • +
+
+ + +
+ + After adding the endpoint, copy the Signing secret from Stripe and set + STRIPE_WEBHOOK_SECRET=whsec_… in your environment. + This protects against spoofed webhook events. +
+ + +
+
+ + STRIPE_WEBHOOK_SECRET is configured. Webhook signatures are verified. +
+ +
+ + +
+ + Local testing with Stripe CLI + +
+
# 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
+
+
+ + +
+ +
+
+ + +{% endblock %} diff --git a/tests/test_billing.py b/tests/test_billing.py index a669173a..2e40c34f 100644 --- a/tests/test_billing.py +++ b/tests/test_billing.py @@ -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