Initial commit

This commit is contained in:
belviskhoremk
2026-03-06 22:57:58 +00:00
commit c4d836a0f9
60 changed files with 5423 additions and 0 deletions

View File

@@ -0,0 +1,350 @@
"""Payment service — CinetPay integration for subscriptions and purchases."""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.core.config import get_settings
from app.core.exceptions import BadRequestException, ForbiddenException, NotFoundException
from app.core.supabase import get_supabase_admin
CINETPAY_INIT_URL = "https://api-checkout.cinetpay.com/v2/payment"
CINETPAY_CHECK_URL = "https://api-checkout.cinetpay.com/v2/payment/check"
class PaymentService:
def __init__(self):
self.db = get_supabase_admin()
self.settings = get_settings()
# ── Public methods ────────────────────────────────────
def initiate(
self,
user_id: str,
payment_type: str,
plan: Optional[str] = None,
listing_id: Optional[str] = None,
) -> dict:
"""Create a pending payment record and return CinetPay redirect URL."""
settings = self.settings
if payment_type == "subscription":
if plan not in ("monthly", "yearly"):
raise BadRequestException("plan must be 'monthly' or 'yearly'")
amount = (
settings.SUBSCRIPTION_MONTHLY_AMOUNT
if plan == "monthly"
else settings.SUBSCRIPTION_YEARLY_AMOUNT
)
# Resolve agency_id from user
agency_result = (
self.db.table("agencies").select("id").eq("user_id", user_id).execute()
)
if not agency_result.data:
raise ForbiddenException("Agency profile not found")
agency_id = agency_result.data[0]["id"]
transaction_id = self._build_transaction_id("SUB", agency_id)
description = f"Abonnement {plan} - Deals24Togo"
metadata = {"plan": plan, "agency_id": agency_id}
elif payment_type == "purchase":
if not listing_id:
raise BadRequestException("listing_id is required for purchase")
listing_result = (
self.db.table("listings")
.select("id, title, price, status")
.eq("id", listing_id)
.execute()
)
if not listing_result.data:
raise NotFoundException("Listing not found")
listing = listing_result.data[0]
if listing["status"] != "approved":
raise BadRequestException("Listing is not available for purchase")
amount = float(listing["price"])
transaction_id = self._build_transaction_id("PUR", listing_id)
description = f"Achat - {listing['title'][:40]}"
metadata = {"listing_id": listing_id, "listing_title": listing["title"]}
else:
raise BadRequestException("type must be 'subscription' or 'purchase'")
# Insert pending payment row
now = datetime.now(timezone.utc).isoformat()
payment_row = {
"transaction_id": transaction_id,
"type": payment_type,
"payer_id": user_id,
"amount": amount,
"currency": "XOF",
"status": "pending",
"metadata": metadata,
"created_at": now,
}
insert_result = self.db.table("payments").insert(payment_row).execute()
if not insert_result.data:
raise Exception("Failed to create payment record")
# Call CinetPay
notify_url = f"{settings.BACKEND_PUBLIC_URL}/api/v1/payments/webhook"
return_url = f"{settings.FRONTEND_URL}/payment-return?transaction_id={transaction_id}"
payload = {
"apikey": settings.CINETPAY_API_KEY,
"site_id": settings.CINETPAY_SITE_ID,
"transaction_id": transaction_id,
"amount": int(amount),
"currency": "XOF",
"description": description,
"notify_url": notify_url,
"return_url": return_url,
"channels": "ALL",
}
with httpx.Client(timeout=30) as client:
resp = client.post(CINETPAY_INIT_URL, json=payload)
resp.raise_for_status()
data = resp.json()
if data.get("code") != "201" and data.get("code") != "00":
raise Exception(f"CinetPay error: {data.get('message', 'Unknown error')}")
payment_url = data["data"]["payment_url"]
return {"payment_url": payment_url, "transaction_id": transaction_id}
def handle_webhook(self, form_data: dict) -> None:
"""Process CinetPay webhook notification."""
settings = self.settings
transaction_id = form_data.get("cpm_trans_id")
site_id = form_data.get("cpm_site_id")
if not transaction_id:
return
# Validate site_id
if site_id and site_id != str(settings.CINETPAY_SITE_ID):
return
# Idempotency: check if already completed
result = (
self.db.table("payments")
.select("*")
.eq("transaction_id", transaction_id)
.execute()
)
if not result.data:
return
payment = result.data[0]
if payment["status"] == "completed":
return # Already processed
# Verify with CinetPay
verify_payload = {
"apikey": settings.CINETPAY_API_KEY,
"site_id": settings.CINETPAY_SITE_ID,
"transaction_id": transaction_id,
}
with httpx.Client(timeout=30) as client:
resp = client.post(CINETPAY_CHECK_URL, json=verify_payload)
resp.raise_for_status()
verify_data = resp.json()
if verify_data.get("code") != "00":
return
cp_status = verify_data["data"].get("status")
payment_method = verify_data["data"].get("payment_method")
operator_id = verify_data["data"].get("operator_id")
if cp_status == "ACCEPTED":
# Update payment to completed
now = datetime.now(timezone.utc).isoformat()
self.db.table("payments").update(
{
"status": "completed",
"payment_method": payment_method,
"operator_id": operator_id,
"paid_at": now,
}
).eq("transaction_id", transaction_id).execute()
# Re-fetch updated payment
updated = (
self.db.table("payments")
.select("*")
.eq("transaction_id", transaction_id)
.execute()
)
if updated.data:
payment = updated.data[0]
if payment["type"] == "subscription":
self._activate_subscription(payment)
elif payment["type"] == "purchase":
self._complete_purchase(payment)
elif cp_status in ("REFUSED",):
self.db.table("payments").update({"status": "failed"}).eq(
"transaction_id", transaction_id
).execute()
def get_receipt(self, transaction_id: str, user_id: str) -> dict:
"""Return enriched receipt data for a transaction."""
result = (
self.db.table("payments")
.select("*")
.eq("transaction_id", transaction_id)
.execute()
)
if not result.data:
raise NotFoundException("Payment not found")
payment = result.data[0]
if payment["payer_id"] != user_id:
raise ForbiddenException("Not authorized to view this payment")
# Enrich with payer info
payer_name = None
payer_email = None
user_result = (
self.db.table("users")
.select("name, email")
.eq("id", user_id)
.execute()
)
if user_result.data:
payer_name = user_result.data[0].get("name")
payer_email = user_result.data[0].get("email")
# Enrich with plan label or listing title
metadata = payment.get("metadata") or {}
plan_label = None
listing_title = None
if payment["type"] == "subscription":
plan = metadata.get("plan")
plan_label = "Mensuel (1 mois)" if plan == "monthly" else "Annuel (12 mois)"
elif payment["type"] == "purchase":
listing_title = metadata.get("listing_title")
if not listing_title:
lid = metadata.get("listing_id")
if lid:
lr = (
self.db.table("listings")
.select("title")
.eq("id", lid)
.execute()
)
if lr.data:
listing_title = lr.data[0]["title"]
return {
**payment,
"payer_name": payer_name,
"payer_email": payer_email,
"plan_label": plan_label,
"listing_title": listing_title,
}
def get_my_payments(self, user_id: str) -> list:
"""List all payments for a user."""
result = (
self.db.table("payments")
.select("*")
.eq("payer_id", user_id)
.order("created_at", desc=True)
.execute()
)
return result.data or []
def get_subscription_status(self, user_id: str) -> dict:
"""Return the current subscription status for the agency user."""
agency_result = (
self.db.table("agencies").select("id").eq("user_id", user_id).execute()
)
if not agency_result.data:
raise NotFoundException("Agency not found")
agency_id = agency_result.data[0]["id"]
now = datetime.now(timezone.utc).isoformat()
result = (
self.db.table("subscriptions")
.select("*")
.eq("agency_id", agency_id)
.eq("status", "active")
.gt("ends_at", now)
.order("ends_at", desc=True)
.limit(1)
.execute()
)
if result.data:
return {"has_active_subscription": True, "subscription": result.data[0]}
return {"has_active_subscription": False, "subscription": None}
# ── Private helpers ───────────────────────────────────
def _activate_subscription(self, payment: dict) -> None:
"""Create or extend subscription after successful payment."""
metadata = payment.get("metadata") or {}
agency_id = metadata.get("agency_id")
plan = metadata.get("plan")
if not agency_id or not plan:
return
now = datetime.now(timezone.utc)
days = 30 if plan == "monthly" else 365
ends_at = (now + timedelta(days=days)).isoformat()
starts_at = now.isoformat()
self.db.table("subscriptions").insert(
{
"agency_id": agency_id,
"plan": plan,
"status": "active",
"starts_at": starts_at,
"ends_at": ends_at,
"payment_id": payment["id"],
"created_at": starts_at,
}
).execute()
def _complete_purchase(self, payment: dict) -> None:
"""Record purchase and mark listing as sold."""
metadata = payment.get("metadata") or {}
listing_id = metadata.get("listing_id")
if not listing_id:
return
now = datetime.now(timezone.utc).isoformat()
# Insert purchase record
self.db.table("purchases").insert(
{
"listing_id": listing_id,
"buyer_id": payment.get("payer_id"),
"amount": payment["amount"],
"payment_id": payment["id"],
"created_at": now,
}
).execute()
# Mark listing as sold
self.db.table("listings").update(
{"status": "sold", "updated_at": now}
).eq("id", listing_id).execute()
@staticmethod
def _build_transaction_id(prefix: str, resource_id: str) -> str:
"""Build a ≤50-char alphanumeric transaction ID."""
short_id = resource_id[:8].replace("-", "")
ts = int(time.time())
return f"{prefix}{short_id}{ts}"