06/15 Phase 4 + 5 codes
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""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"),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user