mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 15:29:36 +00:00
175 lines
5.5 KiB
Python
175 lines
5.5 KiB
Python
"""
|
|
Pricing + Moyasar checkout endpoints.
|
|
|
|
Usage:
|
|
POST /api/v1/checkout body: {"plan":"starter","email":"x@y.com","lead_id":"optional"}
|
|
→ returns {"invoice_id":"...", "payment_url":"https://..."}
|
|
POST /api/v1/webhooks/moyasar — Moyasar payment webhook (status updates)
|
|
|
|
Plans are intentionally NOT published on the public landing page; the checkout
|
|
endpoint validates against `ALLOWED_PLANS` to prevent tampering.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
from dealix.payments import MoyasarClient, verify_webhook
|
|
from dealix.reliability.dlq import DLQ, WEBHOOKS_DLQ
|
|
from dealix.reliability.idempotency import IdempotencyStore
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["pricing"])
|
|
|
|
|
|
def _fingerprint(value: str) -> str:
|
|
if not value:
|
|
return ""
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
|
# Prices in halalas (SAR x 100). Hidden from landing — only exposed when a lead qualifies.
|
|
PLANS: dict[str, dict[str, Any]] = {
|
|
"starter": {
|
|
"name": "Starter",
|
|
"amount_halalas": 99900,
|
|
"monthly": True,
|
|
}, # 999 SAR/mo
|
|
"growth": {
|
|
"name": "Growth",
|
|
"amount_halalas": 299900,
|
|
"monthly": True,
|
|
}, # 2,999 SAR/mo
|
|
"scale": {
|
|
"name": "Scale",
|
|
"amount_halalas": 799900,
|
|
"monthly": True,
|
|
}, # 7,999 SAR/mo
|
|
"pilot_1sar": {
|
|
"name": "Pilot (1 SAR)",
|
|
"amount_halalas": 100,
|
|
"monthly": False,
|
|
}, # E2E test transaction
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/pricing/plans")
|
|
async def list_plans() -> dict[str, Any]:
|
|
"""List available plans. Not linked from landing — required for approval-gated quotes."""
|
|
return {
|
|
"currency": "SAR",
|
|
"plans": {
|
|
k: {
|
|
"name": v["name"],
|
|
"amount_sar": v["amount_halalas"] / 100,
|
|
"monthly": v["monthly"],
|
|
}
|
|
for k, v in PLANS.items()
|
|
if k != "pilot_1sar" # hide pilot from public listing
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/api/v1/checkout")
|
|
async def create_checkout(req: Request) -> dict[str, Any]:
|
|
body = await req.json()
|
|
plan = str(body.get("plan") or "").lower()
|
|
email = str(body.get("email") or "").strip()
|
|
lead_id = str(body.get("lead_id") or "")
|
|
|
|
if plan not in PLANS:
|
|
raise HTTPException(status_code=400, detail=f"unknown_plan: {plan}")
|
|
if "@" not in email:
|
|
raise HTTPException(status_code=400, detail="invalid_email")
|
|
|
|
plan_info = PLANS[plan]
|
|
callback_base = os.getenv("APP_URL", "https://dealix.me")
|
|
callback_url = f"{callback_base}/checkout/return"
|
|
|
|
client = MoyasarClient()
|
|
try:
|
|
invoice = await client.create_invoice(
|
|
amount_halalas=int(plan_info["amount_halalas"]),
|
|
currency="SAR",
|
|
description=f"Dealix — {plan_info['name']}",
|
|
callback_url=callback_url,
|
|
metadata={
|
|
"plan": plan,
|
|
"email": email,
|
|
"lead_id": lead_id,
|
|
"source": "dealix.checkout",
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
log.exception(
|
|
"moyasar_invoice_failed plan=%s email_fp=%s",
|
|
plan,
|
|
_fingerprint(email),
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail="payment_provider_error",
|
|
) from exc
|
|
|
|
return {
|
|
"invoice_id": invoice.get("id"),
|
|
"status": invoice.get("status"),
|
|
"amount_sar": plan_info["amount_halalas"] / 100,
|
|
"payment_url": invoice.get("url"),
|
|
"plan": plan,
|
|
}
|
|
|
|
|
|
@router.post("/api/v1/webhooks/moyasar")
|
|
async def moyasar_webhook(req: Request) -> dict[str, Any]:
|
|
"""
|
|
Moyasar payment webhook. Verifies secret_token in body and dedupes by event id.
|
|
Failed processing → DLQ(webhooks) for operator replay.
|
|
"""
|
|
try:
|
|
body = await req.json()
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=400, detail="invalid_json") from exc
|
|
|
|
if not verify_webhook(body):
|
|
log.warning("moyasar_webhook_bad_signature")
|
|
raise HTTPException(status_code=401, detail="bad_signature")
|
|
|
|
event_id = str(body.get("id") or "")
|
|
event_type = str(body.get("type") or "")
|
|
event_fp = _fingerprint(event_id)
|
|
idem = IdempotencyStore(prefix="idem:moyasar:")
|
|
if event_id and not idem.claim(event_id, ttl_seconds=7 * 86400):
|
|
log.info("moyasar_webhook_duplicate event_fp=%s", event_fp)
|
|
return {"status": "duplicate", "id": event_id}
|
|
|
|
try:
|
|
data = body.get("data") or {}
|
|
payment = data if data.get("object") in (None, "payment", "invoice") else {}
|
|
status = payment.get("status") or body.get("type")
|
|
log.info(
|
|
"moyasar_webhook_processed event_fp=%s type=%s status=%s amount=%s",
|
|
event_fp,
|
|
event_type,
|
|
status,
|
|
payment.get("amount"),
|
|
)
|
|
# TODO: sync to HubSpot via ConnectorFacade in D+2 E2E test
|
|
return {"status": "ok", "event_id": event_id, "event_type": event_type}
|
|
except Exception as exc:
|
|
log.exception("moyasar_webhook_processing_failed event_fp=%s", event_fp)
|
|
DLQ(WEBHOOKS_DLQ).push(
|
|
source="moyasar.webhook",
|
|
payload=body,
|
|
error=str(exc)[:500],
|
|
metadata={"event_id": event_id, "event_type": event_type},
|
|
)
|
|
# Still 200 so Moyasar doesn't retry forever; we own replay via DLQ.
|
|
return {"status": "dlq", "event_id": event_id}
|