diff options
| author | Ben Sima <ben@bsima.me> | 2025-11-09 20:32:34 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bsima.me> | 2025-11-09 20:32:34 -0500 |
| commit | 14af38a21dc8e790b9ddc29e241784fd769dc3fc (patch) | |
| tree | e33ef9866f6448088cac4acd2ef9b48c32ca3990 /Biz/PodcastItLater | |
| parent | 83d90f815447abc5447f6b0b4a978b2e8ce82894 (diff) | |
PodcastItLater: Add Stripe billing and mobile responsiveness
- Implemented complete Stripe integration (Billing.py)
- Checkout sessions for subscription upgrades - Billing portal for
subscription management - Webhook handling for subscription events
- Usage tracking with tier-based limits (free: 10, personal: 50,
pro: unlimited)
- Added billing page UI (BillingPage component)
- Current plan display with usage stats - Pricing cards for all
tiers with upgrade buttons - Manage subscription button for paid
users - Success/error messaging
- Database migrations for billing
- Added plan_tier, stripe_customer_id, stripe_subscription_id - Added
subscription_status, period dates, cancel_at_period_end - Created
stripe_events table for webhook idempotency - Added get_usage()
method for usage tracking
- Made UI mobile-friendly and responsive
- Added viewport meta tags to all pages - Replaced pages.HtmlPage
with raw html.html for meta tag control - Responsive button layouts
with flexbox wrapping - Responsive pricing cards (1 col mobile,
2 col tablet, 3 col desktop) - Touch-friendly forms and buttons
(44px minimum) - Responsive padding and containers - Admin tables
with horizontal scroll
- Added Stripe testing guide (STRIPE_TESTING.md)
- Fixed CSS bug in pricing cards (cardh-100 text rendering) - Updated
tasks: completed t-144e7lF, t-1pIV0ZF, t-1s8ADC0
Amp-Thread-ID:
https://ampcode.com/threads/T-42fd5fb3-3dc5-4cbc-a9a3-78db9e13187e
Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'Biz/PodcastItLater')
| -rw-r--r-- | Biz/PodcastItLater/Admin.py | 45 | ||||
| -rw-r--r-- | Biz/PodcastItLater/STRIPE_TESTING.md | 366 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 351 |
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") |
