"""Payments blueprint: pricing, subscription checkout, customer portal, boost purchase, Stripe webhook handler, and billing dashboard. """ from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, abort) from flask_login import login_required, current_user from flask_babel import gettext as _ from app.extensions import db, limiter, csrf from app.models.plan import Plan from app.models.listing import Listing from app.models.payments import Subscription, Transaction, Boost from app.services import billing as bsvc from app.services.email import send_email payments_bp = Blueprint("payments", __name__) # ── Pricing page ─────────────────────────────────────────────────────────── @payments_bp.route("/pricing") def pricing(): plans = Plan.query.filter_by(is_active=True).order_by(Plan.sort_order).all() current_plan = None if current_user.is_authenticated: current_plan = current_user.tier return render_template("payments/pricing.html", plans=plans, current_plan=current_plan, stripe_enabled=bsvc.stripe_enabled()) # ── Subscription checkout ────────────────────────────────────────────────── @payments_bp.route("/billing/subscribe/") @login_required @limiter.limit("10 per hour") def subscribe(slug): plan = Plan.query.filter_by(slug=slug, is_active=True).first_or_404() if plan.slug == "free": flash(_("You are already on the free plan."), "info") return redirect(url_for("payments.pricing")) if not bsvc.stripe_enabled(): flash(_("Payments not configured yet."), "warning") return redirect(url_for("payments.pricing")) if not plan.stripe_price_id: flash(_("This plan is not yet available for purchase."), "warning") return redirect(url_for("payments.pricing")) try: url = bsvc.create_subscription_checkout( current_user, plan, success_url=url_for("payments.billing_success", _external=True, _scheme="https"), cancel_url=url_for("payments.pricing", _external=True, _scheme="https"), ) return redirect(url) except Exception as e: current_app.logger.error("Checkout error: %s", e) flash(_("Could not start checkout. Please try again."), "danger") return redirect(url_for("payments.pricing")) # ── Customer portal ──────────────────────────────────────────────────────── @payments_bp.route("/billing/portal") @login_required def portal(): if not bsvc.stripe_enabled(): flash(_("Payments not configured yet."), "warning") return redirect(url_for("payments.my_billing")) sub = Subscription.query.filter_by(user_id=current_user.id).first() if not sub or not sub.stripe_customer_id: flash(_("No active subscription found."), "warning") return redirect(url_for("payments.pricing")) try: url = bsvc.create_customer_portal( current_user, return_url=url_for("payments.my_billing", _external=True, _scheme="https"), ) return redirect(url) except Exception as e: current_app.logger.error("Portal error: %s", e) flash(_("Could not open billing portal."), "danger") return redirect(url_for("payments.my_billing")) # ── Billing dashboard ────────────────────────────────────────────────────── @payments_bp.route("/my/billing") @login_required def my_billing(): sub = Subscription.query.filter_by(user_id=current_user.id).first() txns = (Transaction.query.filter_by(user_id=current_user.id) .order_by(Transaction.created_at.desc()).limit(20).all()) active_boosts = [b for b in Boost.query.filter_by(user_id=current_user.id).all() if b.is_active] return render_template("payments/billing.html", sub=sub, txns=txns, active_boosts=active_boosts, plan=current_user.tier, stripe_enabled=bsvc.stripe_enabled()) # ── Boost purchase ───────────────────────────────────────────────────────── @payments_bp.route("/listings//boost", methods=["GET", "POST"]) @login_required @limiter.limit("20 per hour") def boost_listing(listing_id): listing = db.session.get(Listing, listing_id) if listing is None: abort(404) if listing.user_id != current_user.id: abort(403) if request.method == "POST": boost_type = request.form.get("boost_type") if boost_type not in Boost.TYPES: flash(_("Invalid boost type."), "danger") return redirect(url_for("payments.boost_listing", listing_id=listing_id)) if not bsvc.stripe_enabled(): flash(_("Payments not configured yet."), "warning") return redirect(url_for("listings.detail", listing_id=listing_id)) try: url = bsvc.create_boost_checkout( current_user, listing, boost_type, success_url=url_for("payments.boost_success", listing_id=listing_id, _external=True, _scheme="https"), cancel_url=url_for("listings.detail", listing_id=listing_id, _external=True, _scheme="https"), ) return redirect(url) except Exception as e: current_app.logger.error("Boost checkout error: %s", e) flash(_("Could not start boost checkout."), "danger") active_boosts = {b.type for b in listing.boosts if b.is_active} return render_template("payments/boost.html", listing=listing, boost_types=Boost.TYPES, active_boosts=active_boosts, stripe_enabled=bsvc.stripe_enabled()) # ── Success / cancel pages ───────────────────────────────────────────────── @payments_bp.route("/billing/success") @login_required def billing_success(): flash(_("Subscription activated! Welcome to your new plan."), "success") return render_template("payments/success.html", mode="subscription") @payments_bp.route("/listings//boost/success") @login_required def boost_success(listing_id): flash(_("Boost applied to your listing!"), "success") return render_template("payments/success.html", mode="boost", listing_id=listing_id) # ── Stripe webhook ───────────────────────────────────────────────────────── @payments_bp.route("/billing/webhook", methods=["POST"]) @csrf.exempt def webhook(): payload = request.get_data() sig = request.headers.get("Stripe-Signature", "") if not bsvc.stripe_enabled(): abort(400) try: event = bsvc.handle_webhook(payload, sig) except ValueError as e: current_app.logger.warning("Webhook rejected: %s", e) abort(400) # send payment-failed email after DB is updated if event["type"] == "invoice.payment_failed": _notify_payment_failed(event["data"]["object"]) return jsonify(received=True) def _notify_payment_failed(invoice): import stripe as _stripe_lib _stripe_lib.api_key = current_app.config["STRIPE_SECRET_KEY"] from app.models.payments import Subscription as S sub = S.query.filter_by( stripe_customer_id=invoice["customer"]).first() if sub and sub.user: send_email( sub.user.email, _("Payment failed — action required"), _( "Hi %(name)s,\n\nYour payment for Classifieds failed. " "Please update your payment method to keep your subscription:\n" "%(url)s\n\nIf you need help, reply to this email.", name=sub.user.display_name, url=url_for("payments.portal", _external=True, _scheme="https"), ), )