summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater')
-rw-r--r--Biz/PodcastItLater/Admin.py45
-rw-r--r--Biz/PodcastItLater/STRIPE_TESTING.md366
-rw-r--r--Biz/PodcastItLater/Web.py351
3 files changed, 728 insertions, 34 deletions
diff --git a/Biz/PodcastItLater/Admin.py b/Biz/PodcastItLater/Admin.py
index 5772256..7dd0c50 100644
--- a/Biz/PodcastItLater/Admin.py
+++ b/Biz/PodcastItLater/Admin.py
@@ -13,7 +13,6 @@ Admin pages and functionality for managing users and queue items.
# : dep pytest-mock
import Biz.PodcastItLater.Core as Core
import ludic.catalog.layouts as layouts
-import ludic.catalog.pages as pages
import ludic.html as html
# i need to import these unused because bild cannot get local transitive python
@@ -305,16 +304,24 @@ class AdminUsers(Component[AnyChildren, AdminUsersAttrs]):
"""Admin view for managing users."""
@override
- def render(self) -> pages.HtmlPage:
+ def render(self) -> html.html:
users = self.attrs["users"]
- return pages.HtmlPage(
- pages.Head(
- title="PodcastItLater - User Management",
- htmx_version="1.9.10",
- load_styles=False,
+ return html.html(
+ html.head(
+ html.meta(charset="utf-8"),
+ html.meta(
+ name="viewport",
+ content="width=device-width, initial-scale=1",
+ ),
+ html.title("PodcastItLater - User Management"),
+ html.script(
+ src="https://unpkg.com/htmx.org@1.9.10",
+ integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC",
+ crossorigin="anonymous",
+ ),
),
- pages.Body(
+ html.body(
create_bootstrap_styles(),
html.div(
html.h1(
@@ -371,18 +378,26 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]):
"""Admin view showing all queue items and episodes in tables."""
@override
- def render(self) -> pages.HtmlPage:
+ def render(self) -> html.html:
queue_items = self.attrs["queue_items"]
episodes = self.attrs["episodes"]
status_counts = self.attrs.get("status_counts", {})
- return pages.HtmlPage(
- pages.Head(
- title="PodcastItLater - Admin Queue Status",
- htmx_version="1.9.10",
- load_styles=False,
+ return html.html(
+ html.head(
+ html.meta(charset="utf-8"),
+ html.meta(
+ name="viewport",
+ content="width=device-width, initial-scale=1",
+ ),
+ html.title("PodcastItLater - Admin Queue Status"),
+ html.script(
+ src="https://unpkg.com/htmx.org@1.9.10",
+ integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC",
+ crossorigin="anonymous",
+ ),
),
- pages.Body(
+ html.body(
create_bootstrap_styles(),
html.div(
AdminView._render_content(
diff --git a/Biz/PodcastItLater/STRIPE_TESTING.md b/Biz/PodcastItLater/STRIPE_TESTING.md
new file mode 100644
index 0000000..00910ec
--- /dev/null
+++ b/Biz/PodcastItLater/STRIPE_TESTING.md
@@ -0,0 +1,366 @@
+# Stripe Testing Guide for PodcastItLater
+
+This guide covers end-to-end Stripe billing integration testing.
+
+## Prerequisites
+
+1. Stripe account (sign up at stripe.com)
+2. Stripe CLI installed (`brew install stripe/stripe-cli/stripe` or download from stripe.com/docs/stripe-cli)
+
+## Setup Steps
+
+### 1. Get Stripe Test Mode API Keys
+
+1. Go to https://dashboard.stripe.com/test/apikeys
+2. Copy your test mode keys:
+ - **Publishable key** (starts with `pk_test_`)
+ - **Secret key** (starts with `sk_test_`)
+
+### 2. Create Products and Prices in Stripe Dashboard
+
+#### Personal Plan ($9/month)
+1. Go to https://dashboard.stripe.com/test/products
+2. Click "+ Add product"
+3. Fill in:
+ - Name: `PodcastItLater Personal`
+ - Description: `50 articles per month`
+ - Pricing model: `Standard pricing`
+ - Price: `$9.00`
+ - Billing period: `Monthly`
+ - Payment type: `Recurring`
+4. Click "Save product"
+5. **Copy the Price ID** (starts with `price_`) - you'll need this for `STRIPE_PRICE_ID_PERSONAL`
+
+#### Pro Plan ($29/month)
+1. Repeat above steps with:
+ - Name: `PodcastItLater Pro`
+ - Description: `Unlimited articles`
+ - Price: `$29.00`
+ - Billing period: `Monthly`
+2. **Copy the Price ID** - you'll need this for `STRIPE_PRICE_ID_PRO`
+
+### 3. Configure Environment Variables
+
+Create or update your `.envrc.local` file:
+
+```bash
+# Stripe Test Mode Keys
+export STRIPE_SECRET_KEY="sk_test_YOUR_SECRET_KEY_HERE"
+
+# Price IDs from Stripe dashboard
+export STRIPE_PRICE_ID_PERSONAL="price_YOUR_PERSONAL_PRICE_ID"
+export STRIPE_PRICE_ID_PRO="price_YOUR_PRO_PRICE_ID"
+
+# Other required vars (if not already set)
+export BASE_URL="http://localhost:8000"
+export SESSION_SECRET="dev-secret-key-for-testing"
+export SECRET_KEY="dev-secret-key-for-magic-links"
+export AREA="Test" # Important for test mode behavior
+```
+
+Reload environment:
+```bash
+direnv allow
+```
+
+### 4. Set Up Webhook Testing
+
+#### Option A: Stripe CLI (Recommended for local testing)
+
+1. Login to Stripe CLI:
+ ```bash
+ stripe login
+ ```
+
+2. Start webhook forwarding:
+ ```bash
+ stripe listen --forward-to http://localhost:8000/stripe/webhook
+ ```
+
+3. **Copy the webhook signing secret** shown in the output (starts with `whsec_`)
+
+4. Add to `.envrc.local`:
+ ```bash
+ export STRIPE_WEBHOOK_SECRET="whsec_YOUR_WEBHOOK_SECRET"
+ ```
+
+5. Reload environment:
+ ```bash
+ direnv allow
+ ```
+
+#### Option B: Deploy and Use Stripe Dashboard Webhooks
+
+1. Deploy to production or staging environment
+2. Go to https://dashboard.stripe.com/test/webhooks
+3. Click "+ Add endpoint"
+4. Enter your webhook URL: `https://your-domain.com/stripe/webhook`
+5. Select events to listen for:
+ - `checkout.session.completed`
+ - `customer.subscription.created`
+ - `customer.subscription.updated`
+ - `customer.subscription.deleted`
+ - `invoice.payment_failed`
+6. Copy the signing secret and add to your environment
+
+### 5. Initialize Database
+
+```bash
+# Make sure DATA_DIR is set (defaults to _/var/podcastitlater/)
+export DATA_DIR="_/var/podcastitlater/"
+mkdir -p $DATA_DIR
+
+# Start the web server to initialize database
+bild --time 0 Biz/PodcastItLater/Web.py
+python Biz/PodcastItLater/Web.py
+```
+
+## Testing the Complete Flow
+
+### Test 1: User Registration and Free Tier
+
+1. Start the web server:
+ ```bash
+ python Biz/PodcastItLater/Web.py
+ ```
+
+2. Open http://localhost:8000 in your browser
+
+3. Login with `demo@example.com` (auto-approved in test mode)
+
+4. Verify:
+ - ✓ Logged in as demo@example.com
+ - ✓ Plan shows "Free"
+ - ✓ Billing button visible
+
+5. Click "Billing" button
+
+6. Verify billing page shows:
+ - ✓ Current Plan: Free
+ - ✓ Usage: 0 / 10 articles
+ - ✓ Period dates (current month)
+ - ✓ Three pricing cards (Free, Personal, Pro)
+ - ✓ "Upgrade" buttons on Personal and Pro plans
+
+### Test 2: Stripe Checkout Flow (Personal Plan)
+
+1. On billing page, click "Upgrade" button under Personal plan
+
+2. Verify redirected to Stripe Checkout page:
+ - ✓ Shows "PodcastItLater Personal"
+ - ✓ Shows $9.00/month
+ - ✓ Can enter test card details
+
+3. Use Stripe test card:
+ - Card number: `4242 4242 4242 4242`
+ - Expiry: Any future date (e.g., `12/34`)
+ - CVC: Any 3 digits (e.g., `123`)
+ - Email: Use same email as logged in user
+
+4. Complete checkout
+
+5. Verify:
+ - ✓ Redirected back to `/billing?status=success`
+ - ✓ Success message shown
+ - ✓ Plan updated to "Personal" (may take a few moments)
+ - ✓ Usage shows "0 / 50 articles"
+ - ✓ "Manage Subscription" button appears
+ - ✓ "Current Plan" badge on Personal plan
+
+### Test 3: Webhook Events
+
+Check your terminal running `stripe listen` to verify webhook events received:
+
+```
+✓ checkout.session.completed [evt_xxx]
+✓ customer.subscription.created [evt_xxx]
+✓ customer.subscription.updated [evt_xxx]
+```
+
+Check database to verify subscription data:
+
+```bash
+sqlite3 _/var/podcastitlater/podcast.db
+```
+
+```sql
+-- Check user subscription details
+SELECT id, email, plan_tier, subscription_status,
+ stripe_customer_id, stripe_subscription_id
+FROM users WHERE email = 'demo@example.com';
+
+-- Should show:
+-- plan_tier: personal
+-- subscription_status: active
+-- stripe_customer_id: cus_xxx
+-- stripe_subscription_id: sub_xxx
+```
+
+### Test 4: Billing Portal (Manage Subscription)
+
+1. On billing page (now showing Personal plan), click "Manage Subscription"
+
+2. Verify redirected to Stripe Billing Portal:
+ - ✓ Shows current subscription: PodcastItLater Personal
+ - ✓ Can update payment method
+ - ✓ Can cancel subscription
+ - ✓ Can view invoices
+
+3. Test cancellation:
+ - Click "Cancel plan"
+ - Select cancellation option (e.g., "Cancel at end of period")
+ - Confirm
+
+4. Return to billing page
+
+5. Verify:
+ - ✓ Subscription shows as active but set to cancel
+ - ✓ Still can use service until period ends
+
+### Test 5: Usage Limits Enforcement
+
+1. Login as a free user (or create new user)
+
+2. Try to submit more than 10 articles in the current month
+
+3. On the 11th submission, verify:
+ - ✓ Error message shown: "You've reached your limit of 10 articles per period. Upgrade to continue."
+ - ✓ Submit button disabled or shows error
+ - ✓ Upgrade prompt shown
+
+4. Upgrade to Personal or Pro plan
+
+5. Verify:
+ - ✓ Can submit articles again
+ - ✓ New usage limit applies
+
+### Test 6: Subscription Upgrade Flow
+
+1. Start with Personal plan
+
+2. Go to billing page
+
+3. Click "Upgrade" on Pro plan
+
+4. Complete checkout with test card
+
+5. Verify:
+ - ✓ Plan upgraded to Pro
+ - ✓ Usage shows "0 / ∞ articles"
+ - ✓ Billing reflects pro-rated charges
+
+### Test 7: Payment Failure Handling
+
+1. Use Stripe test card that triggers payment failure:
+ - Card number: `4000 0000 0000 0341` (charge fails)
+
+2. After first payment succeeds, wait for next billing cycle or trigger failure manually
+
+3. Verify:
+ - ✓ Subscription status updates to "past_due"
+ - ✓ User still has access during grace period
+ - ✓ Webhook event processed: `invoice.payment_failed`
+
+### Test 8: Subscription Cancellation
+
+1. Cancel subscription from Billing Portal
+
+2. Wait for end of billing period OR manually expire in Stripe dashboard
+
+3. Verify:
+ - ✓ Webhook event: `customer.subscription.deleted`
+ - ✓ User downgraded to free tier
+ - ✓ Usage limit reset to 10 articles/month
+ - ✓ Stripe subscription data cleared
+
+## Common Test Cards
+
+| Card Number | Scenario |
+|-------------|----------|
+| 4242 4242 4242 4242 | Successful payment |
+| 4000 0000 0000 0341 | Charge fails |
+| 4000 0000 0000 9995 | Card declined |
+| 4000 0025 0000 3155 | Requires authentication (3D Secure) |
+
+Full list: https://stripe.com/docs/testing#cards
+
+## Troubleshooting
+
+### Webhooks not received
+
+- Check Stripe CLI is running: `stripe listen --forward-to ...`
+- Verify webhook secret matches in environment
+- Check web server logs for webhook processing errors
+- Verify web server is accessible at the forwarding URL
+
+### Database not updating
+
+- Check web server logs for errors
+- Verify webhook events are being processed (check stripe_events table)
+- Check database schema is up to date (run Web.py to trigger migrations)
+
+### Checkout session not creating
+
+- Verify STRIPE_SECRET_KEY is set and valid
+- Check STRIPE_PRICE_ID_PERSONAL and STRIPE_PRICE_ID_PRO are correct
+- Look for errors in web server logs
+- Verify price IDs exist in Stripe dashboard
+
+### User not upgrading after checkout
+
+- Verify webhooks are being received and processed
+- Check that customer email in checkout matches user email in database
+- Look for errors in webhook processing logs
+- Check stripe_events table for duplicate processing
+
+## Production Deployment
+
+Before going to production:
+
+1. Switch to live mode keys (remove `_test_` from keys)
+2. Create products/prices in live mode Stripe dashboard
+3. Set up live webhook endpoint in Stripe dashboard
+4. Update STRIPE_WEBHOOK_SECRET to live mode secret
+5. Set AREA=Live in production environment
+6. Test with real payment methods (or use test mode in production at first)
+7. Monitor webhook events and logs closely
+
+## Monitoring
+
+### Check Webhook Events in Database
+
+```sql
+SELECT * FROM stripe_events
+ORDER BY created_at DESC
+LIMIT 10;
+```
+
+### Check Subscription States
+
+```sql
+SELECT email, plan_tier, subscription_status,
+ current_period_start, current_period_end
+FROM users
+WHERE plan_tier != 'free';
+```
+
+### Check Usage Stats
+
+```sql
+SELECT u.email, u.plan_tier, COUNT(e.id) as articles_this_month
+FROM users u
+LEFT JOIN episodes e ON e.user_id = u.id
+ AND e.created_at >= date('now', 'start of month')
+GROUP BY u.id, u.email, u.plan_tier;
+```
+
+## Next Steps
+
+After successful testing:
+
+1. Mark task t-1pIV0ZF as done
+2. Update AGENTS.md with Stripe setup documentation
+3. Create production deployment checklist
+4. Set up error monitoring (Sentry)
+5. Configure email notifications for payment failures
+6. Add analytics for conversion tracking
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index 03d3eb7..2032746 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -22,7 +22,6 @@ import Biz.PodcastItLater.Billing as Billing
import Biz.PodcastItLater.Core as Core
import html as html_module
import httpx
-import ludic.catalog.pages as pages
import ludic.html as html
import Omni.App as App
import Omni.Log as Log
@@ -600,6 +599,300 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]):
)
+class BillingPageAttrs(Attrs):
+ """Attributes for BillingPage component."""
+
+ user: dict[str, typing.Any]
+ usage: dict[str, int]
+ period_start: str
+ period_end: str
+ success: str | None
+ error: str | None
+
+
+class BillingPage(Component[AnyChildren, BillingPageAttrs]):
+ """Billing page showing current plan, usage, and upgrade options."""
+
+ @override
+ def render(self) -> html.html:
+ user = self.attrs["user"]
+ usage = self.attrs["usage"]
+ period_start = self.attrs["period_start"]
+ period_end = self.attrs["period_end"]
+ success = self.attrs.get("success")
+ error_msg = self.attrs.get("error")
+
+ tier = user.get("plan_tier", "free")
+ tier_info = Billing.get_tier_info(tier)
+ has_subscription = tier != "free"
+
+ return html.html(
+ html.head(
+ html.meta(charset="utf-8"),
+ html.meta(
+ name="viewport",
+ content="width=device-width, initial-scale=1",
+ ),
+ html.title("Billing - PodcastItLater"),
+ html.script(
+ src="https://unpkg.com/htmx.org@1.9.10",
+ integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC",
+ crossorigin="anonymous",
+ ),
+ ),
+ html.body(
+ html.style(
+ "@import url('https://cdn.jsdelivr.net/npm/bootstrap@5.3.2"
+ "/dist/css/bootstrap.min.css');"
+ "@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons"
+ "@1.11.3/font/bootstrap-icons.min.css');",
+ ),
+ html.div(
+ html.div(
+ html.h1(
+ html.i(classes=["bi", "bi-credit-card", "me-2"]),
+ "Billing & Usage",
+ classes=["mb-4"],
+ ),
+ html.div(
+ html.a(
+ html.i(classes=["bi", "bi-arrow-left", "me-1"]),
+ "Back to Dashboard",
+ href="/",
+ classes=[
+ "btn",
+ "btn-outline-secondary",
+ "mb-4",
+ ],
+ ),
+ ),
+ classes=["mb-4"],
+ ),
+ # Success/Error alerts
+ html.div(
+ html.div(
+ html.i(
+ classes=["bi", "bi-check-circle-fill", "me-2"],
+ ),
+ success or "",
+ classes=["alert", "alert-success"],
+ ),
+ )
+ if success
+ else html.div(),
+ html.div(
+ html.div(
+ html.i(
+ classes=[
+ "bi",
+ "bi-exclamation-triangle-fill",
+ "me-2",
+ ],
+ ),
+ error_msg or "",
+ classes=["alert", "alert-danger"],
+ ),
+ )
+ if error_msg
+ else html.div(),
+ # Current Plan Card
+ html.div(
+ html.div(
+ html.h4(
+ html.i(classes=["bi", "bi-star-fill", "me-2"]),
+ "Current Plan",
+ classes=["card-title", "mb-3"],
+ ),
+ html.div(
+ html.div(
+ html.h3(
+ tier_info["name"],
+ classes=["text-primary"],
+ ),
+ html.p(
+ tier_info["description"],
+ classes=["text-muted"],
+ ),
+ classes=["col-md-6"],
+ ),
+ html.div(
+ html.h5(
+ "Usage This Period",
+ classes=["mb-2"],
+ ),
+ html.p(
+ html.i(
+ classes=[
+ "bi",
+ "bi-file-text",
+ "me-2",
+ ],
+ ),
+ (
+ f"{usage['articles']} / "
+ f"{tier_info['articles_limit'] or '∞'} " # noqa: E501
+ "articles"
+ ),
+ classes=["mb-1"],
+ ),
+ html.p(
+ html.i(
+ classes=[
+ "bi",
+ "bi-calendar-range",
+ "me-2",
+ ],
+ ),
+ (
+ f"Period: {period_start} - "
+ f"{period_end}"
+ ),
+ classes=["text-muted", "small"],
+ ),
+ classes=["col-md-6"],
+ ),
+ classes=["row"],
+ ),
+ # Manage subscription button for paid users
+ html.div(
+ html.form(
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-gear-fill",
+ "me-1",
+ ],
+ ),
+ "Manage Subscription",
+ type="submit",
+ classes=[
+ "btn",
+ "btn-primary",
+ ],
+ ),
+ method="post",
+ action="/billing/portal",
+ ),
+ classes=["mt-3"],
+ )
+ if has_subscription
+ else html.div(),
+ classes=["card-body"],
+ ),
+ classes=["card", "mb-4"],
+ ),
+ # Pricing Cards
+ html.div(
+ html.h4("Available Plans", classes=["mb-4"]),
+ html.div(
+ self._render_pricing_card("free", tier == "free"),
+ self._render_pricing_card(
+ "personal",
+ tier == "personal",
+ ),
+ self._render_pricing_card("pro", tier == "pro"),
+ classes=["row", "g-3"],
+ ),
+ ),
+ classes=["container", "my-5", "px-3", "px-md-4"],
+ style={"max-width": "1000px"},
+ ),
+ html.script(
+ src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js",
+ integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL",
+ crossorigin="anonymous",
+ ),
+ ),
+ )
+
+ @staticmethod
+ def _render_pricing_card(
+ tier_name: str,
+ is_current: bool, # noqa: FBT001
+ ) -> html.div:
+ """Render a single pricing card."""
+ tier_info = Billing.get_tier_info(tier_name)
+
+ # Determine card styling based on tier
+ card_classes = ["card", "h-100"]
+ if tier_name == "pro":
+ card_classes.append("border-primary")
+
+ return html.div(
+ html.div(
+ html.div(
+ html.div(
+ html.h5(
+ tier_info["name"],
+ classes=["card-title"],
+ ),
+ html.span(
+ "Current Plan",
+ classes=["badge", "bg-success", "ms-2"],
+ )
+ if is_current
+ else html.span(),
+ ),
+ html.h3(
+ tier_info["price"],
+ classes=["my-3"],
+ ),
+ html.p(
+ tier_info["description"],
+ classes=["text-muted"],
+ ),
+ html.ul(
+ html.li(
+ (
+ f"{tier_info['articles_limit'] or 'Unlimited'} "
+ "articles per month"
+ ),
+ ),
+ html.li("High-quality TTS"),
+ html.li("Personal RSS feed"),
+ classes=["list-unstyled", "mb-4"],
+ ),
+ # Upgrade button (only show for tiers higher than current)
+ html.form(
+ html.input(
+ type="hidden",
+ name="tier",
+ value=tier_name,
+ ),
+ html.button(
+ "Upgrade",
+ type="submit",
+ classes=[
+ "btn",
+ "btn-primary"
+ if tier_name == "pro"
+ else "btn-outline-primary",
+ "w-100",
+ ],
+ ),
+ method="post",
+ action="/billing/checkout",
+ )
+ if tier_name != "free" and not is_current
+ else html.div(
+ html.button(
+ "Current Plan",
+ type="button",
+ disabled=True,
+ classes=["btn", "btn-secondary", "w-100"],
+ ),
+ )
+ if is_current
+ else html.div(),
+ classes=["card-body"],
+ ),
+ classes=card_classes,
+ ),
+ classes=["col-12", "col-sm-6", "col-md-4"],
+ )
+
+
class HomePageAttrs(Attrs):
"""Attributes for HomePage component."""
@@ -613,25 +906,26 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
"""Main page combining all components."""
@override
- def render(self) -> pages.HtmlPage:
+ def render(self) -> html.html:
queue_items = self.attrs["queue_items"]
episodes = self.attrs["episodes"]
user = self.attrs.get("user")
- return pages.HtmlPage(
- pages.Head(
- title="PodcastItLater",
- htmx_version="1.9.10",
- load_styles=False,
- ),
- pages.Body(
- # Add HTMX
+ return html.html(
+ html.head(
+ html.meta(charset="utf-8"),
+ html.meta(
+ name="viewport",
+ content="width=device-width, initial-scale=1",
+ ),
+ html.title("PodcastItLater"),
html.script(
src="https://unpkg.com/htmx.org@1.9.10",
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC",
crossorigin="anonymous",
),
- # Add Bootstrap CSS and icons (pages.Head doesn't support it)
+ ),
+ html.body(
html.style(
"@import url('https://cdn.jsdelivr.net/npm/bootstrap@5.3.2"
"/dist/css/bootstrap.min.css');"
@@ -707,6 +1001,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
"btn-outline-secondary",
"btn-sm",
"me-2",
+ "mb-2",
],
),
html.a(
@@ -724,6 +1019,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
"btn-outline-primary",
"btn-sm",
"me-2",
+ "mb-2",
],
)
if Core.is_admin(user)
@@ -742,8 +1038,10 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
"btn",
"btn-outline-danger",
"btn-sm",
+ "mb-2",
],
),
+ classes=["d-flex", "flex-wrap"],
),
classes=["card-body", "bg-light"],
),
@@ -771,7 +1069,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]):
)
if user
else html.div(),
- classes=["container", "max-w-4xl"],
+ classes=["container", "px-3", "px-md-4"],
style={"max-width": "900px"},
),
# Bootstrap JS bundle
@@ -1171,7 +1469,7 @@ app.post("/queue/{job_id}/retry")(Admin.retry_queue_item)
@app.get("/billing")
-def billing_page(request: Request) -> Response:
+def billing_page(request: Request) -> BillingPage | RedirectResponse:
"""Display billing page with current plan and upgrade options."""
user_id = request.session.get("user_id")
if not user_id:
@@ -1181,15 +1479,30 @@ def billing_page(request: Request) -> Response:
if not user:
return RedirectResponse(url="/?error=user_not_found")
- tier = user.get("plan_tier", "free")
- tier_info = Billing.get_tier_info(tier)
-
# Get current usage
period_start, period_end = Billing.get_period_boundaries(user)
- Billing.get_usage(user_id, period_start, period_end)
+ usage = Billing.get_usage(user_id, period_start, period_end)
+
+ # Check for status query params
+ status = request.query_params.get("status")
+ success = None
+ error = None
+ if status == "success":
+ success = (
+ "Subscription updated successfully! "
+ "Changes may take a few moments to reflect."
+ )
+ elif status == "cancel":
+ error = "Checkout cancelled."
- # Billing page component to be implemented
- return Response(f"<h1>Billing - Current plan: {tier_info['name']}</h1>")
+ return BillingPage(
+ user=user,
+ usage=usage,
+ period_start=period_start.strftime("%Y-%m-%d"),
+ period_end=period_end.strftime("%Y-%m-%d"),
+ success=success,
+ error=error,
+ )
@app.post("/billing/checkout")