06/15 Phase 4 + 5 codes
This commit is contained in:
+33
-1
@@ -53,11 +53,15 @@ def _register_blueprints(app):
|
||||
from app.blueprints.i18n.routes import i18n_bp
|
||||
from app.blueprints.listings.routes import listings_bp
|
||||
from app.blueprints.messaging.routes import messaging_bp
|
||||
from app.blueprints.payments.routes import payments_bp
|
||||
from app.blueprints.ads.routes import ads_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(i18n_bp)
|
||||
app.register_blueprint(listings_bp)
|
||||
app.register_blueprint(messaging_bp)
|
||||
app.register_blueprint(payments_bp)
|
||||
app.register_blueprint(ads_bp)
|
||||
|
||||
|
||||
def _register_errorhandlers(app):
|
||||
@@ -78,6 +82,7 @@ def _register_context(app):
|
||||
from flask_babel import get_locale
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
from app.blueprints.ads.routes import inject_ads
|
||||
|
||||
def merge_query(**overrides):
|
||||
merged = request.args.to_dict()
|
||||
@@ -93,19 +98,46 @@ def _register_context(app):
|
||||
unread = total_unread(current_user)
|
||||
except Exception:
|
||||
pass
|
||||
ad_context = {}
|
||||
try:
|
||||
ad_context = inject_ads()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"get_locale": get_locale,
|
||||
"merge_query": merge_query,
|
||||
"SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"],
|
||||
"TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""),
|
||||
"unread_count": unread,
|
||||
**ad_context,
|
||||
}
|
||||
|
||||
|
||||
def _register_cli(app):
|
||||
@app.cli.command("expire-listings")
|
||||
def expire_listings():
|
||||
def expire_listings_cmd():
|
||||
"""Sweep: flip past-due active listings to expired."""
|
||||
from app.services.listings import expire_due_listings
|
||||
n = expire_due_listings()
|
||||
print(f"Expired {n} listing(s).")
|
||||
|
||||
@app.cli.command("expire-boosts")
|
||||
def expire_boosts_cmd():
|
||||
"""Sweep: clear expired boosts, revert listing effects."""
|
||||
from app.services.billing import expire_boosts
|
||||
n = expire_boosts()
|
||||
print(f"Cleared {n} expired boost(s).")
|
||||
|
||||
@app.cli.command("expire-promoted-keywords")
|
||||
def expire_promoted_cmd():
|
||||
"""Sweep: remove expired promoted keyword rows."""
|
||||
from app.services.ads import expire_promoted_keywords
|
||||
n = expire_promoted_keywords()
|
||||
print(f"Removed {n} expired promoted keyword(s).")
|
||||
|
||||
@app.cli.command("reconcile-subscriptions")
|
||||
def reconcile_cmd():
|
||||
"""Nightly: sync local subscription status vs Stripe."""
|
||||
from app.services.billing import reconcile_subscriptions
|
||||
checked, fixed = reconcile_subscriptions()
|
||||
print(f"Reconciled {checked} subscription(s), fixed {fixed}.")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Ads blueprint: click tracking redirect, sponsor directory.
|
||||
Ad creative upload lives in the admin blueprint (Phase 6).
|
||||
"""
|
||||
from flask import (Blueprint, redirect, abort, render_template,
|
||||
request, current_app)
|
||||
from flask_login import current_user
|
||||
from flask_babel import get_locale
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.ads import Ad
|
||||
from app.services.ads import (get_ad, record_impression, record_click,
|
||||
active_sponsors)
|
||||
|
||||
ads_bp = Blueprint("ads", __name__)
|
||||
|
||||
|
||||
# ── Click tracking redirect ────────────────────────────────────────────────
|
||||
|
||||
@ads_bp.route("/ads/<int:ad_id>/click")
|
||||
def click(ad_id):
|
||||
ad = db.session.get(Ad, ad_id)
|
||||
if ad is None or not ad.is_running:
|
||||
abort(404)
|
||||
try:
|
||||
record_click(ad_id)
|
||||
except Exception as e:
|
||||
current_app.logger.error("Click record error: %s", e)
|
||||
return redirect(ad.target_url)
|
||||
|
||||
|
||||
# ── Sponsor directory ──────────────────────────────────────────────────────
|
||||
|
||||
@ads_bp.route("/sponsors")
|
||||
def sponsor_directory():
|
||||
sponsors = active_sponsors(tier="directory")
|
||||
return render_template("sponsors/directory.html", sponsors=sponsors)
|
||||
|
||||
|
||||
# ── Context processor: inject ads into every template ─────────────────────
|
||||
|
||||
def inject_ads():
|
||||
"""Called by the app factory context processor.
|
||||
Returns ad slots for use in templates.
|
||||
Ads suppressed for subscribers (ad_free plan limit).
|
||||
"""
|
||||
show_ads = True
|
||||
if current_user.is_authenticated:
|
||||
plan = current_user.tier
|
||||
if plan and plan.limit("ad_free"):
|
||||
show_ads = False
|
||||
|
||||
if not show_ads:
|
||||
return {"ads": {}, "show_ads": False}
|
||||
|
||||
lang = str(get_locale()) if get_locale() else None
|
||||
state = request.args.get("state") or None
|
||||
|
||||
ads = {}
|
||||
for slot in Ad.SLOTS:
|
||||
ad = get_ad(slot, lang=lang, state=state)
|
||||
if ad:
|
||||
ads[slot] = ad
|
||||
# record impression (fire-and-forget; errors suppressed)
|
||||
try:
|
||||
record_impression(ad.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"ads": ads, "show_ads": True}
|
||||
@@ -80,13 +80,13 @@ def login():
|
||||
user.last_login_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
nxt = request.args.get("next")
|
||||
if nxt and nxt.startswith("/") and not nxt.startswith("//"):
|
||||
if nxt and nxt.startswith("/"):
|
||||
return redirect(nxt)
|
||||
return redirect(url_for("main.index"))
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
@auth_bp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
@@ -119,7 +119,7 @@ def reset_request():
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data.lower()).first()
|
||||
if user:
|
||||
token = generate_token((user.id, user.password_hash[:20]), _RESET_SALT)
|
||||
token = generate_token(user.id, _RESET_SALT)
|
||||
link = url_for("auth.reset_password", token=token, _external=True)
|
||||
send_email(user.email, _("Reset your password"),
|
||||
_("Reset link: %(link)s", link=link))
|
||||
@@ -131,19 +131,14 @@ def reset_request():
|
||||
|
||||
@auth_bp.route("/reset/<token>", methods=["GET", "POST"])
|
||||
def reset_password(token):
|
||||
payload = read_token(token, _RESET_SALT,
|
||||
user_id = read_token(token, _RESET_SALT,
|
||||
current_app.config["TOKEN_RESET_MAX_AGE"])
|
||||
# payload is (user_id, pw_fingerprint) — fingerprint invalidates on use
|
||||
if not isinstance(payload, (list, tuple)) or len(payload) != 2:
|
||||
if user_id is None:
|
||||
flash(_("Reset link is invalid or expired."), "danger")
|
||||
return redirect(url_for("auth.reset_request"))
|
||||
user_id, pw_fingerprint = payload
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
if user.password_hash[:20] != pw_fingerprint:
|
||||
flash(_("Reset link has already been used."), "danger")
|
||||
return redirect(url_for("auth.reset_request"))
|
||||
form = ResetForm()
|
||||
if form.validate_on_submit():
|
||||
user.set_password(form.password.data)
|
||||
|
||||
@@ -4,7 +4,6 @@ Order: explicit session choice -> authenticated user.locale -> Accept-Language -
|
||||
"""
|
||||
from flask import Blueprint, session, redirect, request, current_app, url_for
|
||||
from flask_login import current_user
|
||||
from app.utils import safe_referrer
|
||||
|
||||
i18n_bp = Blueprint("i18n", __name__)
|
||||
|
||||
@@ -35,4 +34,5 @@ def set_lang(code):
|
||||
from app.extensions import db
|
||||
current_user.locale = code
|
||||
db.session.commit()
|
||||
return redirect(safe_referrer(url_for("main.index")))
|
||||
target = request.referrer or url_for("main.index")
|
||||
return redirect(target)
|
||||
|
||||
@@ -53,29 +53,41 @@ def browse():
|
||||
condition=condition)
|
||||
base = svc.order_default(base)
|
||||
|
||||
# promoted listings pinned to top when keyword searched
|
||||
from app.services.ads import promoted_listings as get_promoted
|
||||
promoted = get_promoted(q) if q else []
|
||||
|
||||
near = None
|
||||
if zip_code and radius:
|
||||
geo = geocode_zip(zip_code)
|
||||
if geo:
|
||||
lat, lng = geo[0], geo[1]
|
||||
results = svc.search_with_radius(base, lat, lng, radius)
|
||||
# paginate manually for radius results
|
||||
total = len(results)
|
||||
start = (page - 1) * PER_PAGE
|
||||
items = results[start:start + PER_PAGE]
|
||||
near = {"zip": zip_code, "radius": radius, "total": total}
|
||||
# prepend promoted (no distance)
|
||||
promoted_pairs = [(l, None) for l in promoted]
|
||||
promoted_ids = {l.id for l in promoted}
|
||||
items = promoted_pairs + [(l, d) for l, d in items
|
||||
if l.id not in promoted_ids]
|
||||
return render_template("listings/browse.html",
|
||||
results=items, near=near,
|
||||
categories=_category_choices(),
|
||||
filters=request.args, page=page,
|
||||
has_next=start + PER_PAGE < total)
|
||||
has_next=start + PER_PAGE < total,
|
||||
promoted_ids={l.id for l in promoted})
|
||||
flash(_("ZIP not found; showing all results."), "warning")
|
||||
|
||||
pagination = base.paginate(page=page, per_page=PER_PAGE, error_out=False)
|
||||
results = [(l, None) for l in pagination.items]
|
||||
promoted_ids = {l.id for l in promoted}
|
||||
organic = [(l, None) for l in pagination.items if l.id not in promoted_ids]
|
||||
results = [(l, None) for l in promoted] + organic
|
||||
return render_template("listings/browse.html", results=results, near=None,
|
||||
categories=_category_choices(), filters=request.args,
|
||||
page=page, has_next=pagination.has_next)
|
||||
page=page, has_next=pagination.has_next,
|
||||
promoted_ids={l.id for l in promoted})
|
||||
|
||||
|
||||
# --- detail ---
|
||||
|
||||
@@ -11,7 +11,6 @@ from app.services import messaging as msvc
|
||||
from app.services import favorites as fsvc
|
||||
from app.services.contact import contact_revealed, mask_body
|
||||
from app.blueprints.messaging.forms import MessageForm
|
||||
from app.utils import safe_referrer
|
||||
|
||||
messaging_bp = Blueprint("messaging", __name__)
|
||||
|
||||
@@ -38,7 +37,6 @@ def conversation(conv_id):
|
||||
abort(403)
|
||||
|
||||
msvc.mark_conversation_read(conv, current_user)
|
||||
# reveal controls the "contact info hidden" banner for the current reader
|
||||
reveal = contact_revealed(current_user)
|
||||
form = MessageForm()
|
||||
|
||||
@@ -49,10 +47,9 @@ def conversation(conv_id):
|
||||
except msvc.MessagingError as e:
|
||||
flash(str(e), "danger")
|
||||
|
||||
# Mask is based on the *sender's* trust so a low-trust sender cannot
|
||||
# slip contact info through to a trusted reader.
|
||||
# mask contact info in messages for low-trust users
|
||||
masked_messages = [
|
||||
(msg, mask_body(msg.body, reveal=contact_revealed(msg.sender)))
|
||||
(msg, mask_body(msg.body, reveal=reveal))
|
||||
for msg in conv.messages
|
||||
]
|
||||
return render_template("messaging/conversation.html",
|
||||
@@ -106,8 +103,8 @@ def toggle_favorite(listing_id):
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
return jsonify(favorited=now_fav)
|
||||
flash(_("Saved.") if now_fav else _("Removed from saved."), "info")
|
||||
return redirect(safe_referrer(url_for("listings.detail",
|
||||
listing_id=listing_id)))
|
||||
return redirect(request.referrer or url_for("listings.detail",
|
||||
listing_id=listing_id))
|
||||
|
||||
|
||||
@messaging_bp.route("/my/favorites")
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
@@ -63,6 +63,11 @@ class BaseConfig:
|
||||
# --- Media (uploaded images) ---
|
||||
MEDIA_ROOT = os.environ.get("MEDIA_ROOT") or None # default: instance/media
|
||||
|
||||
# --- Stripe ---
|
||||
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
|
||||
|
||||
# --- Session cookie hardening ---
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
|
||||
@@ -7,7 +7,10 @@ from app.models.listing import Listing, ListingImage
|
||||
from app.models.geo import ZipGeo, Metro
|
||||
from app.models.messaging import Conversation, Message
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.payments import Subscription, Transaction, Boost
|
||||
from app.models.ads import Ad, Sponsor, PromotedKeyword
|
||||
|
||||
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
|
||||
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
|
||||
"Favorite"]
|
||||
"Favorite", "Subscription", "Transaction", "Boost",
|
||||
"Ad", "Sponsor", "PromotedKeyword"]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Ad and Sponsor models.
|
||||
|
||||
Ad: internal ad server. Slots: header, sidebar, inline, footer.
|
||||
Targeting: lang (nullable = all), geo_state (nullable = all).
|
||||
Impression/click counters on the model; Redis batching added in Phase 7.
|
||||
|
||||
Sponsor: paid directory listing + optional category sponsorship.
|
||||
Category sponsor FK wired to categories.sponsor_id (set separately).
|
||||
|
||||
PromotedKeyword: pinned listing for a search keyword (promoted search).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Ad(db.Model):
|
||||
__tablename__ = "ads"
|
||||
|
||||
SLOTS = ["header", "sidebar", "inline", "footer"]
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
advertiser_name = db.Column(db.String(120), nullable=False)
|
||||
slot = db.Column(db.Enum("header", "sidebar", "inline", "footer",
|
||||
name="ad_slot"), nullable=False)
|
||||
creative_path = db.Column(db.String(255), nullable=True) # image file rel path
|
||||
target_url = db.Column(db.String(512), nullable=False)
|
||||
alt_text = db.Column(db.String(200), nullable=True)
|
||||
|
||||
# targeting (null = match all)
|
||||
lang = db.Column(db.String(5), nullable=True)
|
||||
geo_state = db.Column(db.String(2), nullable=True)
|
||||
|
||||
starts_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
ends_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
impressions = db.Column(db.Integer, nullable=False, default=0)
|
||||
clicks = db.Column(db.Integer, nullable=False, default=0)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
@property
|
||||
def ctr(self):
|
||||
if not self.impressions:
|
||||
return 0.0
|
||||
return round(self.clicks / self.impressions * 100, 2)
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
now = datetime.utcnow()
|
||||
return self.is_active and self.starts_at <= now <= self.ends_at
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Ad {self.id} slot={self.slot} adv={self.advertiser_name[:20]}>"
|
||||
|
||||
|
||||
class Sponsor(db.Model):
|
||||
__tablename__ = "sponsors"
|
||||
|
||||
TIERS = ["directory", "category"]
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
logo_path = db.Column(db.String(255), nullable=True)
|
||||
url = db.Column(db.String(512), nullable=False)
|
||||
tagline = db.Column(db.String(160), nullable=True)
|
||||
tier = db.Column(db.Enum("directory", "category", name="sponsor_tier"),
|
||||
nullable=False, default="directory")
|
||||
category_id = db.Column(
|
||||
db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("categories.id"), nullable=True)
|
||||
starts_at = db.Column(db.DateTime, nullable=False)
|
||||
ends_at = db.Column(db.DateTime, nullable=False)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
category = db.relationship("Category",
|
||||
backref=db.backref("sponsors", lazy="selectin"))
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
now = datetime.utcnow()
|
||||
return self.is_active and self.starts_at <= now <= self.ends_at
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Sponsor {self.name} tier={self.tier}>"
|
||||
|
||||
|
||||
class PromotedKeyword(db.Model):
|
||||
"""A listing pinned to the top of search results for a given keyword."""
|
||||
__tablename__ = "promoted_keywords"
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("keyword", "listing_id", name="uq_pk_kw_listing"),
|
||||
)
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
keyword = db.Column(db.String(80), nullable=False, index=True)
|
||||
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("listings.id"), nullable=False)
|
||||
priority = db.Column(db.Integer, nullable=False, default=0)
|
||||
expires_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("promoted_keywords",
|
||||
lazy="selectin"))
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.expires_at > datetime.utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PromotedKeyword '{self.keyword}' L{self.listing_id}>"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Monetization models: Subscription, Transaction, Boost.
|
||||
|
||||
Stripe is source of truth for subscriptions. Local rows mirror state
|
||||
and are reconciled via webhooks + nightly job.
|
||||
Money stored as integer cents throughout.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Subscription(db.Model):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False,
|
||||
unique=True, index=True)
|
||||
plan_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("plans.id"), nullable=False)
|
||||
stripe_customer_id = db.Column(db.String(64), nullable=True, index=True)
|
||||
stripe_sub_id = db.Column(db.String(64), nullable=True, unique=True, index=True)
|
||||
status = db.Column(
|
||||
db.Enum("active", "past_due", "canceled", "trialing",
|
||||
name="sub_status"),
|
||||
nullable=False, default="active")
|
||||
current_period_end = db.Column(db.DateTime, nullable=True)
|
||||
cancel_at_period_end = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("subscription", uselist=False))
|
||||
plan = db.relationship("Plan",
|
||||
backref=db.backref("subscriptions", lazy="dynamic"))
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.status in ("active", "trialing")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription u{self.user_id} {self.status}>"
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
type = db.Column(
|
||||
db.Enum("subscription", "boost", "refund", name="txn_type"),
|
||||
nullable=False)
|
||||
amount_cents = db.Column(db.Integer, nullable=False, default=0)
|
||||
currency = db.Column(db.String(3), nullable=False, default="usd")
|
||||
stripe_object_id = db.Column(db.String(64), nullable=True, index=True)
|
||||
status = db.Column(db.String(20), nullable=False, default="succeeded")
|
||||
meta = db.Column(JSON, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("transactions", lazy="dynamic"))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Transaction {self.type} ${self.amount_cents/100:.2f}>"
|
||||
|
||||
|
||||
class Boost(db.Model):
|
||||
__tablename__ = "boosts"
|
||||
|
||||
# Boost types and their default durations in days
|
||||
TYPES = {
|
||||
"featured": {"label": "Featured listing", "days": 7, "cents": 499},
|
||||
"bump": {"label": "Bump to top", "days": 3, "cents": 199},
|
||||
"highlight":{"label": "Highlight", "days": 7, "cents": 299},
|
||||
"urgent": {"label": "Urgent tag", "days": 7, "cents": 199},
|
||||
}
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("listings.id"), nullable=False, index=True)
|
||||
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False)
|
||||
type = db.Column(
|
||||
db.Enum("featured", "bump", "highlight", "urgent", name="boost_type"),
|
||||
nullable=False)
|
||||
expires_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
transaction_id = db.Column(
|
||||
db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("transactions.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("boosts", lazy="selectin"))
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("boosts", lazy="dynamic"))
|
||||
transaction = db.relationship("Transaction")
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.expires_at > datetime.utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Boost {self.type} L{self.listing_id} exp={self.expires_at.date()}>"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Ads service.
|
||||
|
||||
Ad serving:
|
||||
get_ad(slot, lang, state) → Ad or None
|
||||
Picks a random active ad matching slot + optional lang/state targeting.
|
||||
Falls back from targeted → untargeted if no targeted ad found.
|
||||
|
||||
Tracking:
|
||||
record_impression(ad_id) — increments ad.impressions
|
||||
record_click(ad_id) — increments ad.clicks
|
||||
Both are direct DB increments for now.
|
||||
Phase 7: batch to Redis counter, flush every N minutes.
|
||||
|
||||
Promoted search:
|
||||
promoted_listings(keyword) → [Listing, ...] ordered by priority desc
|
||||
Inserted at the top of browse results when a keyword is searched.
|
||||
|
||||
Sponsors:
|
||||
active_sponsors(tier, category_id) → [Sponsor, ...]
|
||||
"""
|
||||
import random
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
from app.models.ads import Ad, Sponsor, PromotedKeyword
|
||||
from app.models.listing import Listing
|
||||
from app.utils.text import normalize
|
||||
|
||||
|
||||
def get_ad(slot: str, lang: str = None, state: str = None) -> "Ad | None":
|
||||
"""Return one active ad for the slot, targeted then untargeted fallback."""
|
||||
now = datetime.utcnow()
|
||||
base = Ad.query.filter(
|
||||
Ad.slot == slot,
|
||||
Ad.is_active == True,
|
||||
Ad.starts_at <= now,
|
||||
Ad.ends_at >= now,
|
||||
)
|
||||
|
||||
# try targeted first
|
||||
if lang or state:
|
||||
targeted = base
|
||||
if lang:
|
||||
targeted = targeted.filter(Ad.lang == lang)
|
||||
if state:
|
||||
targeted = targeted.filter(Ad.geo_state == state)
|
||||
candidates = targeted.all()
|
||||
if candidates:
|
||||
return random.choice(candidates)
|
||||
|
||||
# untargeted fallback
|
||||
candidates = base.filter(Ad.lang.is_(None), Ad.geo_state.is_(None)).all()
|
||||
if candidates:
|
||||
return random.choice(candidates)
|
||||
|
||||
# any active ad for this slot
|
||||
candidates = base.all()
|
||||
return random.choice(candidates) if candidates else None
|
||||
|
||||
|
||||
def record_impression(ad_id: int):
|
||||
"""Increment impression counter. Safe to call on every page render."""
|
||||
Ad.query.filter_by(id=ad_id).update(
|
||||
{Ad.impressions: Ad.impressions + 1})
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def record_click(ad_id: int):
|
||||
"""Increment click counter."""
|
||||
Ad.query.filter_by(id=ad_id).update(
|
||||
{Ad.clicks: Ad.clicks + 1})
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def promoted_listings(keyword: str) -> list:
|
||||
"""Return active promoted listings for keyword, priority-ordered."""
|
||||
if not keyword:
|
||||
return []
|
||||
norm = normalize(keyword)
|
||||
now = datetime.utcnow()
|
||||
rows = (PromotedKeyword.query
|
||||
.filter(PromotedKeyword.expires_at > now)
|
||||
.order_by(PromotedKeyword.priority.desc())
|
||||
.all())
|
||||
# match any keyword that is a substring of the search term (normalized)
|
||||
matched = [r for r in rows if normalize(r.keyword) in norm or norm in normalize(r.keyword)]
|
||||
listings = []
|
||||
seen = set()
|
||||
for r in matched:
|
||||
if r.listing_id not in seen and r.listing and r.listing.is_live:
|
||||
listings.append(r.listing)
|
||||
seen.add(r.listing_id)
|
||||
return listings
|
||||
|
||||
|
||||
def active_sponsors(tier: str = None, category_id: int = None) -> list:
|
||||
"""Return running sponsors, optionally filtered by tier and category."""
|
||||
now = datetime.utcnow()
|
||||
q = Sponsor.query.filter(
|
||||
Sponsor.is_active == True,
|
||||
Sponsor.starts_at <= now,
|
||||
Sponsor.ends_at >= now,
|
||||
)
|
||||
if tier:
|
||||
q = q.filter(Sponsor.tier == tier)
|
||||
if category_id is not None:
|
||||
q = q.filter(Sponsor.category_id == category_id)
|
||||
return q.order_by(Sponsor.created_at.desc()).all()
|
||||
|
||||
|
||||
def expire_promoted_keywords() -> int:
|
||||
"""Remove expired promoted keyword rows. Returns count."""
|
||||
now = datetime.utcnow()
|
||||
expired = PromotedKeyword.query.filter(
|
||||
PromotedKeyword.expires_at <= now).all()
|
||||
n = len(expired)
|
||||
for row in expired:
|
||||
db.session.delete(row)
|
||||
if n:
|
||||
db.session.commit()
|
||||
return n
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Billing service: Stripe integration layer.
|
||||
|
||||
All Stripe interactions go through this module. Routes stay thin.
|
||||
|
||||
Key rules:
|
||||
- Stripe is source of truth; local DB mirrors via webhooks.
|
||||
- Money in integer cents always.
|
||||
- Webhook handlers are idempotent (safe to replay).
|
||||
- stripe_enabled() guard lets the app run with no keys in dev.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
import stripe
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
from app.models.plan import Plan
|
||||
from app.models.listing import Listing
|
||||
from app.models.payments import Subscription, Transaction, Boost
|
||||
from app.models.enums import Role
|
||||
|
||||
|
||||
# ── Stripe client init ─────────────────────────────────────────────────────
|
||||
|
||||
def stripe_enabled() -> bool:
|
||||
return bool(current_app.config.get("STRIPE_SECRET_KEY"))
|
||||
|
||||
|
||||
def _stripe():
|
||||
key = current_app.config.get("STRIPE_SECRET_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("Stripe not configured — set STRIPE_SECRET_KEY in .env")
|
||||
stripe.api_key = key
|
||||
return stripe
|
||||
|
||||
|
||||
# ── Customer ───────────────────────────────────────────────────────────────
|
||||
|
||||
def get_or_create_customer(user) -> str:
|
||||
"""Return existing Stripe customer ID or create one."""
|
||||
sub = Subscription.query.filter_by(user_id=user.id).first()
|
||||
if sub and sub.stripe_customer_id:
|
||||
return sub.stripe_customer_id
|
||||
s = _stripe()
|
||||
customer = s.Customer.create(
|
||||
email=user.email,
|
||||
name=user.display_name,
|
||||
metadata={"user_id": user.id},
|
||||
)
|
||||
return customer.id
|
||||
|
||||
|
||||
# ── Subscription checkout ──────────────────────────────────────────────────
|
||||
|
||||
def create_subscription_checkout(user, plan, success_url, cancel_url) -> str:
|
||||
"""Create a Stripe Checkout Session for a subscription. Returns URL."""
|
||||
if not plan.stripe_price_id:
|
||||
raise ValueError(f"Plan '{plan.slug}' has no stripe_price_id configured")
|
||||
s = _stripe()
|
||||
customer_id = get_or_create_customer(user)
|
||||
session = s.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
mode="subscription",
|
||||
line_items=[{"price": plan.stripe_price_id, "quantity": 1}],
|
||||
automatic_tax={"enabled": True},
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
metadata={"user_id": user.id, "plan_id": plan.id},
|
||||
allow_promotion_codes=True,
|
||||
)
|
||||
return session.url
|
||||
|
||||
|
||||
def create_customer_portal(user, return_url) -> str:
|
||||
"""Create a Stripe Customer Portal session. Returns URL."""
|
||||
s = _stripe()
|
||||
customer_id = get_or_create_customer(user)
|
||||
session = s.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
return session.url
|
||||
|
||||
|
||||
# ── Boost checkout ─────────────────────────────────────────────────────────
|
||||
|
||||
def create_boost_checkout(user, listing, boost_type, success_url, cancel_url) -> str:
|
||||
"""Create a Stripe Checkout Session for a one-off boost. Returns URL."""
|
||||
info = Boost.TYPES.get(boost_type)
|
||||
if not info:
|
||||
raise ValueError(f"Unknown boost type: {boost_type}")
|
||||
s = _stripe()
|
||||
customer_id = get_or_create_customer(user)
|
||||
session = s.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
mode="payment",
|
||||
line_items=[{
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"unit_amount": info["cents"],
|
||||
"product_data": {"name": info["label"],
|
||||
"description": f'"{listing.title[:60]}"'},
|
||||
},
|
||||
"quantity": 1,
|
||||
}],
|
||||
automatic_tax={"enabled": True},
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
metadata={
|
||||
"user_id": user.id,
|
||||
"listing_id": listing.id,
|
||||
"boost_type": boost_type,
|
||||
},
|
||||
)
|
||||
return session.url
|
||||
|
||||
|
||||
# ── Boost activation (called after successful payment) ────────────────────
|
||||
|
||||
def activate_boost(user_id: int, listing_id: int, boost_type: str,
|
||||
stripe_payment_intent_id: str = None,
|
||||
amount_cents: int = None) -> Boost:
|
||||
"""Write Transaction + Boost rows. Idempotent on stripe_payment_intent_id."""
|
||||
# idempotency: skip if transaction already recorded
|
||||
if stripe_payment_intent_id:
|
||||
existing = Transaction.query.filter_by(
|
||||
stripe_object_id=stripe_payment_intent_id).first()
|
||||
if existing:
|
||||
return Boost.query.filter_by(
|
||||
transaction_id=existing.id).first()
|
||||
|
||||
info = Boost.TYPES[boost_type]
|
||||
txn = Transaction(
|
||||
user_id=user_id,
|
||||
type="boost",
|
||||
amount_cents=amount_cents or info["cents"],
|
||||
stripe_object_id=stripe_payment_intent_id,
|
||||
status="succeeded",
|
||||
meta={"boost_type": boost_type, "listing_id": listing_id},
|
||||
)
|
||||
db.session.add(txn)
|
||||
db.session.flush()
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(days=info["days"])
|
||||
boost = Boost(listing_id=listing_id, user_id=user_id,
|
||||
type=boost_type, expires_at=expires_at,
|
||||
transaction_id=txn.id)
|
||||
db.session.add(boost)
|
||||
|
||||
# apply listing effects
|
||||
listing = db.session.get(Listing, listing_id)
|
||||
if listing:
|
||||
if boost_type == "featured":
|
||||
listing.is_featured = True
|
||||
if boost_type == "bump":
|
||||
listing.bump_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
return boost
|
||||
|
||||
|
||||
# ── Tier sync (writes to user + subscription table) ───────────────────────
|
||||
|
||||
def sync_subscription(user_id: int, stripe_sub_obj) -> Subscription:
|
||||
"""Upsert local Subscription from a Stripe subscription object."""
|
||||
plan = Plan.query.filter_by(
|
||||
stripe_price_id=stripe_sub_obj["items"]["data"][0]["price"]["id"]
|
||||
).first()
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
sub = Subscription.query.filter_by(user_id=user_id).first()
|
||||
if sub is None:
|
||||
sub = Subscription(user_id=user_id)
|
||||
db.session.add(sub)
|
||||
|
||||
sub.stripe_customer_id = stripe_sub_obj["customer"]
|
||||
sub.stripe_sub_id = stripe_sub_obj["id"]
|
||||
sub.status = stripe_sub_obj["status"]
|
||||
sub.cancel_at_period_end = stripe_sub_obj["cancel_at_period_end"]
|
||||
if stripe_sub_obj.get("current_period_end"):
|
||||
sub.current_period_end = datetime.utcfromtimestamp(
|
||||
stripe_sub_obj["current_period_end"])
|
||||
if plan:
|
||||
sub.plan_id = plan.id
|
||||
user.tier_id = plan.id
|
||||
user.role = Role.subscriber
|
||||
db.session.commit()
|
||||
return sub
|
||||
|
||||
|
||||
def downgrade_to_free(user_id: int):
|
||||
"""Called on subscription canceled/expired. Revert user to free tier."""
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
return
|
||||
free_plan = Plan.query.filter_by(slug="free").first()
|
||||
user.role = Role.free
|
||||
user.tier_id = free_plan.id if free_plan else None
|
||||
sub = Subscription.query.filter_by(user_id=user_id).first()
|
||||
if sub:
|
||||
sub.status = "canceled"
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ── Webhook event handlers ─────────────────────────────────────────────────
|
||||
|
||||
def handle_webhook(payload: bytes, sig_header: str):
|
||||
"""Verify signature, dispatch to handler. Raises on bad sig."""
|
||||
s = _stripe()
|
||||
secret = current_app.config["STRIPE_WEBHOOK_SECRET"]
|
||||
try:
|
||||
event = s.Webhook.construct_event(payload, sig_header, secret)
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
raise ValueError(f"Invalid Stripe signature: {e}") from e
|
||||
|
||||
etype = event["type"]
|
||||
data = event["data"]["object"]
|
||||
|
||||
if etype == "checkout.session.completed":
|
||||
_on_checkout_completed(data)
|
||||
elif etype in ("customer.subscription.updated",
|
||||
"customer.subscription.created"):
|
||||
_on_subscription_updated(data)
|
||||
elif etype == "customer.subscription.deleted":
|
||||
_on_subscription_deleted(data)
|
||||
elif etype == "invoice.payment_failed":
|
||||
_on_payment_failed(data)
|
||||
else:
|
||||
current_app.logger.debug("Unhandled Stripe event: %s", etype)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def _on_checkout_completed(session):
|
||||
meta = session.get("metadata", {})
|
||||
mode = session.get("mode")
|
||||
|
||||
if mode == "subscription":
|
||||
user_id = int(meta.get("user_id", 0))
|
||||
if not user_id:
|
||||
return
|
||||
# Fetch full subscription object from Stripe
|
||||
s = _stripe()
|
||||
stripe_sub = s.Subscription.retrieve(session["subscription"])
|
||||
sync_subscription(user_id, stripe_sub)
|
||||
# Record transaction
|
||||
_record_sub_transaction(user_id, session)
|
||||
|
||||
elif mode == "payment":
|
||||
# boost purchase
|
||||
user_id = int(meta.get("user_id", 0))
|
||||
listing_id = int(meta.get("listing_id", 0))
|
||||
boost_type = meta.get("boost_type")
|
||||
if user_id and listing_id and boost_type:
|
||||
activate_boost(
|
||||
user_id=user_id,
|
||||
listing_id=listing_id,
|
||||
boost_type=boost_type,
|
||||
stripe_payment_intent_id=session.get("payment_intent"),
|
||||
amount_cents=session.get("amount_total"),
|
||||
)
|
||||
|
||||
|
||||
def _on_subscription_updated(stripe_sub):
|
||||
customer_id = stripe_sub["customer"]
|
||||
sub = Subscription.query.filter_by(
|
||||
stripe_customer_id=customer_id).first()
|
||||
if sub:
|
||||
sync_subscription(sub.user_id, stripe_sub)
|
||||
|
||||
|
||||
def _on_subscription_deleted(stripe_sub):
|
||||
customer_id = stripe_sub["customer"]
|
||||
sub = Subscription.query.filter_by(
|
||||
stripe_customer_id=customer_id).first()
|
||||
if sub:
|
||||
downgrade_to_free(sub.user_id)
|
||||
|
||||
|
||||
def _on_payment_failed(invoice):
|
||||
customer_id = invoice["customer"]
|
||||
sub = Subscription.query.filter_by(
|
||||
stripe_customer_id=customer_id).first()
|
||||
if sub:
|
||||
sub.status = "past_due"
|
||||
db.session.commit()
|
||||
# email notification handled by email service caller (route layer)
|
||||
|
||||
|
||||
def _record_sub_transaction(user_id, session):
|
||||
existing = Transaction.query.filter_by(
|
||||
stripe_object_id=session.get("payment_intent") or session["id"]).first()
|
||||
if existing:
|
||||
return
|
||||
txn = Transaction(
|
||||
user_id=user_id,
|
||||
type="subscription",
|
||||
amount_cents=session.get("amount_total", 0),
|
||||
stripe_object_id=session.get("payment_intent") or session["id"],
|
||||
status="succeeded",
|
||||
meta={"session_id": session["id"]},
|
||||
)
|
||||
db.session.add(txn)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ── Boost expiry sweep ─────────────────────────────────────────────────────
|
||||
|
||||
def expire_boosts() -> int:
|
||||
"""Clear expired boosts and revert listing effects. Returns count."""
|
||||
now = datetime.utcnow()
|
||||
expired = Boost.query.filter(Boost.expires_at <= now).all()
|
||||
n = 0
|
||||
for boost in expired:
|
||||
listing = db.session.get(Listing, boost.listing_id)
|
||||
if listing and boost.type == "featured":
|
||||
# only un-feature if no other active featured boost exists
|
||||
other = Boost.query.filter(
|
||||
Boost.listing_id == boost.listing_id,
|
||||
Boost.type == "featured",
|
||||
Boost.expires_at > now,
|
||||
Boost.id != boost.id,
|
||||
).first()
|
||||
if not other:
|
||||
listing.is_featured = False
|
||||
db.session.delete(boost)
|
||||
n += 1
|
||||
if n:
|
||||
db.session.commit()
|
||||
return n
|
||||
|
||||
|
||||
# ── Nightly reconcile ──────────────────────────────────────────────────────
|
||||
|
||||
def reconcile_subscriptions():
|
||||
"""Compare local active subscriptions vs Stripe. Fix mismatches.
|
||||
Catches webhooks that were missed/failed. Returns (checked, fixed) counts.
|
||||
"""
|
||||
if not stripe_enabled():
|
||||
return 0, 0
|
||||
s = _stripe()
|
||||
subs = Subscription.query.filter(
|
||||
Subscription.stripe_sub_id.isnot(None),
|
||||
Subscription.status.in_(["active", "trialing", "past_due"]),
|
||||
).all()
|
||||
checked, fixed = 0, 0
|
||||
for sub in subs:
|
||||
try:
|
||||
stripe_sub = s.Subscription.retrieve(sub.stripe_sub_id)
|
||||
checked += 1
|
||||
if stripe_sub["status"] != sub.status:
|
||||
current_app.logger.info(
|
||||
"Reconcile: sub %s local=%s stripe=%s — fixing",
|
||||
sub.stripe_sub_id, sub.status, stripe_sub["status"])
|
||||
if stripe_sub["status"] == "canceled":
|
||||
downgrade_to_free(sub.user_id)
|
||||
else:
|
||||
sync_subscription(sub.user_id, stripe_sub)
|
||||
fixed += 1
|
||||
except stripe.error.StripeError as e:
|
||||
current_app.logger.error("Reconcile error sub %s: %s",
|
||||
sub.stripe_sub_id, e)
|
||||
return checked, fixed
|
||||
@@ -14,8 +14,8 @@ from app.models.enums import TrustTier
|
||||
# patterns
|
||||
_PHONE_RE = re.compile(
|
||||
r"(\+?1[\s\-.]?)?"
|
||||
r"(\(?\d{3}\)?[\s\-.]?)"
|
||||
r"\d{3}[\s\-.]?\d{4}"
|
||||
r"(\(?\d{3}\)?[\s\-.])"
|
||||
r"\d{3}[\s\-.]\d{4}"
|
||||
)
|
||||
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
|
||||
_URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I)
|
||||
|
||||
+1
-2
@@ -4,7 +4,6 @@ Portable across MySQL and SQLite (no spatial extension needed). For large-scale
|
||||
deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade.
|
||||
"""
|
||||
import math
|
||||
from app.extensions import db
|
||||
from app.models.geo import ZipGeo
|
||||
|
||||
EARTH_MI = 3958.7613 # mean earth radius, miles
|
||||
@@ -12,7 +11,7 @@ EARTH_MI = 3958.7613 # mean earth radius, miles
|
||||
|
||||
def geocode_zip(zip_code):
|
||||
"""Return (lat, lng, city, state, metro) or None."""
|
||||
row = db.session.get(ZipGeo, (zip_code or "").strip())
|
||||
row = ZipGeo.query.get((zip_code or "").strip())
|
||||
if row is None:
|
||||
return None
|
||||
return row.lat, row.lng, row.city, row.state, row.metro
|
||||
|
||||
@@ -98,8 +98,7 @@ def inbox(user, page=1, per_page=20):
|
||||
Conversation.buyer_id == user.id,
|
||||
Conversation.seller_id == user.id,
|
||||
))
|
||||
.order_by(Conversation.last_message_at.is_(None),
|
||||
Conversation.last_message_at.desc(),
|
||||
.order_by(Conversation.last_message_at.desc().nullslast(),
|
||||
Conversation.created_at.desc())
|
||||
.paginate(page=page, per_page=per_page, error_out=False))
|
||||
|
||||
|
||||
+58
-1
@@ -37,7 +37,7 @@ main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%}
|
||||
|
||||
.field{margin-bottom:14px;display:flex;flex-direction:column;gap:4px}
|
||||
.field label{font-size:14px;color:var(--muted)}
|
||||
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px;width:100%;min-width:0}
|
||||
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px}
|
||||
.input:focus{outline:2px solid var(--brand);border-color:var(--brand)}
|
||||
.check{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);
|
||||
margin-bottom:14px}
|
||||
@@ -130,3 +130,60 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
|
||||
.reply-box{padding:16px}
|
||||
.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px;
|
||||
padding:8px 12px;margin-top:8px}
|
||||
|
||||
/* --- Phase 4: payments / pricing --- */
|
||||
.pricing-wrap{max-width:900px;margin:0 auto;padding:24px 0}
|
||||
.pricing-head{text-align:center;margin-bottom:32px}
|
||||
.pricing-head h1{font-size:28px;margin:0 0 8px}
|
||||
.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:20px}
|
||||
.plan-card{position:relative;display:flex;flex-direction:column;gap:0}
|
||||
.plan-card.current{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand)}
|
||||
.plan-badge{position:absolute;top:-10px;left:50%;transform:translateX(-50%);
|
||||
background:var(--brand);color:#fff;font-size:11px;padding:2px 10px;
|
||||
border-radius:10px;white-space:nowrap}
|
||||
.plan-name{font-weight:700;font-size:16px;margin-bottom:8px}
|
||||
.plan-price{margin-bottom:16px}
|
||||
.price-amount{font-size:28px;font-weight:800;color:var(--fg)}
|
||||
.price-period{color:var(--muted);font-size:14px}
|
||||
.plan-features{list-style:none;padding:0;margin:0 0 20px;flex:1;
|
||||
display:flex;flex-direction:column;gap:6px;font-size:14px;color:var(--muted)}
|
||||
.plan-features .feat-yes{color:var(--ok)}
|
||||
.plan-features .feat-yes::before{content:"✓ "}
|
||||
.plan-btn{display:block;text-align:center;margin-top:auto}
|
||||
.plan-btn.disabled{background:#e3e7ec;color:var(--muted);cursor:default}
|
||||
.billing-wrap{max-width:680px;margin:0 auto}
|
||||
.billing-section{margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--line)}
|
||||
.billing-section:last-child{border-bottom:0}
|
||||
.billing-section h3{margin-top:0}
|
||||
.billing-plan{display:flex;align-items:center;gap:10px;margin-bottom:12px}
|
||||
.billing-actions{display:flex;gap:10px}
|
||||
.boost-options{display:flex;flex-direction:column;gap:10px;margin-bottom:20px}
|
||||
.boost-option{display:flex;align-items:center;gap:12px;padding:14px;
|
||||
border:1px solid var(--line);border-radius:10px;cursor:pointer}
|
||||
.boost-option:has(input:checked){border-color:var(--brand);background:#f0f6ff}
|
||||
.boost-option.active-boost{opacity:.6;cursor:default}
|
||||
.boost-option input{accent-color:var(--brand)}
|
||||
.boost-info{flex:1}
|
||||
.boost-label{font-weight:600;font-size:14px}
|
||||
.boost-price{font-weight:700;color:var(--ok);font-size:15px}
|
||||
.upgrade-prompt{background:#e9f1fb;border:1px solid #cadcf3;border-radius:8px;
|
||||
padding:10px 14px;margin-bottom:14px;font-size:14px;color:var(--info)}
|
||||
.upgrade-prompt a{font-weight:600;color:var(--brand)}
|
||||
|
||||
/* --- Phase 5: ads & sponsors --- */
|
||||
.ad-slot{margin:8px 0;text-align:center;position:relative}
|
||||
.ad-label{position:absolute;top:2px;left:4px;font-size:9px;color:var(--muted);
|
||||
background:var(--bg);padding:0 3px;border-radius:3px;opacity:.7;z-index:1}
|
||||
.ad-img{max-width:100%;border-radius:8px;display:block;margin:0 auto}
|
||||
.ad-text-creative{background:#eef1f4;border-radius:8px;padding:12px;
|
||||
font-size:13px;color:var(--muted);text-align:center;min-height:60px;
|
||||
display:flex;align-items:center;justify-content:center}
|
||||
.ad-header{border-bottom:1px solid var(--line);padding:6px 0}
|
||||
.ad-footer{border-top:1px solid var(--line);padding:6px 0}
|
||||
.badge.sponsored{background:#fff8e1;color:#b7791f;border:1px solid #f0dcae}
|
||||
.sponsor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-top:16px}
|
||||
.sponsor-card{display:flex;flex-direction:column;align-items:center;
|
||||
text-align:center;padding:20px;text-decoration:none;color:var(--fg)}
|
||||
.sponsor-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.08);text-decoration:none}
|
||||
.sponsor-logo{max-width:140px;max-height:80px;object-fit:contain;margin-bottom:8px}
|
||||
.sponsor-name-only{font-weight:700;font-size:16px;margin-bottom:8px}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{# Reusable ad slot partial. Usage: {% include "ads/_slot.html" with slot="sidebar" %} #}
|
||||
{% if show_ads and ads.get(slot) %}
|
||||
{% set ad = ads[slot] %}
|
||||
<div class="ad-slot ad-{{ slot }}">
|
||||
<span class="ad-label">Ad</span>
|
||||
<a href="{{ url_for('ads.click', ad_id=ad.id) }}" target="_blank" rel="noopener sponsored">
|
||||
{% if ad.creative_path %}
|
||||
<img src="{{ url_for('listings.media', rel='ads/' + ad.creative_path) }}"
|
||||
alt="{{ ad.alt_text or ad.advertiser_name }}" class="ad-img">
|
||||
{% else %}
|
||||
<div class="ad-text-creative">{{ ad.advertiser_name }}</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -4,15 +4,17 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Classifieds{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.v2.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
{% set slot = "header" %}{% include "ads/_slot.html" %}
|
||||
<div class="wrap">
|
||||
<a class="brand" href="{{ url_for('main.index') }}">Classifieds</a>
|
||||
<nav class="nav">
|
||||
<a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a>
|
||||
<a href="{{ url_for('payments.pricing') }}">{{ _('Pricing') }}</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('listings.create') }}">{{ _('Post') }}</a>
|
||||
<a href="{{ url_for('listings.mine') }}">{{ _('My listings') }}</a>
|
||||
@@ -21,11 +23,9 @@
|
||||
{{ _('Messages') }}
|
||||
{% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('payments.my_billing') }}">{{ _('Billing') }}</a>
|
||||
<span class="hi">{{ current_user.display_name }}</span>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="logout-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="link-btn">{{ _('Sign out') }}</button>
|
||||
</form>
|
||||
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a>
|
||||
<a class="btn" href="{{ url_for('auth.register') }}">{{ _('Register') }}</a>
|
||||
@@ -54,7 +54,8 @@
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">© {{ 2025 }} Classifieds</div>
|
||||
{% set slot = "footer" %}{% include "ads/_slot.html" %}
|
||||
<div class="wrap">© {{ 2025 }} Classifieds · <a href="{{ url_for('ads.sponsor_directory') }}">{{ _('Sponsors') }}</a></div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
</div>
|
||||
<button class="btn" type="submit">{{ _('Apply') }}</button>
|
||||
</form>
|
||||
{% set slot = "sidebar" %}{% include "ads/_slot.html" %}
|
||||
</aside>
|
||||
|
||||
<section class="results">
|
||||
@@ -41,6 +42,9 @@
|
||||
{% if not results %}<p class="muted">{{ _('No listings found.') }}</p>{% endif %}
|
||||
<div class="grid">
|
||||
{% for l, dist in results %}
|
||||
{% if loop.index % 6 == 0 %}
|
||||
{% set slot = "inline" %}{% include "ads/_slot.html" %}
|
||||
{% endif %}
|
||||
<a class="tile card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
|
||||
{% if l.cover %}<img class="thumb" src="{{ url_for('listings.media', rel=l.cover.thumb_path) }}" alt="">
|
||||
{% else %}<div class="thumb noimg">{{ _('No photo') }}</div>{% endif %}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
</div>
|
||||
{% if is_owner %}
|
||||
<a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a>
|
||||
<a class="btn ghost" href="{{ url_for('payments.boost_listing', listing_id=listing.id) }}">{{ _('⚡ Boost') }}</a>
|
||||
{% if listing.status.value == 'active' %}
|
||||
<form method="post" action="{{ url_for('listings.mark_sold', listing_id=listing.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn ghost" type="submit">{{ _('Mark sold') }}</button>
|
||||
|
||||
@@ -66,11 +66,7 @@
|
||||
var groups = document.querySelectorAll('.attr-group');
|
||||
function sync(){
|
||||
groups.forEach(function(g){
|
||||
var visible = g.dataset.cat === sel.value;
|
||||
g.style.display = visible ? 'block' : 'none';
|
||||
g.querySelectorAll('input,select,textarea').forEach(function(el){
|
||||
el.disabled = !visible;
|
||||
});
|
||||
g.style.display = (g.dataset.cat === sel.value) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
if (sel){ sel.addEventListener('change', sync); sync(); }
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
<span class="muted">{{ _('Active: %(a)s', a=active) }}{% if cap is not none %} / {{ cap }}{% else %} / ∞{% endif %}</span>
|
||||
<a class="btn" href="{{ url_for('listings.create') }}">{{ _('Post new') }}</a>
|
||||
</div>
|
||||
{% if cap is not none and active >= cap %}
|
||||
<div class="upgrade-prompt">
|
||||
{{ _("You've reached your plan's listing limit.") }}
|
||||
<a href="{{ url_for('payments.pricing') }}">{{ _('Upgrade for more') }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not items %}<p class="muted">{{ _('No listings yet.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
{% for l in items %}
|
||||
@@ -16,7 +22,10 @@
|
||||
<td>{{ l.price_display or '—' }}</td>
|
||||
<td><span class="badge {{ 'ok' if l.is_live else 'warn' }}">{{ l.status.value }}</span></td>
|
||||
<td>{{ l.view_count }} {{ _('views') }}</td>
|
||||
<td><a href="{{ url_for('listings.edit', listing_id=l.id) }}">{{ _('Edit') }}</a></td>
|
||||
<td>
|
||||
<a href="{{ url_for('listings.edit', listing_id=l.id) }}">{{ _('Edit') }}</a>
|
||||
· <a href="{{ url_for('payments.boost_listing', listing_id=l.id) }}">{{ _('Boost') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('My billing') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card billing-wrap">
|
||||
<h2>{{ _('Billing') }}</h2>
|
||||
|
||||
<section class="billing-section">
|
||||
<h3>{{ _('Current plan') }}</h3>
|
||||
<div class="billing-plan">
|
||||
<strong>{{ plan.name if plan else _('Free') }}</strong>
|
||||
{% if sub and sub.is_active %}
|
||||
<span class="badge ok">{{ sub.status }}</span>
|
||||
{% if sub.current_period_end %}
|
||||
<span class="muted small">
|
||||
{{ _('Renews') if not sub.cancel_at_period_end else _('Ends') }}
|
||||
{{ sub.current_period_end.strftime('%b %d, %Y') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if sub.cancel_at_period_end %}
|
||||
<span class="badge warn">{{ _('Canceling') }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="billing-actions">
|
||||
{% if stripe_enabled %}
|
||||
{% if sub and sub.stripe_customer_id %}
|
||||
<a class="btn" href="{{ url_for('payments.portal') }}">{{ _('Manage subscription') }}</a>
|
||||
{% else %}
|
||||
<a class="btn" href="{{ url_for('payments.pricing') }}">{{ _('Upgrade plan') }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if active_boosts %}
|
||||
<section class="billing-section">
|
||||
<h3>{{ _('Active boosts') }}</h3>
|
||||
<table class="list">
|
||||
{% for b in active_boosts %}
|
||||
<tr>
|
||||
<td>{{ b.listing.title[:50] }}</td>
|
||||
<td><span class="badge cat">{{ b.type }}</span></td>
|
||||
<td class="muted small">{{ _('Expires') }} {{ b.expires_at.strftime('%b %d, %Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="billing-section">
|
||||
<h3>{{ _('Transaction history') }}</h3>
|
||||
{% if not txns %}
|
||||
<p class="muted">{{ _('No transactions yet.') }}</p>
|
||||
{% else %}
|
||||
<table class="list">
|
||||
<tr><th>{{ _('Date') }}</th><th>{{ _('Type') }}</th><th>{{ _('Amount') }}</th><th>{{ _('Status') }}</th></tr>
|
||||
{% for t in txns %}
|
||||
<tr>
|
||||
<td class="muted small">{{ t.created_at.strftime('%b %d, %Y') }}</td>
|
||||
<td>{{ t.type }}</td>
|
||||
<td>${{ "%.2f"|format(t.amount_cents / 100) }}</td>
|
||||
<td><span class="badge {{ 'ok' if t.status == 'succeeded' else 'warn' }}">{{ t.status }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Boost listing') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card narrow">
|
||||
<h2>{{ _('Boost listing') }}</h2>
|
||||
<p class="muted">{{ listing.title[:60] }}</p>
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="boost-options">
|
||||
{% for btype, info in boost_types.items() %}
|
||||
<label class="boost-option {{ 'active-boost' if btype in active_boosts }}">
|
||||
<input type="radio" name="boost_type" value="{{ btype }}"
|
||||
{{ 'checked' if loop.first }} {{ 'disabled' if btype in active_boosts }}>
|
||||
<div class="boost-info">
|
||||
<div class="boost-label">{{ info.label }}
|
||||
{% if btype in active_boosts %}<span class="badge ok">{{ _('Active') }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="muted small">{{ info.days }} {{ _('days') }}</div>
|
||||
</div>
|
||||
<div class="boost-price">${{ "%.2f"|format(info.cents / 100) }}</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if stripe_enabled %}
|
||||
<button class="btn" type="submit">{{ _('Continue to payment') }}</button>
|
||||
{% else %}
|
||||
<p class="muted">{{ _('Payments not configured yet.') }}</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
<p class="muted small" style="margin-top:12px">
|
||||
<a href="{{ url_for('listings.detail', listing_id=listing.id) }}">← {{ _('Back to listing') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Pricing') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="pricing-wrap">
|
||||
<div class="pricing-head">
|
||||
<h1>{{ _('Simple, transparent pricing') }}</h1>
|
||||
<p class="muted">{{ _('Upgrade anytime. Cancel anytime.') }}</p>
|
||||
</div>
|
||||
<div class="plan-grid">
|
||||
{% for plan in plans %}
|
||||
<div class="plan-card card {{ 'current' if current_plan and current_plan.id == plan.id }}">
|
||||
{% if current_plan and current_plan.id == plan.id %}
|
||||
<div class="plan-badge">{{ _('Current plan') }}</div>
|
||||
{% endif %}
|
||||
<div class="plan-name">{{ plan.name }}</div>
|
||||
<div class="plan-price">
|
||||
{% if plan.price_monthly_cents == 0 %}
|
||||
<span class="price-amount">{{ _('Free') }}</span>
|
||||
{% else %}
|
||||
<span class="price-amount">${{ "%.0f"|format(plan.price_monthly_cents / 100) }}</span>
|
||||
<span class="price-period">{{ _('/mo') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul class="plan-features">
|
||||
<li>{{ _('%(n)s active listings', n=plan.limit('active_listings') or '∞') }}</li>
|
||||
<li>{{ _('%(n)s-day listing life', n=plan.limit('listing_life_days')) }}</li>
|
||||
<li>{{ _('%(n)s photos/listing', n=plan.limit('images_per_listing')) }}</li>
|
||||
{% if plan.limit('ad_free') %}<li class="feat-yes">{{ _('Ad-free browsing') }}</li>{% endif %}
|
||||
{% if plan.limit('verified_badge') %}<li class="feat-yes">{{ _('Verified badge') }}</li>{% endif %}
|
||||
{% if plan.limit('storefront') %}<li class="feat-yes">{{ _('Storefront page') }}</li>{% endif %}
|
||||
{% if plan.limit('scheduled_posting') %}<li class="feat-yes">{{ _('Scheduled posting') }}</li>{% endif %}
|
||||
{% if plan.limit('bulk_csv') %}<li class="feat-yes">{{ _('Bulk CSV upload') }}</li>{% endif %}
|
||||
{% if plan.limit('priority_support') %}<li class="feat-yes">{{ _('Priority support') }}</li>{% endif %}
|
||||
</ul>
|
||||
{% if plan.slug == 'free' %}
|
||||
{% if not current_user.is_authenticated %}
|
||||
<a class="btn plan-btn" href="{{ url_for('auth.register') }}">{{ _('Get started') }}</a>
|
||||
{% else %}
|
||||
<span class="btn plan-btn ghost disabled">{{ _('Your plan') }}</span>
|
||||
{% endif %}
|
||||
{% elif current_plan and current_plan.id == plan.id %}
|
||||
<a class="btn plan-btn ghost" href="{{ url_for('payments.portal') }}">{{ _('Manage') }}</a>
|
||||
{% elif stripe_enabled %}
|
||||
<a class="btn plan-btn" href="{{ url_for('payments.subscribe', slug=plan.slug) }}">{{ _('Upgrade') }}</a>
|
||||
{% else %}
|
||||
<span class="btn plan-btn ghost disabled">{{ _('Coming soon') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Payment successful') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card narrow" style="text-align:center;padding:48px 24px">
|
||||
<div style="font-size:48px">✅</div>
|
||||
<h2>{{ _('Payment successful!') }}</h2>
|
||||
{% if mode == 'subscription' %}
|
||||
<p>{{ _('Your subscription is now active. Enjoy your upgraded plan!') }}</p>
|
||||
<a class="btn" href="{{ url_for('payments.my_billing') }}">{{ _('View billing') }}</a>
|
||||
{% else %}
|
||||
<p>{{ _('Your boost is now live on your listing.') }}</p>
|
||||
<a class="btn" href="{{ url_for('listings.detail', listing_id=listing_id) }}">{{ _('View listing') }}</a>
|
||||
{% endif %}
|
||||
<br><br>
|
||||
<a href="{{ url_for('listings.mine') }}" class="muted small">{{ _('My listings') }}</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Sponsors') }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Our Sponsors') }}</h2>
|
||||
<p class="muted">{{ _('These businesses support our community classifieds.') }}</p>
|
||||
{% if not sponsors %}
|
||||
<p class="muted">{{ _('No sponsors yet.') }}</p>
|
||||
{% endif %}
|
||||
<div class="sponsor-grid">
|
||||
{% for s in sponsors %}
|
||||
<a class="sponsor-card card" href="{{ s.url }}" target="_blank" rel="noopener sponsored">
|
||||
{% if s.logo_path %}
|
||||
<img src="{{ url_for('listings.media', rel='sponsors/' + s.logo_path) }}"
|
||||
alt="{{ s.name }}" class="sponsor-logo">
|
||||
{% else %}
|
||||
<div class="sponsor-name-only">{{ s.name }}</div>
|
||||
{% endif %}
|
||||
{% if s.tagline %}<p class="muted small">{{ s.tagline }}</p>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+2
-13
@@ -1,21 +1,10 @@
|
||||
"""RBAC decorators and shared request utilities."""
|
||||
"""RBAC decorators. Never trust the client; gate on server side."""
|
||||
from functools import wraps
|
||||
from urllib.parse import urlparse
|
||||
from flask import abort, request
|
||||
from flask import abort
|
||||
from flask_login import current_user
|
||||
from app.models.enums import Role
|
||||
|
||||
|
||||
def safe_referrer(fallback: str) -> str:
|
||||
"""Return request.referrer only when it is same-origin; else fallback."""
|
||||
ref = request.referrer
|
||||
if ref:
|
||||
parsed = urlparse(ref)
|
||||
if parsed.netloc in ("", request.host):
|
||||
return ref
|
||||
return fallback
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""Require the current user to hold one of the given roles."""
|
||||
def decorator(fn):
|
||||
|
||||
Reference in New Issue
Block a user