210 lines
8.8 KiB
Python
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"),
|
|
),
|
|
)
|