Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -82,31 +82,38 @@ CORS(app)
|
|
| 82 |
# =============================================================================
|
| 83 |
# PRICING CATALOGUE
|
| 84 |
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
PLANS = {
|
| 86 |
"starter": {
|
| 87 |
"label": "Starter",
|
| 88 |
"price_zar": 149,
|
| 89 |
"monthly_credits": 115,
|
| 90 |
-
"billing": "
|
|
|
|
| 91 |
},
|
| 92 |
"professional": {
|
| 93 |
"label": "Professional",
|
| 94 |
"price_zar": 399,
|
| 95 |
"monthly_credits": 320,
|
| 96 |
-
"billing": "
|
|
|
|
| 97 |
},
|
| 98 |
"executive": {
|
| 99 |
"label": "Executive",
|
| 100 |
"price_zar": 899,
|
| 101 |
"monthly_credits": 750,
|
| 102 |
-
"billing": "
|
|
|
|
| 103 |
},
|
| 104 |
"lifetime": {
|
| 105 |
"label": "Infinite Prep (Lifetime)",
|
| 106 |
"price_zar": 3499,
|
| 107 |
"original_price_zar": 4999,
|
| 108 |
"monthly_credits": 400,
|
| 109 |
-
"billing": "
|
| 110 |
},
|
| 111 |
"topup_quick": {
|
| 112 |
"label": "Quick Refill",
|
|
@@ -144,8 +151,10 @@ INSIGHT_PLANS = {"executive", "lifetime"}
|
|
| 144 |
PITCHFY_MARKER = "plan"
|
| 145 |
PITCHFY_MARKER_KEYS = {"plan", "createdAt"}
|
| 146 |
|
| 147 |
-
#
|
| 148 |
-
|
|
|
|
|
|
|
| 149 |
|
| 150 |
# =============================================================================
|
| 151 |
# FIREBASE & AI INIT
|
|
@@ -172,7 +181,7 @@ try:
|
|
| 172 |
if not _gemini_key:
|
| 173 |
raise ValueError("'Gemini' env var not set.")
|
| 174 |
client = genai.Client(api_key=_gemini_key)
|
| 175 |
-
MODEL_NAME = "gemini-
|
| 176 |
logger.info(f"Gemini initialized ({MODEL_NAME}).")
|
| 177 |
|
| 178 |
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
|
|
@@ -307,6 +316,65 @@ def _log_payment_event(uid, plan_id, payment_id, credits, event_type):
|
|
| 307 |
logger.error(f"Payment event log failed for {uid}: {e}")
|
| 308 |
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
# =============================================================================
|
| 311 |
# 3. AI LOGIC FUNCTIONS
|
| 312 |
# =============================================================================
|
|
@@ -811,7 +879,7 @@ def _collect_platform_stats() -> dict:
|
|
| 811 |
price = float(plan_cfg.get("price_zar", 0))
|
| 812 |
billing = plan_cfg.get("billing", "")
|
| 813 |
|
| 814 |
-
if event_type in ("subscription_activated", "monthly_renewal", "topup"):
|
| 815 |
paying_uids.add(uid)
|
| 816 |
total_revenue_zar += price
|
| 817 |
revenue_by_plan[plan_id] += price
|
|
@@ -822,12 +890,13 @@ def _collect_platform_stats() -> dict:
|
|
| 822 |
|
| 823 |
if event_type == "subscription_activated":
|
| 824 |
subscription_activations += 1
|
| 825 |
-
if billing == "lifetime":
|
| 826 |
-
lifetime_purchases += 1
|
| 827 |
if prev_plan and prev_plan != plan_id:
|
| 828 |
upgrade_flows[f"{prev_plan}β{plan_id}"] += 1
|
| 829 |
prev_plan = plan_id
|
| 830 |
plan_history_by_uid[uid].append(plan_id)
|
|
|
|
|
|
|
|
|
|
| 831 |
elif event_type == "monthly_renewal":
|
| 832 |
renewals += 1
|
| 833 |
elif event_type == "topup":
|
|
@@ -1009,9 +1078,6 @@ def social_signin():
|
|
| 1009 |
if user_data:
|
| 1010 |
if user_data.get("suspended"):
|
| 1011 |
return jsonify({"error": "Account suspended. Contact support."}), 403
|
| 1012 |
-
# Back-fill Pitchfy marker keys for accounts that pre-date the plan field
|
| 1013 |
-
# (e.g. SozoFix accounts signing into Pitchfy for the first time).
|
| 1014 |
-
# Lightweight patch written once; subsequent logins are no-ops.
|
| 1015 |
backfill = {}
|
| 1016 |
if "plan" not in user_data:
|
| 1017 |
backfill["plan"] = "free"
|
|
@@ -1689,7 +1755,7 @@ def admin_set_plan(uid):
|
|
| 1689 |
if not user_data or not any(k in user_data for k in PITCHFY_MARKER_KEYS):
|
| 1690 |
return jsonify({"error": "Pitchfy user not found."}), 404
|
| 1691 |
plan_cfg = PLANS[plan_id]
|
| 1692 |
-
billing = plan_cfg.get("billing"
|
| 1693 |
update = {
|
| 1694 |
"plan": plan_id,
|
| 1695 |
"plan_label": plan_cfg["label"],
|
|
@@ -1697,20 +1763,22 @@ def admin_set_plan(uid):
|
|
| 1697 |
"plan_activated_at": _now_iso(),
|
| 1698 |
"plan_note": data.get("note", "Admin override"),
|
| 1699 |
}
|
| 1700 |
-
|
| 1701 |
-
|
|
|
|
| 1702 |
update["last_credit_refresh"] = _now_iso()
|
| 1703 |
-
if billing == "
|
| 1704 |
update["is_lifetime"] = True
|
| 1705 |
user_ref.update(update)
|
| 1706 |
_log_payment_event(
|
| 1707 |
uid, plan_id, "admin_override",
|
| 1708 |
-
plan_cfg.get("monthly_credits", 0), "admin_plan_set",
|
| 1709 |
)
|
| 1710 |
return jsonify({"success": True, "plan": plan_id, "credits": update.get("credits")}), 200
|
| 1711 |
except PermissionError as e:
|
| 1712 |
return jsonify({"error": str(e)}), 403
|
| 1713 |
except Exception as e:
|
|
|
|
| 1714 |
return jsonify({"error": str(e)}), 500
|
| 1715 |
|
| 1716 |
|
|
@@ -1745,9 +1813,9 @@ def admin_refresh_credits(uid):
|
|
| 1745 |
return jsonify({"error": "User not found."}), 404
|
| 1746 |
plan_id = user_data.get("plan", "free")
|
| 1747 |
plan = PLANS.get(plan_id)
|
| 1748 |
-
if not plan or plan.get("billing") not in ("
|
| 1749 |
-
return jsonify({"error": f'Plan "{plan_id}" has no
|
| 1750 |
-
credits = plan["monthly_credits"]
|
| 1751 |
user_ref.update({"credits": credits, "last_credit_refresh": _now_iso()})
|
| 1752 |
return jsonify({"success": True, "credits_loaded": credits}), 200
|
| 1753 |
except PermissionError as e:
|
|
@@ -1858,155 +1926,146 @@ def admin_get_report(report_id):
|
|
| 1858 |
# =============================================================================
|
| 1859 |
# 15. PAYMENT WEBHOOK & BILLING (PAYSTACK)
|
| 1860 |
# =============================================================================
|
| 1861 |
-
|
| 1862 |
-
# ---------------------------------------------------------------------------
|
| 1863 |
-
# charge_monthly_subscriptions
|
| 1864 |
-
# ---------------------------------------------------------------------------
|
| 1865 |
-
# Called by the /api/cron/billing-sweep endpoint.
|
| 1866 |
-
# Iterates only Pitchfy users on billable plans whose last_credit_refresh
|
| 1867 |
-
# was 30+ days ago, then either:
|
| 1868 |
-
# - Refills lifetime users for free, or
|
| 1869 |
-
# - Charges monthly subscribers via Paystack's charge_authorization API.
|
| 1870 |
#
|
| 1871 |
-
#
|
| 1872 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1873 |
# ---------------------------------------------------------------------------
|
| 1874 |
|
| 1875 |
-
|
| 1876 |
-
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
|
| 1880 |
-
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
-
|
| 1884 |
-
|
|
|
|
| 1885 |
|
| 1886 |
-
last_refresh_str = user_data.get("last_credit_refresh")
|
| 1887 |
-
if not last_refresh_str:
|
| 1888 |
-
logger.warning(f"CRON | uid={uid} plan={plan_id} has no last_credit_refresh β skipping.")
|
| 1889 |
-
continue
|
| 1890 |
|
| 1891 |
-
|
| 1892 |
-
|
| 1893 |
-
|
| 1894 |
-
|
| 1895 |
-
continue
|
| 1896 |
|
| 1897 |
-
|
| 1898 |
-
if days_since_refresh < 30:
|
| 1899 |
-
continue
|
| 1900 |
|
| 1901 |
-
|
| 1902 |
-
|
| 1903 |
-
|
| 1904 |
-
|
|
|
|
|
|
|
|
|
|
| 1905 |
|
| 1906 |
-
|
| 1907 |
-
|
| 1908 |
-
|
| 1909 |
-
db_ref.child(f"users/{uid}").update({
|
| 1910 |
-
"credits": PLANS["lifetime"]["monthly_credits"],
|
| 1911 |
-
"last_credit_refresh": _now_iso(),
|
| 1912 |
-
})
|
| 1913 |
-
logger.info(f"CRON | Refilled lifetime credits for uid={uid}.")
|
| 1914 |
-
except Exception as e:
|
| 1915 |
-
logger.error(f"CRON | Firebase write failed for lifetime refill uid={uid}: {e}")
|
| 1916 |
-
continue
|
| 1917 |
|
| 1918 |
-
|
| 1919 |
-
|
| 1920 |
-
|
|
|
|
| 1921 |
|
| 1922 |
-
|
| 1923 |
-
|
| 1924 |
-
|
| 1925 |
-
f"paystack_auth_code or paystack_email β cannot charge."
|
| 1926 |
-
)
|
| 1927 |
-
continue
|
| 1928 |
|
| 1929 |
-
|
| 1930 |
-
|
| 1931 |
-
|
| 1932 |
|
| 1933 |
-
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
"email": email,
|
| 1937 |
-
"amount": int(plan["price_zar"] * 100), # Paystack uses kobo
|
| 1938 |
-
}
|
| 1939 |
try:
|
| 1940 |
-
|
| 1941 |
-
|
| 1942 |
-
|
| 1943 |
-
|
| 1944 |
-
|
| 1945 |
-
|
| 1946 |
-
|
| 1947 |
-
|
| 1948 |
-
|
| 1949 |
-
|
| 1950 |
-
|
| 1951 |
-
|
| 1952 |
-
|
| 1953 |
-
|
| 1954 |
-
|
| 1955 |
-
|
| 1956 |
-
f"reference={reference}."
|
| 1957 |
-
)
|
| 1958 |
-
else:
|
| 1959 |
-
logger.warning(
|
| 1960 |
-
f"CRON | Charge FAILED for uid={uid} plan={plan_id}: "
|
| 1961 |
-
f"{res.get('message', 'unknown error')} | full response: {json.dumps(res)}"
|
| 1962 |
-
)
|
| 1963 |
-
# Optionally: flag account for follow-up
|
| 1964 |
-
# db_ref.child(f"users/{uid}").update({"payment_failed": True})
|
| 1965 |
|
| 1966 |
-
|
| 1967 |
-
|
| 1968 |
-
|
| 1969 |
-
|
|
|
|
|
|
|
| 1970 |
|
| 1971 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1972 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1973 |
|
| 1974 |
-
|
| 1975 |
-
|
| 1976 |
-
|
| 1977 |
-
Triggered by an external scheduler (e.g. BetterStack monitor, cron.io).
|
| 1978 |
-
Secured by WEBHOOK_SECRET in the Authorization header.
|
| 1979 |
-
GET is allowed so a simple URL ping from a scheduler without a body works.
|
| 1980 |
-
"""
|
| 1981 |
-
if not WEBHOOK_SECRET:
|
| 1982 |
-
logger.error("CRON | WEBHOOK_SECRET env var not set β billing sweep endpoint is disabled.")
|
| 1983 |
-
return jsonify({"error": "Cron endpoint not configured."}), 503
|
| 1984 |
-
|
| 1985 |
-
auth_header = request.headers.get("Authorization", "")
|
| 1986 |
-
expected_secret = f"Bearer {WEBHOOK_SECRET}"
|
| 1987 |
-
if auth_header != expected_secret:
|
| 1988 |
-
logger.warning("CRON | Unauthorized attempt to trigger billing sweep.")
|
| 1989 |
-
return jsonify({"error": "Unauthorized."}), 401
|
| 1990 |
|
| 1991 |
-
|
| 1992 |
-
|
| 1993 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1994 |
except Exception as e:
|
| 1995 |
-
logger.error(f"
|
| 1996 |
-
return jsonify({"error": "
|
| 1997 |
|
| 1998 |
|
| 1999 |
@app.route("/api/payments/verify", methods=["POST"])
|
| 2000 |
def verify_initial_payment():
|
| 2001 |
"""
|
| 2002 |
-
Called by the frontend
|
| 2003 |
-
the transaction and activate the plan / add credits.
|
| 2004 |
|
| 2005 |
Body: { reference: str, plan_id: str }
|
| 2006 |
|
| 2007 |
-
|
| 2008 |
-
|
| 2009 |
-
|
|
|
|
| 2010 |
"""
|
| 2011 |
uid = verify_token(request.headers.get("Authorization"))
|
| 2012 |
if not uid:
|
|
@@ -2023,6 +2082,11 @@ def verify_initial_payment():
|
|
| 2023 |
if not plan_data:
|
| 2024 |
return jsonify({"error": f"Invalid plan_id '{plan_id}'."}), 400
|
| 2025 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2026 |
if not PAYSTACK_SECRET_KEY:
|
| 2027 |
logger.error("PAYMENTS_VERIFY | PAYSTACK_SECRET_KEY not configured.")
|
| 2028 |
return jsonify({"error": "Payment gateway not configured on server."}), 503
|
|
@@ -2046,70 +2110,90 @@ def verify_initial_payment():
|
|
| 2046 |
)
|
| 2047 |
return jsonify({"error": "Payment verification failed."}), 400
|
| 2048 |
|
| 2049 |
-
tx_data
|
| 2050 |
-
|
| 2051 |
-
|
| 2052 |
-
|
| 2053 |
-
|
| 2054 |
-
logger.info(
|
| 2055 |
-
f"PAYMENTS_VERIFY | Verified OK. uid={uid} plan={plan_id} "
|
| 2056 |
-
f"billing={billing_type} reference={reference}"
|
| 2057 |
-
)
|
| 2058 |
|
| 2059 |
user_ref = db_ref.child(f"users/{uid}")
|
| 2060 |
user_data = user_ref.get() or {}
|
| 2061 |
|
| 2062 |
try:
|
| 2063 |
if billing_type == "topup":
|
| 2064 |
-
credits_to_add = plan_data["credits"]
|
| 2065 |
-
new_credits = user_data.get("credits", 0) + credits_to_add
|
| 2066 |
-
user_ref.update({"credits": new_credits})
|
| 2067 |
_log_payment_event(uid, plan_id, reference, credits_to_add, "topup")
|
| 2068 |
logger.info(
|
| 2069 |
-
f"PAYMENTS_VERIFY | TOP-UP
|
| 2070 |
-
f"
|
| 2071 |
)
|
| 2072 |
|
| 2073 |
-
|
| 2074 |
-
|
| 2075 |
-
update
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2076 |
"plan": plan_id,
|
| 2077 |
"plan_label": plan_data["label"],
|
| 2078 |
-
"plan_billing":
|
| 2079 |
-
"credits":
|
| 2080 |
"plan_activated_at": _now_iso(),
|
| 2081 |
"last_credit_refresh": _now_iso(),
|
| 2082 |
}
|
| 2083 |
-
|
| 2084 |
-
|
| 2085 |
-
|
| 2086 |
-
|
| 2087 |
-
|
| 2088 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2089 |
user_ref.update(update)
|
| 2090 |
-
_log_payment_event(
|
| 2091 |
-
uid, plan_id, reference,
|
| 2092 |
-
plan_data["monthly_credits"], "subscription_activated",
|
| 2093 |
-
)
|
| 2094 |
logger.info(
|
| 2095 |
-
f"PAYMENTS_VERIFY | SUBSCRIPTION
|
| 2096 |
-
f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2097 |
)
|
|
|
|
| 2098 |
|
| 2099 |
return jsonify({"success": True}), 200
|
| 2100 |
|
| 2101 |
except Exception as e:
|
| 2102 |
logger.critical(
|
| 2103 |
-
f"PAYMENTS_VERIFY | Firebase write FAILED
|
| 2104 |
f"reference={reference}: {e}\n{traceback.format_exc()}"
|
| 2105 |
)
|
| 2106 |
-
return jsonify({
|
|
|
|
|
|
|
| 2107 |
|
| 2108 |
|
| 2109 |
# ---------------------------------------------------------------------------
|
| 2110 |
-
# Paystack
|
| 2111 |
-
# cancellations that originate from Paystack's own subscription engine,
|
| 2112 |
-
# not from our cron). The two flows coexist without conflict.
|
| 2113 |
# ---------------------------------------------------------------------------
|
| 2114 |
|
| 2115 |
def _verify_paystack_signature(req):
|
|
@@ -2141,7 +2225,25 @@ def _verify_paystack_signature(req):
|
|
| 2141 |
|
| 2142 |
@app.route("/api/webhooks/payment", methods=["POST"])
|
| 2143 |
def payment_webhook():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2144 |
logger.info("WEBHOOK | Incoming Paystack webhook received.")
|
|
|
|
| 2145 |
try:
|
| 2146 |
_verify_paystack_signature(request)
|
| 2147 |
except PermissionError as e:
|
|
@@ -2150,212 +2252,190 @@ def payment_webhook():
|
|
| 2150 |
|
| 2151 |
payload = request.get_json(silent=True)
|
| 2152 |
if not payload:
|
| 2153 |
-
logger.error("WEBHOOK |
|
| 2154 |
return jsonify({"error": "Empty payload."}), 400
|
| 2155 |
|
| 2156 |
paystack_event = payload.get("event")
|
|
|
|
|
|
|
| 2157 |
logger.info(f"WEBHOOK | Event type: '{paystack_event}'")
|
| 2158 |
|
| 2159 |
if paystack_event not in ("charge.success", "subscription.disable"):
|
| 2160 |
-
logger.info(f"WEBHOOK | Event '{paystack_event}'
|
| 2161 |
return jsonify({"success": True, "message": "Event not actionable."}), 200
|
| 2162 |
|
| 2163 |
-
|
| 2164 |
-
|
| 2165 |
-
|
| 2166 |
-
|
| 2167 |
-
|
| 2168 |
-
|
| 2169 |
|
| 2170 |
-
|
| 2171 |
-
|
| 2172 |
-
|
| 2173 |
)
|
| 2174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2175 |
|
| 2176 |
-
uid = plan_id = None
|
| 2177 |
for f in custom_fields:
|
| 2178 |
var = f.get("variable_name")
|
| 2179 |
val = f.get("value")
|
| 2180 |
-
|
| 2181 |
-
if var == "
|
| 2182 |
-
uid = val
|
| 2183 |
-
elif var == "plan_id":
|
| 2184 |
-
plan_id = val
|
| 2185 |
|
| 2186 |
-
|
| 2187 |
-
logger.error(
|
| 2188 |
-
f"WEBHOOK | CRITICAL β 'uid' missing from custom_fields for payment {payment_id}. "
|
| 2189 |
-
f"Raw custom_fields: {json.dumps(custom_fields)}. Plan upgrade CANNOT proceed."
|
| 2190 |
-
)
|
| 2191 |
-
return jsonify({"error": "Missing uid in payment metadata."}), 400
|
| 2192 |
-
|
| 2193 |
-
if not plan_id:
|
| 2194 |
-
logger.error(
|
| 2195 |
-
f"WEBHOOK | CRITICAL β 'plan_id' missing from custom_fields for payment {payment_id}, "
|
| 2196 |
-
f"uid={uid}. Plan upgrade CANNOT proceed."
|
| 2197 |
-
)
|
| 2198 |
-
return jsonify({"error": "Missing plan_id in payment metadata."}), 400
|
| 2199 |
|
| 2200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2201 |
|
| 2202 |
-
|
| 2203 |
-
if not plan:
|
| 2204 |
logger.error(
|
| 2205 |
-
f"WEBHOOK |
|
| 2206 |
-
f"
|
| 2207 |
)
|
| 2208 |
-
return jsonify({"error": "
|
| 2209 |
-
|
| 2210 |
-
logger.info(f"WEBHOOK | Plan resolved: {plan['label']} billing={plan['billing']}")
|
| 2211 |
|
| 2212 |
-
logger.info(f"WEBHOOK | Fetching Firebase user record for uid={uid}")
|
| 2213 |
user_ref = db_ref.child(f"users/{uid}")
|
| 2214 |
-
|
| 2215 |
user_data = user_ref.get()
|
| 2216 |
-
except Exception as e:
|
| 2217 |
-
logger.critical(
|
| 2218 |
-
f"WEBHOOK | Firebase read FAILED for uid={uid} payment={payment_id}: {e}\n"
|
| 2219 |
-
f"{traceback.format_exc()}"
|
| 2220 |
-
)
|
| 2221 |
-
return jsonify({"error": "Database read error."}), 500
|
| 2222 |
|
| 2223 |
if not user_data:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2224 |
logger.error(
|
| 2225 |
-
f"WEBHOOK |
|
| 2226 |
-
f"Payment {payment_id} for plan '{plan_id}' CANNOT be applied."
|
| 2227 |
)
|
| 2228 |
-
return jsonify({"error": "
|
| 2229 |
|
|
|
|
| 2230 |
logger.info(
|
| 2231 |
-
f"WEBHOOK |
|
| 2232 |
-
f"
|
| 2233 |
)
|
| 2234 |
|
| 2235 |
-
|
| 2236 |
if paystack_event == "subscription.disable":
|
| 2237 |
-
|
| 2238 |
-
|
| 2239 |
-
|
| 2240 |
|
| 2241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2242 |
|
| 2243 |
-
|
| 2244 |
-
|
| 2245 |
-
|
|
|
|
|
|
|
|
|
|
| 2246 |
|
| 2247 |
-
|
| 2248 |
-
|
| 2249 |
-
|
| 2250 |
-
|
| 2251 |
-
|
| 2252 |
-
|
| 2253 |
-
|
| 2254 |
-
|
| 2255 |
-
|
| 2256 |
-
user_ref.update({"credits": credits_after})
|
| 2257 |
-
logger.info(f"WEBHOOK | TOP-UP: Firebase write SUCCESS for uid={uid}")
|
| 2258 |
-
except Exception as e:
|
| 2259 |
-
logger.critical(
|
| 2260 |
-
f"WEBHOOK | TOP-UP: Firebase write FAILED uid={uid} payment={payment_id}: "
|
| 2261 |
-
f"{e}\n{traceback.format_exc()}"
|
| 2262 |
)
|
| 2263 |
-
return jsonify({"
|
| 2264 |
-
_log_payment_event(uid, plan_id, payment_id, credits, "topup")
|
| 2265 |
-
logger.info(f"WEBHOOK | TOP-UP complete. uid={uid} payment={payment_id}")
|
| 2266 |
-
return jsonify({"success": True, "credits_loaded": credits}), 200
|
| 2267 |
|
| 2268 |
-
|
| 2269 |
-
|
| 2270 |
-
|
| 2271 |
-
|
| 2272 |
-
|
| 2273 |
-
|
| 2274 |
-
|
| 2275 |
-
|
| 2276 |
-
|
| 2277 |
-
|
| 2278 |
-
|
| 2279 |
-
|
| 2280 |
-
logger.info(
|
| 2281 |
-
f"WEBHOOK | SUBSCRIPTION ACTIVATE: uid={uid} plan={plan_id} "
|
| 2282 |
-
f"credits={credits} billing={billing}. Writing: {json.dumps(update)}"
|
| 2283 |
-
)
|
| 2284 |
-
try:
|
| 2285 |
-
user_ref.update(update)
|
| 2286 |
logger.info(
|
| 2287 |
-
f"WEBHOOK |
|
| 2288 |
-
f"uid={uid} now on plan='{plan_id}' credits={credits}"
|
| 2289 |
)
|
| 2290 |
-
|
| 2291 |
-
|
| 2292 |
-
|
| 2293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2294 |
)
|
| 2295 |
-
return jsonify({"
|
| 2296 |
-
_log_payment_event(uid, plan_id, payment_id, credits, "subscription_activated")
|
| 2297 |
-
logger.info(
|
| 2298 |
-
f"WEBHOOK | SUBSCRIPTION ACTIVATE complete. "
|
| 2299 |
-
f"uid={uid} plan={plan_id} payment={payment_id}"
|
| 2300 |
-
)
|
| 2301 |
-
return jsonify({"success": True, "credits_loaded": credits}), 200
|
| 2302 |
|
| 2303 |
-
else:
|
| 2304 |
logger.error(
|
| 2305 |
-
f"WEBHOOK |
|
| 2306 |
-
f"uid={uid} payment={payment_id}. No action taken."
|
| 2307 |
)
|
| 2308 |
return jsonify({"error": f"Unhandled billing type: {billing}"}), 400
|
| 2309 |
|
| 2310 |
-
elif event == "monthly_renewal":
|
| 2311 |
-
current_plan_id = user_data.get("plan", plan_id)
|
| 2312 |
-
current_plan = PLANS.get(current_plan_id, plan)
|
| 2313 |
-
credits = current_plan["monthly_credits"]
|
| 2314 |
-
logger.info(
|
| 2315 |
-
f"WEBHOOK | MONTHLY RENEWAL: uid={uid} current_plan={current_plan_id} "
|
| 2316 |
-
f"refreshing to {credits} credits."
|
| 2317 |
-
)
|
| 2318 |
-
try:
|
| 2319 |
-
user_ref.update({"credits": credits, "last_credit_refresh": _now_iso()})
|
| 2320 |
-
logger.info(f"WEBHOOK | MONTHLY RENEWAL: Firebase write SUCCESS. uid={uid}")
|
| 2321 |
except Exception as e:
|
| 2322 |
logger.critical(
|
| 2323 |
-
f"WEBHOOK |
|
| 2324 |
-
f"
|
| 2325 |
)
|
| 2326 |
return jsonify({"error": "Database write error."}), 500
|
| 2327 |
-
_log_payment_event(uid, current_plan_id, payment_id, credits, "monthly_renewal")
|
| 2328 |
-
logger.info(f"WEBHOOK | MONTHLY RENEWAL complete. uid={uid} payment={payment_id}")
|
| 2329 |
-
return jsonify({"success": True, "credits_loaded": credits}), 200
|
| 2330 |
|
| 2331 |
-
elif event == "subscription_cancelled":
|
| 2332 |
-
if user_data.get("is_lifetime", False):
|
| 2333 |
-
logger.info(f"WEBHOOK | CANCELLATION ignored β uid={uid} is lifetime.")
|
| 2334 |
-
return jsonify({"success": True, "message": "Lifetime unaffected."}), 200
|
| 2335 |
-
logger.info(f"WEBHOOK | CANCELLATION: uid={uid} downgrading to free.")
|
| 2336 |
-
try:
|
| 2337 |
-
user_ref.update({
|
| 2338 |
-
"credits": 0,
|
| 2339 |
-
"plan": "free",
|
| 2340 |
-
"plan_label": "Free",
|
| 2341 |
-
"plan_billing": "free",
|
| 2342 |
-
"plan_cancelled_at": _now_iso(),
|
| 2343 |
-
})
|
| 2344 |
-
logger.info(f"WEBHOOK | CANCELLATION: Firebase write SUCCESS. uid={uid}")
|
| 2345 |
-
except Exception as e:
|
| 2346 |
-
logger.critical(
|
| 2347 |
-
f"WEBHOOK | CANCELLATION: Firebase write FAILED uid={uid} "
|
| 2348 |
-
f"payment={payment_id}: {e}\n{traceback.format_exc()}"
|
| 2349 |
-
)
|
| 2350 |
-
return jsonify({"error": "Database write error."}), 500
|
| 2351 |
-
_log_payment_event(uid, plan_id, payment_id, 0, event)
|
| 2352 |
-
logger.info(f"WEBHOOK | CANCELLATION complete. uid={uid}")
|
| 2353 |
-
return jsonify({"success": True}), 200
|
| 2354 |
-
|
| 2355 |
-
logger.error(
|
| 2356 |
-
f"WEBHOOK | Fell through all branches. event='{event}' "
|
| 2357 |
-
f"uid={uid} payment={payment_id}"
|
| 2358 |
-
)
|
| 2359 |
return jsonify({"success": True}), 200
|
| 2360 |
|
| 2361 |
|
|
|
|
| 82 |
# =============================================================================
|
| 83 |
# PRICING CATALOGUE
|
| 84 |
# =============================================================================
|
| 85 |
+
# billing values:
|
| 86 |
+
# "subscription" β Paystack native recurring plan (starter/professional/executive)
|
| 87 |
+
# "one_time" β single charge, lifetime access
|
| 88 |
+
# "topup" β credit top-up purchase, no plan change
|
| 89 |
PLANS = {
|
| 90 |
"starter": {
|
| 91 |
"label": "Starter",
|
| 92 |
"price_zar": 149,
|
| 93 |
"monthly_credits": 115,
|
| 94 |
+
"billing": "subscription",
|
| 95 |
+
"paystack_plan_code": "PLN_ryg6ylw9zpwp4r7",
|
| 96 |
},
|
| 97 |
"professional": {
|
| 98 |
"label": "Professional",
|
| 99 |
"price_zar": 399,
|
| 100 |
"monthly_credits": 320,
|
| 101 |
+
"billing": "subscription",
|
| 102 |
+
"paystack_plan_code": "PLN_pxrcwq9fnc6lv9n",
|
| 103 |
},
|
| 104 |
"executive": {
|
| 105 |
"label": "Executive",
|
| 106 |
"price_zar": 899,
|
| 107 |
"monthly_credits": 750,
|
| 108 |
+
"billing": "subscription",
|
| 109 |
+
"paystack_plan_code": "PLN_alp803pz8oowu8u",
|
| 110 |
},
|
| 111 |
"lifetime": {
|
| 112 |
"label": "Infinite Prep (Lifetime)",
|
| 113 |
"price_zar": 3499,
|
| 114 |
"original_price_zar": 4999,
|
| 115 |
"monthly_credits": 400,
|
| 116 |
+
"billing": "one_time",
|
| 117 |
},
|
| 118 |
"topup_quick": {
|
| 119 |
"label": "Quick Refill",
|
|
|
|
| 151 |
PITCHFY_MARKER = "plan"
|
| 152 |
PITCHFY_MARKER_KEYS = {"plan", "createdAt"}
|
| 153 |
|
| 154 |
+
# Recurring billing is now handled entirely by Paystack native subscriptions.
|
| 155 |
+
# The cron sweep endpoint is disabled; this set is kept empty so no accidental
|
| 156 |
+
# server-side charges can occur.
|
| 157 |
+
BILLABLE_PLANS = set()
|
| 158 |
|
| 159 |
# =============================================================================
|
| 160 |
# FIREBASE & AI INIT
|
|
|
|
| 181 |
if not _gemini_key:
|
| 182 |
raise ValueError("'Gemini' env var not set.")
|
| 183 |
client = genai.Client(api_key=_gemini_key)
|
| 184 |
+
MODEL_NAME = "gemini-2.5-flash"
|
| 185 |
logger.info(f"Gemini initialized ({MODEL_NAME}).")
|
| 186 |
|
| 187 |
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY")
|
|
|
|
| 316 |
logger.error(f"Payment event log failed for {uid}: {e}")
|
| 317 |
|
| 318 |
|
| 319 |
+
def _payment_reference_exists(reference: str) -> bool:
|
| 320 |
+
"""
|
| 321 |
+
Prevents the same Paystack reference from being processed twice.
|
| 322 |
+
Both the callback verification flow and the webhook can fire for the same
|
| 323 |
+
charge β this guard makes the handler idempotent.
|
| 324 |
+
"""
|
| 325 |
+
if not reference:
|
| 326 |
+
return False
|
| 327 |
+
all_events = db_ref.child("payment_events").get() or {}
|
| 328 |
+
for uid_events in all_events.values():
|
| 329 |
+
if not isinstance(uid_events, dict):
|
| 330 |
+
continue
|
| 331 |
+
for event in uid_events.values():
|
| 332 |
+
if isinstance(event, dict) and str(event.get("payment_id")) == str(reference):
|
| 333 |
+
return True
|
| 334 |
+
return False
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _find_user_by_paystack_subscription(subscription_code: str):
|
| 338 |
+
"""
|
| 339 |
+
Looks up a Pitchfy user by their stored Paystack subscription code.
|
| 340 |
+
Returns (uid, user_data) or (None, None).
|
| 341 |
+
"""
|
| 342 |
+
if not subscription_code:
|
| 343 |
+
return None, None
|
| 344 |
+
users = _get_pitchfy_users()
|
| 345 |
+
for uid, user_data in users.items():
|
| 346 |
+
if user_data.get("paystack_subscription_code") == subscription_code:
|
| 347 |
+
return uid, user_data
|
| 348 |
+
return None, None
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _find_user_by_email(email: str):
|
| 352 |
+
"""
|
| 353 |
+
Looks up a Pitchfy user by email address (case-insensitive).
|
| 354 |
+
Returns (uid, user_data) or (None, None).
|
| 355 |
+
"""
|
| 356 |
+
if not email:
|
| 357 |
+
return None, None
|
| 358 |
+
users = _get_pitchfy_users()
|
| 359 |
+
for uid, user_data in users.items():
|
| 360 |
+
if (user_data.get("email") or "").lower() == email.lower():
|
| 361 |
+
return uid, user_data
|
| 362 |
+
return None, None
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _find_plan_id_by_paystack_plan_code(plan_code: str):
|
| 366 |
+
"""
|
| 367 |
+
Maps a Paystack plan code back to our internal plan_id.
|
| 368 |
+
Returns plan_id string or None.
|
| 369 |
+
"""
|
| 370 |
+
if not plan_code:
|
| 371 |
+
return None
|
| 372 |
+
for plan_id, plan in PLANS.items():
|
| 373 |
+
if plan.get("paystack_plan_code") == plan_code:
|
| 374 |
+
return plan_id
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
|
| 378 |
# =============================================================================
|
| 379 |
# 3. AI LOGIC FUNCTIONS
|
| 380 |
# =============================================================================
|
|
|
|
| 879 |
price = float(plan_cfg.get("price_zar", 0))
|
| 880 |
billing = plan_cfg.get("billing", "")
|
| 881 |
|
| 882 |
+
if event_type in ("subscription_activated", "monthly_renewal", "topup", "one_time_purchase"):
|
| 883 |
paying_uids.add(uid)
|
| 884 |
total_revenue_zar += price
|
| 885 |
revenue_by_plan[plan_id] += price
|
|
|
|
| 890 |
|
| 891 |
if event_type == "subscription_activated":
|
| 892 |
subscription_activations += 1
|
|
|
|
|
|
|
| 893 |
if prev_plan and prev_plan != plan_id:
|
| 894 |
upgrade_flows[f"{prev_plan}β{plan_id}"] += 1
|
| 895 |
prev_plan = plan_id
|
| 896 |
plan_history_by_uid[uid].append(plan_id)
|
| 897 |
+
elif event_type == "one_time_purchase":
|
| 898 |
+
lifetime_purchases += 1
|
| 899 |
+
plan_history_by_uid[uid].append(plan_id)
|
| 900 |
elif event_type == "monthly_renewal":
|
| 901 |
renewals += 1
|
| 902 |
elif event_type == "topup":
|
|
|
|
| 1078 |
if user_data:
|
| 1079 |
if user_data.get("suspended"):
|
| 1080 |
return jsonify({"error": "Account suspended. Contact support."}), 403
|
|
|
|
|
|
|
|
|
|
| 1081 |
backfill = {}
|
| 1082 |
if "plan" not in user_data:
|
| 1083 |
backfill["plan"] = "free"
|
|
|
|
| 1755 |
if not user_data or not any(k in user_data for k in PITCHFY_MARKER_KEYS):
|
| 1756 |
return jsonify({"error": "Pitchfy user not found."}), 404
|
| 1757 |
plan_cfg = PLANS[plan_id]
|
| 1758 |
+
billing = plan_cfg.get("billing")
|
| 1759 |
update = {
|
| 1760 |
"plan": plan_id,
|
| 1761 |
"plan_label": plan_cfg["label"],
|
|
|
|
| 1763 |
"plan_activated_at": _now_iso(),
|
| 1764 |
"plan_note": data.get("note", "Admin override"),
|
| 1765 |
}
|
| 1766 |
+
# Grant credits for any paid plan (subscription or one-time lifetime)
|
| 1767 |
+
if billing in ("subscription", "one_time"):
|
| 1768 |
+
update["credits"] = int(plan_cfg["monthly_credits"])
|
| 1769 |
update["last_credit_refresh"] = _now_iso()
|
| 1770 |
+
if billing == "one_time":
|
| 1771 |
update["is_lifetime"] = True
|
| 1772 |
user_ref.update(update)
|
| 1773 |
_log_payment_event(
|
| 1774 |
uid, plan_id, "admin_override",
|
| 1775 |
+
int(plan_cfg.get("monthly_credits", 0)), "admin_plan_set",
|
| 1776 |
)
|
| 1777 |
return jsonify({"success": True, "plan": plan_id, "credits": update.get("credits")}), 200
|
| 1778 |
except PermissionError as e:
|
| 1779 |
return jsonify({"error": str(e)}), 403
|
| 1780 |
except Exception as e:
|
| 1781 |
+
logger.error(f"ADMIN_SET_PLAN | Failed uid={uid}: {e}\n{traceback.format_exc()}")
|
| 1782 |
return jsonify({"error": str(e)}), 500
|
| 1783 |
|
| 1784 |
|
|
|
|
| 1813 |
return jsonify({"error": "User not found."}), 404
|
| 1814 |
plan_id = user_data.get("plan", "free")
|
| 1815 |
plan = PLANS.get(plan_id)
|
| 1816 |
+
if not plan or plan.get("billing") not in ("subscription", "one_time"):
|
| 1817 |
+
return jsonify({"error": f'Plan "{plan_id}" has no credit refresh.'}), 400
|
| 1818 |
+
credits = int(plan["monthly_credits"])
|
| 1819 |
user_ref.update({"credits": credits, "last_credit_refresh": _now_iso()})
|
| 1820 |
return jsonify({"success": True, "credits_loaded": credits}), 200
|
| 1821 |
except PermissionError as e:
|
|
|
|
| 1926 |
# =============================================================================
|
| 1927 |
# 15. PAYMENT WEBHOOK & BILLING (PAYSTACK)
|
| 1928 |
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1929 |
#
|
| 1930 |
+
# Architecture: Paystack native subscriptions handle all recurring billing.
|
| 1931 |
+
# Two complementary flows keep the DB in sync:
|
| 1932 |
+
#
|
| 1933 |
+
# (A) CALLBACK VERIFY β /api/payments/verify
|
| 1934 |
+
# Called by the frontend immediately after Paystack redirects back.
|
| 1935 |
+
# Verifies the transaction reference and activates the plan right away
|
| 1936 |
+
# so the user doesn't wait for a webhook. Idempotent via
|
| 1937 |
+
# _payment_reference_exists().
|
| 1938 |
+
#
|
| 1939 |
+
# (B) WEBHOOK β /api/webhooks/payment
|
| 1940 |
+
# Paystack-initiated events (recurring charge.success, subscription.disable).
|
| 1941 |
+
# Also idempotent β handles the same reference gracefully if (A) already
|
| 1942 |
+
# processed it. Falls back to subscription_code / email lookup when
|
| 1943 |
+
# metadata is absent (e.g. Paystack-triggered renewals).
|
| 1944 |
+
#
|
| 1945 |
+
# The cron billing-sweep endpoint is DISABLED β Paystack owns the recurring
|
| 1946 |
+
# schedule and fires webhooks; we never charge cards server-side.
|
| 1947 |
# ---------------------------------------------------------------------------
|
| 1948 |
|
| 1949 |
+
@app.route("/api/cron/billing-sweep", methods=["POST", "GET"])
|
| 1950 |
+
def trigger_billing_sweep():
|
| 1951 |
+
"""
|
| 1952 |
+
Disabled β Paystack native subscriptions handle recurring billing.
|
| 1953 |
+
Kept as a tombstone endpoint so old scheduler pings get a clear response
|
| 1954 |
+
rather than a 404.
|
| 1955 |
+
"""
|
| 1956 |
+
return jsonify({
|
| 1957 |
+
"success": False,
|
| 1958 |
+
"message": "Billing sweep disabled. Paystack native subscriptions are active.",
|
| 1959 |
+
}), 410
|
| 1960 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1961 |
|
| 1962 |
+
@app.route("/api/payments/initialize", methods=["POST"])
|
| 1963 |
+
def initialize_payment():
|
| 1964 |
+
"""
|
| 1965 |
+
Initialises a Paystack Checkout session and returns the hosted URL.
|
|
|
|
| 1966 |
|
| 1967 |
+
Body: { plan_id: str, callback_url: str }
|
|
|
|
|
|
|
| 1968 |
|
| 1969 |
+
For subscription plans the Paystack plan code is attached so Paystack
|
| 1970 |
+
creates the recurring subscription automatically.
|
| 1971 |
+
For one_time / topup plans, only the amount is sent.
|
| 1972 |
+
"""
|
| 1973 |
+
uid = verify_token(request.headers.get("Authorization"))
|
| 1974 |
+
if not uid:
|
| 1975 |
+
return jsonify({"error": "Unauthorized."}), 401
|
| 1976 |
|
| 1977 |
+
data = request.get_json() or {}
|
| 1978 |
+
plan_id = data.get("plan_id")
|
| 1979 |
+
callback_url = data.get("callback_url")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1980 |
|
| 1981 |
+
if not plan_id:
|
| 1982 |
+
return jsonify({"error": "plan_id is required."}), 400
|
| 1983 |
+
if not callback_url:
|
| 1984 |
+
return jsonify({"error": "callback_url is required."}), 400
|
| 1985 |
|
| 1986 |
+
plan = PLANS.get(plan_id)
|
| 1987 |
+
if not plan:
|
| 1988 |
+
return jsonify({"error": f"Invalid plan_id '{plan_id}'."}), 400
|
|
|
|
|
|
|
|
|
|
| 1989 |
|
| 1990 |
+
if not PAYSTACK_SECRET_KEY:
|
| 1991 |
+
logger.error("PAYMENTS_INIT | PAYSTACK_SECRET_KEY not configured.")
|
| 1992 |
+
return jsonify({"error": "Payment gateway not configured."}), 503
|
| 1993 |
|
| 1994 |
+
user_data = db_ref.child(f"users/{uid}").get() or {}
|
| 1995 |
+
email = user_data.get("email")
|
| 1996 |
+
if not email:
|
|
|
|
|
|
|
|
|
|
| 1997 |
try:
|
| 1998 |
+
fb_user = auth.get_user(uid)
|
| 1999 |
+
email = fb_user.email
|
| 2000 |
+
except Exception:
|
| 2001 |
+
email = None
|
| 2002 |
+
if not email:
|
| 2003 |
+
return jsonify({"error": "User email not found."}), 400
|
| 2004 |
+
|
| 2005 |
+
metadata = {
|
| 2006 |
+
"uid": uid,
|
| 2007 |
+
"plan_id": plan_id,
|
| 2008 |
+
"billing": plan.get("billing"),
|
| 2009 |
+
"custom_fields": [
|
| 2010 |
+
{"display_name": "Firebase UID", "variable_name": "uid", "value": uid},
|
| 2011 |
+
{"display_name": "Plan ID", "variable_name": "plan_id", "value": plan_id},
|
| 2012 |
+
],
|
| 2013 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2014 |
|
| 2015 |
+
payload = {
|
| 2016 |
+
"email": email,
|
| 2017 |
+
"callback_url": callback_url,
|
| 2018 |
+
"metadata": metadata,
|
| 2019 |
+
"amount": int(plan["price_zar"] * 100), # always include; Paystack ignores it for plan-based charges
|
| 2020 |
+
}
|
| 2021 |
|
| 2022 |
+
if plan.get("billing") == "subscription":
|
| 2023 |
+
paystack_plan_code = plan.get("paystack_plan_code")
|
| 2024 |
+
if not paystack_plan_code:
|
| 2025 |
+
return jsonify({"error": f"Missing Paystack plan code for '{plan_id}'."}), 500
|
| 2026 |
+
payload["plan"] = paystack_plan_code
|
| 2027 |
|
| 2028 |
+
try:
|
| 2029 |
+
res = requests.post(
|
| 2030 |
+
"https://api.paystack.co/transaction/initialize",
|
| 2031 |
+
json=payload,
|
| 2032 |
+
headers={
|
| 2033 |
+
"Authorization": f"Bearer {PAYSTACK_SECRET_KEY}",
|
| 2034 |
+
"Content-Type": "application/json",
|
| 2035 |
+
},
|
| 2036 |
+
timeout=15,
|
| 2037 |
+
).json()
|
| 2038 |
|
| 2039 |
+
if not res.get("status"):
|
| 2040 |
+
logger.warning(f"PAYMENTS_INIT | Paystack init failed: {json.dumps(res)}")
|
| 2041 |
+
return jsonify({"error": res.get("message", "Could not initialize payment.")}), 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2042 |
|
| 2043 |
+
return jsonify({
|
| 2044 |
+
"success": True,
|
| 2045 |
+
"authorization_url": res["data"]["authorization_url"],
|
| 2046 |
+
"access_code": res["data"]["access_code"],
|
| 2047 |
+
"reference": res["data"]["reference"],
|
| 2048 |
+
}), 200
|
| 2049 |
+
|
| 2050 |
+
except requests.RequestException as e:
|
| 2051 |
+
logger.error(f"PAYMENTS_INIT | Paystack API failed: {e}")
|
| 2052 |
+
return jsonify({"error": "Could not reach payment gateway."}), 502
|
| 2053 |
except Exception as e:
|
| 2054 |
+
logger.error(f"PAYMENTS_INIT | Unexpected error: {e}\n{traceback.format_exc()}")
|
| 2055 |
+
return jsonify({"error": "Payment initialization failed."}), 500
|
| 2056 |
|
| 2057 |
|
| 2058 |
@app.route("/api/payments/verify", methods=["POST"])
|
| 2059 |
def verify_initial_payment():
|
| 2060 |
"""
|
| 2061 |
+
Called by the frontend callback page after Paystack redirects back.
|
|
|
|
| 2062 |
|
| 2063 |
Body: { reference: str, plan_id: str }
|
| 2064 |
|
| 2065 |
+
Verifies the Paystack transaction, then activates the correct plan type:
|
| 2066 |
+
subscription β starter / professional / executive (Paystack native plan)
|
| 2067 |
+
one_time β lifetime access
|
| 2068 |
+
topup β add credits without changing plan
|
| 2069 |
"""
|
| 2070 |
uid = verify_token(request.headers.get("Authorization"))
|
| 2071 |
if not uid:
|
|
|
|
| 2082 |
if not plan_data:
|
| 2083 |
return jsonify({"error": f"Invalid plan_id '{plan_id}'."}), 400
|
| 2084 |
|
| 2085 |
+
# Idempotency β if the webhook already handled this reference, skip quietly
|
| 2086 |
+
if _payment_reference_exists(reference):
|
| 2087 |
+
logger.info(f"PAYMENTS_VERIFY | Already processed. uid={uid} reference={reference}")
|
| 2088 |
+
return jsonify({"success": True, "already_processed": True}), 200
|
| 2089 |
+
|
| 2090 |
if not PAYSTACK_SECRET_KEY:
|
| 2091 |
logger.error("PAYMENTS_VERIFY | PAYSTACK_SECRET_KEY not configured.")
|
| 2092 |
return jsonify({"error": "Payment gateway not configured on server."}), 503
|
|
|
|
| 2110 |
)
|
| 2111 |
return jsonify({"error": "Payment verification failed."}), 400
|
| 2112 |
|
| 2113 |
+
tx_data = res["data"]
|
| 2114 |
+
billing_type = plan_data.get("billing")
|
| 2115 |
+
customer = tx_data.get("customer") or {}
|
| 2116 |
+
subscription = tx_data.get("subscription") or {}
|
| 2117 |
+
authorization = tx_data.get("authorization") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2118 |
|
| 2119 |
user_ref = db_ref.child(f"users/{uid}")
|
| 2120 |
user_data = user_ref.get() or {}
|
| 2121 |
|
| 2122 |
try:
|
| 2123 |
if billing_type == "topup":
|
| 2124 |
+
credits_to_add = int(plan_data["credits"])
|
| 2125 |
+
new_credits = int(user_data.get("credits", 0)) + credits_to_add
|
| 2126 |
+
user_ref.update({"credits": new_credits, "last_topup_at": _now_iso()})
|
| 2127 |
_log_payment_event(uid, plan_id, reference, credits_to_add, "topup")
|
| 2128 |
logger.info(
|
| 2129 |
+
f"PAYMENTS_VERIFY | TOP-UP uid={uid} +{credits_to_add} "
|
| 2130 |
+
f"new_total={new_credits}"
|
| 2131 |
)
|
| 2132 |
|
| 2133 |
+
elif billing_type == "one_time":
|
| 2134 |
+
credits = int(plan_data["monthly_credits"])
|
| 2135 |
+
user_ref.update({
|
| 2136 |
+
"plan": plan_id,
|
| 2137 |
+
"plan_label": plan_data["label"],
|
| 2138 |
+
"plan_billing": "one_time",
|
| 2139 |
+
"credits": credits,
|
| 2140 |
+
"plan_activated_at": _now_iso(),
|
| 2141 |
+
"last_credit_refresh": _now_iso(),
|
| 2142 |
+
"is_lifetime": True,
|
| 2143 |
+
})
|
| 2144 |
+
_log_payment_event(uid, plan_id, reference, credits, "one_time_purchase")
|
| 2145 |
+
logger.info(
|
| 2146 |
+
f"PAYMENTS_VERIFY | ONE-TIME uid={uid} plan={plan_id} credits={credits}"
|
| 2147 |
+
)
|
| 2148 |
+
|
| 2149 |
+
elif billing_type == "subscription":
|
| 2150 |
+
credits = int(plan_data["monthly_credits"])
|
| 2151 |
+
update = {
|
| 2152 |
"plan": plan_id,
|
| 2153 |
"plan_label": plan_data["label"],
|
| 2154 |
+
"plan_billing": "subscription",
|
| 2155 |
+
"credits": credits,
|
| 2156 |
"plan_activated_at": _now_iso(),
|
| 2157 |
"last_credit_refresh": _now_iso(),
|
| 2158 |
}
|
| 2159 |
+
# Store Paystack identifiers for webhook resolution and cancellation
|
| 2160 |
+
if subscription.get("subscription_code"):
|
| 2161 |
+
update["paystack_subscription_code"] = subscription["subscription_code"]
|
| 2162 |
+
if subscription.get("email_token"):
|
| 2163 |
+
update["paystack_email_token"] = subscription["email_token"]
|
| 2164 |
+
if customer.get("customer_code"):
|
| 2165 |
+
update["paystack_customer_code"] = customer["customer_code"]
|
| 2166 |
+
if authorization.get("authorization_code"):
|
| 2167 |
+
update["paystack_authorization_code"] = authorization["authorization_code"]
|
| 2168 |
+
# Strip None values before writing
|
| 2169 |
+
update = {k: v for k, v in update.items() if v is not None}
|
| 2170 |
user_ref.update(update)
|
| 2171 |
+
_log_payment_event(uid, plan_id, reference, credits, "subscription_activated")
|
|
|
|
|
|
|
|
|
|
| 2172 |
logger.info(
|
| 2173 |
+
f"PAYMENTS_VERIFY | SUBSCRIPTION uid={uid} plan={plan_id} credits={credits} "
|
| 2174 |
+
f"sub_code={subscription.get('subscription_code')}"
|
| 2175 |
+
)
|
| 2176 |
+
|
| 2177 |
+
else:
|
| 2178 |
+
logger.error(
|
| 2179 |
+
f"PAYMENTS_VERIFY | Unhandled billing type '{billing_type}' plan={plan_id}"
|
| 2180 |
)
|
| 2181 |
+
return jsonify({"error": f"Unhandled billing type: {billing_type}"}), 400
|
| 2182 |
|
| 2183 |
return jsonify({"success": True}), 200
|
| 2184 |
|
| 2185 |
except Exception as e:
|
| 2186 |
logger.critical(
|
| 2187 |
+
f"PAYMENTS_VERIFY | Firebase write FAILED uid={uid} plan={plan_id} "
|
| 2188 |
f"reference={reference}: {e}\n{traceback.format_exc()}"
|
| 2189 |
)
|
| 2190 |
+
return jsonify({
|
| 2191 |
+
"error": "Payment verified but account update failed. Contact support."
|
| 2192 |
+
}), 500
|
| 2193 |
|
| 2194 |
|
| 2195 |
# ---------------------------------------------------------------------------
|
| 2196 |
+
# Paystack signature verification
|
|
|
|
|
|
|
| 2197 |
# ---------------------------------------------------------------------------
|
| 2198 |
|
| 2199 |
def _verify_paystack_signature(req):
|
|
|
|
| 2225 |
|
| 2226 |
@app.route("/api/webhooks/payment", methods=["POST"])
|
| 2227 |
def payment_webhook():
|
| 2228 |
+
"""
|
| 2229 |
+
Receives Paystack webhook events.
|
| 2230 |
+
|
| 2231 |
+
Handled events:
|
| 2232 |
+
charge.success β first payment, recurring renewal, or topup charge
|
| 2233 |
+
subscription.disable β user cancelled via Paystack dashboard or API
|
| 2234 |
+
|
| 2235 |
+
User resolution order (most β least reliable):
|
| 2236 |
+
1. uid from metadata.custom_fields (set by /api/payments/initialize)
|
| 2237 |
+
2. paystack_subscription_code (stored at first payment)
|
| 2238 |
+
3. customer email (last resort)
|
| 2239 |
+
|
| 2240 |
+
Plan resolution order:
|
| 2241 |
+
1. plan_id from metadata.custom_fields
|
| 2242 |
+
2. Paystack plan_code β PLANS lookup
|
| 2243 |
+
3. Current plan stored on the user record
|
| 2244 |
+
"""
|
| 2245 |
logger.info("WEBHOOK | Incoming Paystack webhook received.")
|
| 2246 |
+
|
| 2247 |
try:
|
| 2248 |
_verify_paystack_signature(request)
|
| 2249 |
except PermissionError as e:
|
|
|
|
| 2252 |
|
| 2253 |
payload = request.get_json(silent=True)
|
| 2254 |
if not payload:
|
| 2255 |
+
logger.error("WEBHOOK | Empty or invalid JSON payload.")
|
| 2256 |
return jsonify({"error": "Empty payload."}), 400
|
| 2257 |
|
| 2258 |
paystack_event = payload.get("event")
|
| 2259 |
+
data = payload.get("data", {}) or {}
|
| 2260 |
+
|
| 2261 |
logger.info(f"WEBHOOK | Event type: '{paystack_event}'")
|
| 2262 |
|
| 2263 |
if paystack_event not in ("charge.success", "subscription.disable"):
|
| 2264 |
+
logger.info(f"WEBHOOK | Event '{paystack_event}' ignored.")
|
| 2265 |
return jsonify({"success": True, "message": "Event not actionable."}), 200
|
| 2266 |
|
| 2267 |
+
payment_id = str(data.get("reference", "unknown"))
|
| 2268 |
+
channel = data.get("channel", "unknown")
|
| 2269 |
+
metadata = data.get("metadata", {}) or {}
|
| 2270 |
+
subscription_data = data.get("subscription") or {}
|
| 2271 |
+
customer_data = data.get("customer") or {}
|
| 2272 |
+
plan_data_from_paystack = data.get("plan") or {}
|
| 2273 |
|
| 2274 |
+
subscription_code = (
|
| 2275 |
+
subscription_data.get("subscription_code")
|
| 2276 |
+
if isinstance(subscription_data, dict) else None
|
| 2277 |
)
|
| 2278 |
+
customer_email = (
|
| 2279 |
+
customer_data.get("email")
|
| 2280 |
+
if isinstance(customer_data, dict) else None
|
| 2281 |
+
)
|
| 2282 |
+
paystack_plan_code = (
|
| 2283 |
+
plan_data_from_paystack.get("plan_code")
|
| 2284 |
+
if isinstance(plan_data_from_paystack, dict) else None
|
| 2285 |
+
)
|
| 2286 |
+
|
| 2287 |
+
custom_fields = metadata.get("custom_fields", []) or []
|
| 2288 |
+
uid = metadata.get("uid")
|
| 2289 |
+
plan_id = metadata.get("plan_id")
|
| 2290 |
|
|
|
|
| 2291 |
for f in custom_fields:
|
| 2292 |
var = f.get("variable_name")
|
| 2293 |
val = f.get("value")
|
| 2294 |
+
if var == "uid" and not uid: uid = val
|
| 2295 |
+
if var == "plan_id" and not plan_id: plan_id = val
|
|
|
|
|
|
|
|
|
|
| 2296 |
|
| 2297 |
+
user_data = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2298 |
|
| 2299 |
+
# Fallback resolution when metadata fields are absent (e.g. Paystack-initiated renewals)
|
| 2300 |
+
if not uid and subscription_code:
|
| 2301 |
+
uid, user_data = _find_user_by_paystack_subscription(subscription_code)
|
| 2302 |
+
if not uid and customer_email:
|
| 2303 |
+
uid, user_data = _find_user_by_email(customer_email)
|
| 2304 |
+
if not plan_id and paystack_plan_code:
|
| 2305 |
+
plan_id = _find_plan_id_by_paystack_plan_code(paystack_plan_code)
|
| 2306 |
|
| 2307 |
+
if not uid:
|
|
|
|
| 2308 |
logger.error(
|
| 2309 |
+
f"WEBHOOK | Could not resolve user. reference={payment_id} "
|
| 2310 |
+
f"subscription_code={subscription_code} customer_email={customer_email}"
|
| 2311 |
)
|
| 2312 |
+
return jsonify({"error": "Could not resolve user."}), 400
|
|
|
|
|
|
|
| 2313 |
|
|
|
|
| 2314 |
user_ref = db_ref.child(f"users/{uid}")
|
| 2315 |
+
if user_data is None:
|
| 2316 |
user_data = user_ref.get()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2317 |
|
| 2318 |
if not user_data:
|
| 2319 |
+
logger.error(f"WEBHOOK | User not found uid={uid} reference={payment_id}")
|
| 2320 |
+
return jsonify({"error": "Account not found."}), 404
|
| 2321 |
+
|
| 2322 |
+
if not plan_id:
|
| 2323 |
+
plan_id = user_data.get("plan")
|
| 2324 |
+
|
| 2325 |
+
plan = PLANS.get(plan_id)
|
| 2326 |
+
if not plan:
|
| 2327 |
logger.error(
|
| 2328 |
+
f"WEBHOOK | Could not resolve plan. uid={uid} plan_id={plan_id} reference={payment_id}"
|
|
|
|
| 2329 |
)
|
| 2330 |
+
return jsonify({"error": "Unrecognized plan."}), 400
|
| 2331 |
|
| 2332 |
+
billing = plan.get("billing")
|
| 2333 |
logger.info(
|
| 2334 |
+
f"WEBHOOK | Resolved uid={uid} plan_id={plan_id} billing={billing} "
|
| 2335 |
+
f"reference={payment_id} channel={channel}"
|
| 2336 |
)
|
| 2337 |
|
| 2338 |
+
# ββ subscription.disable βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2339 |
if paystack_event == "subscription.disable":
|
| 2340 |
+
if user_data.get("is_lifetime", False):
|
| 2341 |
+
logger.info(f"WEBHOOK | Subscription disable ignored for lifetime uid={uid}")
|
| 2342 |
+
return jsonify({"success": True, "message": "Lifetime unaffected."}), 200
|
| 2343 |
|
| 2344 |
+
try:
|
| 2345 |
+
user_ref.update({
|
| 2346 |
+
"credits": 0,
|
| 2347 |
+
"plan": "free",
|
| 2348 |
+
"plan_label": "Free",
|
| 2349 |
+
"plan_billing": "free",
|
| 2350 |
+
"plan_cancelled_at": _now_iso(),
|
| 2351 |
+
"paystack_subscription_status": "disabled",
|
| 2352 |
+
})
|
| 2353 |
+
_log_payment_event(uid, plan_id, payment_id, 0, "subscription_cancelled")
|
| 2354 |
+
logger.info(f"WEBHOOK | Subscription cancelled uid={uid} plan={plan_id}")
|
| 2355 |
+
return jsonify({"success": True}), 200
|
| 2356 |
+
except Exception as e:
|
| 2357 |
+
logger.critical(
|
| 2358 |
+
f"WEBHOOK | Cancellation write failed uid={uid}: {e}\n{traceback.format_exc()}"
|
| 2359 |
+
)
|
| 2360 |
+
return jsonify({"error": "Database write error."}), 500
|
| 2361 |
|
| 2362 |
+
# ββ charge.success βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2363 |
+
if paystack_event == "charge.success":
|
| 2364 |
+
# Idempotency β the callback verify may have already handled this
|
| 2365 |
+
if _payment_reference_exists(payment_id):
|
| 2366 |
+
logger.info(f"WEBHOOK | Reference already processed: {payment_id}")
|
| 2367 |
+
return jsonify({"success": True, "already_processed": True}), 200
|
| 2368 |
|
| 2369 |
+
try:
|
| 2370 |
+
if billing == "topup":
|
| 2371 |
+
credits = int(plan["credits"])
|
| 2372 |
+
credits_before = int(user_data.get("credits", 0))
|
| 2373 |
+
credits_after = credits_before + credits
|
| 2374 |
+
user_ref.update({"credits": credits_after, "last_topup_at": _now_iso()})
|
| 2375 |
+
_log_payment_event(uid, plan_id, payment_id, credits, "topup")
|
| 2376 |
+
logger.info(
|
| 2377 |
+
f"WEBHOOK | TOP-UP uid={uid} +{credits} credits_after={credits_after}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2378 |
)
|
| 2379 |
+
return jsonify({"success": True, "credits_loaded": credits}), 200
|
|
|
|
|
|
|
|
|
|
| 2380 |
|
| 2381 |
+
if billing == "one_time":
|
| 2382 |
+
credits = int(plan["monthly_credits"])
|
| 2383 |
+
user_ref.update({
|
| 2384 |
+
"credits": credits,
|
| 2385 |
+
"plan": plan_id,
|
| 2386 |
+
"plan_label": plan["label"],
|
| 2387 |
+
"plan_billing": "one_time",
|
| 2388 |
+
"plan_activated_at": _now_iso(),
|
| 2389 |
+
"last_credit_refresh": _now_iso(),
|
| 2390 |
+
"is_lifetime": True,
|
| 2391 |
+
})
|
| 2392 |
+
_log_payment_event(uid, plan_id, payment_id, credits, "one_time_purchase")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2393 |
logger.info(
|
| 2394 |
+
f"WEBHOOK | ONE-TIME uid={uid} plan={plan_id} credits={credits}"
|
|
|
|
| 2395 |
)
|
| 2396 |
+
return jsonify({"success": True, "credits_loaded": credits}), 200
|
| 2397 |
+
|
| 2398 |
+
if billing == "subscription":
|
| 2399 |
+
credits = int(plan["monthly_credits"])
|
| 2400 |
+
update = {
|
| 2401 |
+
"credits": credits,
|
| 2402 |
+
"plan": plan_id,
|
| 2403 |
+
"plan_label": plan["label"],
|
| 2404 |
+
"plan_billing": "subscription",
|
| 2405 |
+
"last_credit_refresh": _now_iso(),
|
| 2406 |
+
"paystack_subscription_status": "active",
|
| 2407 |
+
}
|
| 2408 |
+
if not user_data.get("plan_activated_at"):
|
| 2409 |
+
update["plan_activated_at"] = _now_iso()
|
| 2410 |
+
if subscription_code:
|
| 2411 |
+
update["paystack_subscription_code"] = subscription_code
|
| 2412 |
+
if isinstance(customer_data, dict) and customer_data.get("customer_code"):
|
| 2413 |
+
update["paystack_customer_code"] = customer_data["customer_code"]
|
| 2414 |
+
authorization = data.get("authorization") or {}
|
| 2415 |
+
if isinstance(authorization, dict) and authorization.get("authorization_code"):
|
| 2416 |
+
update["paystack_authorization_code"] = authorization["authorization_code"]
|
| 2417 |
+
|
| 2418 |
+
user_ref.update(update)
|
| 2419 |
+
event_type = "monthly_renewal" if channel == "recurring" else "subscription_activated"
|
| 2420 |
+
_log_payment_event(uid, plan_id, payment_id, credits, event_type)
|
| 2421 |
+
logger.info(
|
| 2422 |
+
f"WEBHOOK | SUBSCRIPTION uid={uid} plan={plan_id} "
|
| 2423 |
+
f"event={event_type} credits={credits}"
|
| 2424 |
)
|
| 2425 |
+
return jsonify({"success": True, "credits_loaded": credits}), 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2426 |
|
|
|
|
| 2427 |
logger.error(
|
| 2428 |
+
f"WEBHOOK | Unhandled billing type '{billing}' uid={uid} plan={plan_id}"
|
|
|
|
| 2429 |
)
|
| 2430 |
return jsonify({"error": f"Unhandled billing type: {billing}"}), 400
|
| 2431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2432 |
except Exception as e:
|
| 2433 |
logger.critical(
|
| 2434 |
+
f"WEBHOOK | DB write failed uid={uid} plan={plan_id} "
|
| 2435 |
+
f"reference={payment_id}: {e}\n{traceback.format_exc()}"
|
| 2436 |
)
|
| 2437 |
return jsonify({"error": "Database write error."}), 500
|
|
|
|
|
|
|
|
|
|
| 2438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2439 |
return jsonify({"success": True}), 200
|
| 2440 |
|
| 2441 |
|