diff options
Diffstat (limited to 'Biz')
| -rw-r--r-- | Biz/PodcastItLater/Billing.py | 6 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 532 |
2 files changed, 159 insertions, 379 deletions
diff --git a/Biz/PodcastItLater/Billing.py b/Biz/PodcastItLater/Billing.py index 41d04d6..4996607 100644 --- a/Biz/PodcastItLater/Billing.py +++ b/Biz/PodcastItLater/Billing.py @@ -165,8 +165,8 @@ def create_checkout_session(user_id: int, tier: str, base_url: str) -> str: session_params = { "mode": "subscription", "line_items": [{"price": price_id, "quantity": 1}], - "success_url": f"{base_url}/billing?status=success", - "cancel_url": f"{base_url}/billing?status=cancel", + "success_url": f"{base_url}/?status=success", + "cancel_url": f"{base_url}/?status=cancel", "client_reference_id": str(user_id), "metadata": {"user_id": str(user_id), "tier": tier}, "allow_promotion_codes": True, @@ -214,7 +214,7 @@ def create_portal_session(user_id: int, base_url: str) -> str: session = stripe.billing_portal.Session.create( customer=user["stripe_customer_id"], - return_url=f"{base_url}/billing", + return_url=f"{base_url}/", ) logger.info("Created portal session for user %s: %s", user_id, session.id) diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 274d46c..27b75dd 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -621,306 +621,6 @@ 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.meta( - name="color-scheme", - content="light dark", - ), - 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');", - ), - # Auto dark mode CSS (must come after Bootstrap) - UI.create_auto_dark_mode_style(), - 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("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.""" @@ -934,10 +634,10 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): """Main page combining all components.""" @staticmethod - def _render_plan_banner( + def _render_plan_callout( user: dict[str, typing.Any], - ) -> html.div | html.span: - """Render plan banner with quota for free users.""" + ) -> html.div: + """Render plan info callout box below navbar.""" tier = user.get("plan_tier", "free") if tier == "free": @@ -949,31 +649,61 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): articles_left = max(0, articles_limit - articles_used) return html.div( - html.span( - html.strong("Free: "), - f"{articles_left} articles left", - classes=["navbar-text", "me-3"], - ), - html.a( - html.i( - classes=[ - "bi", - "bi-arrow-up-circle", - "me-1", - ], + html.div( + html.div( + html.i( + classes=[ + "bi", + "bi-info-circle-fill", + "me-2", + ], + ), + html.strong(f"{articles_left} articles remaining"), + " of your free plan limit. ", + html.br(), + "Upgrade to ", + html.strong("Paid Plan"), + " for unlimited articles at $12/month.", + ), + html.form( + html.input( + type="hidden", + name="tier", + value="paid", + ), + html.button( + html.i( + classes=[ + "bi", + "bi-arrow-up-circle", + "me-1", + ], + ), + "Upgrade Now", + type="submit", + classes=[ + "btn", + "btn-success", + "btn-sm", + "mt-2", + ], + ), + method="post", + action="/billing/checkout", ), - "Upgrade Now", - href="/billing", classes=[ - "btn", - "btn-success", - "btn-sm", + "alert", + "alert-info", + "d-flex", + "justify-content-between", + "align-items-center", + "mb-4", ], ), - classes=["d-flex", "align-items-center"], + classes=["mb-4"], ) - # Paid plan - don't show plan name on navbar - return html.span() + # Paid user - no callout needed + return html.div() @override def render(self) -> html.html: @@ -1074,11 +804,6 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): "mb-lg-0", ], ), - # Plan banner/upgrade button - html.div( - self._render_plan_banner(user), - classes=["navbar-nav", "mb-2", "mb-lg-0"], - ), # Action buttons html.div( html.div( @@ -1086,12 +811,12 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): html.i( classes=[ "bi", - "bi-credit-card", + "bi-person-circle", "me-1", ], ), - "Billing", - href="/billing", + "Manage Account", + href="/account", classes=[ "btn", "btn-outline-secondary", @@ -1100,10 +825,7 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): "mb-2", "mb-lg-0", ], - ) - if user.get("plan_tier", "free") - != "free" - else html.span(), + ), html.a( html.i( classes=[ @@ -1160,6 +882,8 @@ class HomePage(Component[AnyChildren, HomePageAttrs]): ) if user else LoginForm(error=self.attrs.get("error")), + # Plan info callout (only for logged in users) + self._render_plan_callout(user) if user else html.div(), # Main content (only if logged in) html.div( SubmitForm(), @@ -1211,6 +935,7 @@ def index(request: Request) -> HomePage: queue_items = [] episodes = [] error = request.query_params.get("error") + status = request.query_params.get("status") # Map error codes to user-friendly messages error_messages = { @@ -1218,8 +943,16 @@ def index(request: Request) -> HomePage: "expired_link": "Login link has expired. Please request a new one.", "user_not_found": "User not found. Please try logging in again.", "forbidden": "Access denied. Admin privileges required.", + "cancel": "Checkout cancelled.", } - error_message = error_messages.get(error) if error else None + + # Handle billing status messages + if status == "success": + error_message = None + elif status == "cancel": + error_message = error_messages["cancel"] + else: + error_message = error_messages.get(error) if error else None if user_id: user = Core.Database.get_user_by_id(user_id) @@ -1384,6 +1117,90 @@ def verify_magic_link(request: Request) -> Response: return RedirectResponse("/?error=expired_link") +@app.get("/account") +def account_page(request: Request) -> html.html | RedirectResponse: + """Account management page (coming soon).""" + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse(url="/?error=login_required") + + user = Core.Database.get_user_by_id(user_id) + if not user: + return RedirectResponse(url="/?error=user_not_found") + + return html.html( + html.head( + html.meta(charset="utf-8"), + html.meta( + name="viewport", + content="width=device-width, initial-scale=1", + ), + html.meta( + name="color-scheme", + content="light dark", + ), + html.title("Account - PodcastItLater"), + ), + 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');", + ), + UI.create_auto_dark_mode_style(), + html.div( + html.div( + html.h1( + html.i( + classes=["bi", "bi-person-circle", "me-2"], + ), + "Account Management", + 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", + ], + ), + ), + html.div( + html.div( + html.i( + classes=["bi", "bi-info-circle-fill", "me-2"], + ), + html.strong("Coming Soon"), + html.p( + "Account management features including " + "subscription management, usage history, and " + "settings will be available here.", + classes=["mb-0", "mt-2"], + ), + classes=["alert", "alert-info"], + ), + ), + classes=["mb-4"], + ), + classes=["container", "my-5", "px-3", "px-md-4"], + style={"max-width": "900px"}, + ), + html.script( + src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js", + integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL", + crossorigin="anonymous", + ), + ), + ) + + @app.get("/logout") def logout(request: Request) -> Response: """Handle logout.""" @@ -1576,43 +1393,6 @@ app.get("/admin")(Admin.admin_queue_status) app.post("/queue/{job_id}/retry")(Admin.retry_queue_item) -@app.get("/billing") -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: - return RedirectResponse(url="/?error=login_required") - - user = Core.Database.get_user_by_id(user_id) - if not user: - return RedirectResponse(url="/?error=user_not_found") - - # Get current usage - period_start, period_end = Billing.get_period_boundaries(user) - 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." - - 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") def billing_checkout(request: Request, data: FormData) -> Response: """Create Stripe Checkout session.""" @@ -1620,9 +1400,9 @@ def billing_checkout(request: Request, data: FormData) -> Response: if not user_id: return Response("Unauthorized", status_code=401) - tier_raw = data.get("tier", "pro") - tier = tier_raw if isinstance(tier_raw, str) else "pro" - if tier != "pro": + tier_raw = data.get("tier", "paid") + tier = tier_raw if isinstance(tier_raw, str) else "paid" + if tier != "paid": return Response("Invalid tier", status_code=400) try: |
