Files
classifieds/app/blueprints/payments/routes.py
T
2026-06-15 17:43:47 -04:00

210 lines
8.8 KiB
Python

"""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/<slug>")
@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/<int:listing_id>/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/<int:listing_id>/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"),
),
)