06/15 Phase 4 + 5 codes

This commit is contained in:
2026-06-15 17:43:47 -04:00
parent f57a95013c
commit a9efd15a76
36 changed files with 1677 additions and 57 deletions
+120
View File
@@ -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
+362
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+1 -2
View File
@@ -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))