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