rairo commited on
Commit
32febd6
Β·
verified Β·
1 Parent(s): af5e73e

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +416 -336
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": "monthly",
 
91
  },
92
  "professional": {
93
  "label": "Professional",
94
  "price_zar": 399,
95
  "monthly_credits": 320,
96
- "billing": "monthly",
 
97
  },
98
  "executive": {
99
  "label": "Executive",
100
  "price_zar": 899,
101
  "monthly_credits": 750,
102
- "billing": "monthly",
 
103
  },
104
  "lifetime": {
105
  "label": "Infinite Prep (Lifetime)",
106
  "price_zar": 3499,
107
  "original_price_zar": 4999,
108
  "monthly_credits": 400,
109
- "billing": "lifetime",
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
- # Plans that the cron billing sweep charges / refreshes
148
- BILLABLE_PLANS = {"starter", "professional", "executive", "lifetime"}
 
 
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-3.1-flash-lite"
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", "monthly")
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
- if billing in ("monthly", "lifetime"):
1701
- update["credits"] = plan_cfg["monthly_credits"]
 
1702
  update["last_credit_refresh"] = _now_iso()
1703
- if billing == "lifetime":
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 ("monthly", "lifetime"):
1749
- return jsonify({"error": f'Plan "{plan_id}" has no monthly refresh.'}), 400
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
- # Requires paystack_auth_code + paystack_email stored at first payment
1872
- # (written by /api/payments/verify when billing != 'topup').
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1873
  # ---------------------------------------------------------------------------
1874
 
1875
- def charge_monthly_subscriptions():
1876
- logger.info("CRON | Starting daily subscription billing sweep...")
1877
- # Use Pitchfy-scoped user list β€” never charge SozoFix-only accounts
1878
- users = _get_pitchfy_users()
1879
- now = datetime.now(timezone.utc)
1880
-
1881
- for uid, user_data in users.items():
1882
- plan_id = user_data.get("plan", "free")
1883
- if plan_id not in BILLABLE_PLANS:
1884
- continue
 
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
- try:
1892
- last_refresh_date = _parse_iso(last_refresh_str)
1893
- except Exception as e:
1894
- logger.error(f"CRON | Date parse error for uid={uid} ts='{last_refresh_str}': {e}")
1895
- continue
1896
 
1897
- days_since_refresh = (now - last_refresh_date).days
1898
- if days_since_refresh < 30:
1899
- continue
1900
 
1901
- logger.info(
1902
- f"CRON | uid={uid} plan={plan_id} due for renewal. "
1903
- f"Days since last refresh: {days_since_refresh}"
1904
- )
 
 
 
1905
 
1906
- # Lifetime: free monthly refill, no charge
1907
- if plan_id == "lifetime":
1908
- try:
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
- # Monthly plans: charge via Paystack stored authorization
1919
- auth_code = user_data.get("paystack_auth_code")
1920
- email = user_data.get("paystack_email")
 
1921
 
1922
- if not auth_code or not email:
1923
- logger.warning(
1924
- f"CRON | uid={uid} plan={plan_id} needs renewal but missing "
1925
- f"paystack_auth_code or paystack_email β€” cannot charge."
1926
- )
1927
- continue
1928
 
1929
- if not PAYSTACK_SECRET_KEY:
1930
- logger.error("CRON | PAYSTACK_SECRET_KEY not set β€” cannot charge subscriptions.")
1931
- break
1932
 
1933
- plan = PLANS[plan_id]
1934
- charge_payload = {
1935
- "authorization_code": auth_code,
1936
- "email": email,
1937
- "amount": int(plan["price_zar"] * 100), # Paystack uses kobo
1938
- }
1939
  try:
1940
- res = requests.post(
1941
- "https://api.paystack.co/transaction/charge_authorization",
1942
- json=charge_payload,
1943
- headers={"Authorization": f"Bearer {PAYSTACK_SECRET_KEY}"},
1944
- timeout=15,
1945
- ).json()
1946
-
1947
- if res.get("status") and res.get("data", {}).get("status") == "success":
1948
- reference = res["data"]["reference"]
1949
- db_ref.child(f"users/{uid}").update({
1950
- "credits": plan["monthly_credits"],
1951
- "last_credit_refresh": _now_iso(),
1952
- })
1953
- _log_payment_event(uid, plan_id, reference, plan["monthly_credits"], "monthly_renewal")
1954
- logger.info(
1955
- f"CRON | Successfully charged and renewed uid={uid} plan={plan_id} "
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
- except requests.RequestException as e:
1967
- logger.error(f"CRON | Paystack API request failed for uid={uid}: {e}")
1968
- except Exception as e:
1969
- logger.error(f"CRON | Unexpected error charging uid={uid}: {e}\n{traceback.format_exc()}")
 
 
1970
 
1971
- logger.info("CRON | Billing sweep complete.")
 
 
 
 
1972
 
 
 
 
 
 
 
 
 
 
 
1973
 
1974
- @app.route("/api/cron/billing-sweep", methods=["POST", "GET"])
1975
- def trigger_billing_sweep():
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
- try:
1992
- charge_monthly_subscriptions()
1993
- return jsonify({"success": True, "message": "Billing sweep completed."}), 200
 
 
 
 
 
 
 
1994
  except Exception as e:
1995
- logger.error(f"CRON | Sweep failed: {e}\n{traceback.format_exc()}")
1996
- return jsonify({"error": "Internal server error during sweep."}), 500
1997
 
1998
 
1999
  @app.route("/api/payments/verify", methods=["POST"])
2000
  def verify_initial_payment():
2001
  """
2002
- Called by the frontend after a successful Paystack inline payment to verify
2003
- the transaction and activate the plan / add credits.
2004
 
2005
  Body: { reference: str, plan_id: str }
2006
 
2007
- For subscription plans (monthly/lifetime): stores paystack_auth_code +
2008
- paystack_email on the user record so the cron sweep can charge recurring.
2009
- For topups: just adds credits, no auth code stored.
 
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 = res["data"]
2050
- auth_code = tx_data.get("authorization", {}).get("authorization_code")
2051
- customer_email = tx_data.get("customer", {}).get("email")
2052
- billing_type = plan_data.get("billing")
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 applied: uid={uid} +{credits_to_add} credits "
2070
- f"(new total={new_credits})."
2071
  )
2072
 
2073
- else:
2074
- # monthly or lifetime
2075
- update = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2076
  "plan": plan_id,
2077
  "plan_label": plan_data["label"],
2078
- "plan_billing": billing_type,
2079
- "credits": plan_data["monthly_credits"],
2080
  "plan_activated_at": _now_iso(),
2081
  "last_credit_refresh": _now_iso(),
2082
  }
2083
- if auth_code:
2084
- update["paystack_auth_code"] = auth_code
2085
- if customer_email:
2086
- update["paystack_email"] = customer_email
2087
- if billing_type == "lifetime":
2088
- update["is_lifetime"] = True
 
 
 
 
 
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 activated: uid={uid} plan={plan_id} "
2096
- f"credits={plan_data['monthly_credits']} auth_code_stored={bool(auth_code)}."
 
 
 
 
 
2097
  )
 
2098
 
2099
  return jsonify({"success": True}), 200
2100
 
2101
  except Exception as e:
2102
  logger.critical(
2103
- f"PAYMENTS_VERIFY | Firebase write FAILED for uid={uid} plan={plan_id} "
2104
  f"reference={reference}: {e}\n{traceback.format_exc()}"
2105
  )
2106
- return jsonify({"error": "Payment verified but account update failed. Contact support."}), 500
 
 
2107
 
2108
 
2109
  # ---------------------------------------------------------------------------
2110
- # Paystack webhook (supplementary β€” fires for recurring charges and
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 | Body parsed to None β€” Content-Type may not be application/json.")
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}' is not actionable β€” ignored.")
2161
  return jsonify({"success": True, "message": "Event not actionable."}), 200
2162
 
2163
- data = payload.get("data", {})
2164
- payment_id = str(data.get("reference", "unknown"))
2165
- channel = data.get("channel", "unknown")
2166
- amount_kobo = data.get("amount", 0)
2167
- metadata = data.get("metadata", {})
2168
- custom_fields = metadata.get("custom_fields", [])
2169
 
2170
- logger.info(
2171
- f"WEBHOOK | Reference={payment_id} | Channel={channel} | "
2172
- f"Amount={amount_kobo} kobo | Custom fields count={len(custom_fields)}"
2173
  )
2174
- logger.debug(f"WEBHOOK | Full metadata: {json.dumps(metadata)}")
 
 
 
 
 
 
 
 
 
 
 
2175
 
2176
- uid = plan_id = None
2177
  for f in custom_fields:
2178
  var = f.get("variable_name")
2179
  val = f.get("value")
2180
- logger.info(f"WEBHOOK | Custom field: variable_name='{var}' value='{val}'")
2181
- if var == "uid":
2182
- uid = val
2183
- elif var == "plan_id":
2184
- plan_id = val
2185
 
2186
- if not uid:
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
- logger.info(f"WEBHOOK | Extracted uid={uid} plan_id={plan_id} payment_id={payment_id}")
 
 
 
 
 
 
2201
 
2202
- plan = PLANS.get(plan_id)
2203
- if not plan:
2204
  logger.error(
2205
- f"WEBHOOK | CRITICAL β€” plan_id='{plan_id}' not in PLANS catalogue. "
2206
- f"Valid: {list(PLANS.keys())}. uid={uid} payment={payment_id}."
2207
  )
2208
- return jsonify({"error": "Unrecognized plan."}), 400
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
- try:
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 | CRITICAL β€” No Firebase user record for uid={uid}. "
2226
- f"Payment {payment_id} for plan '{plan_id}' CANNOT be applied."
2227
  )
2228
- return jsonify({"error": "Account not found."}), 404
2229
 
 
2230
  logger.info(
2231
- f"WEBHOOK | User found: email={user_data.get('email')} "
2232
- f"current_plan={user_data.get('plan','free')} credits={user_data.get('credits',0)}"
2233
  )
2234
 
2235
- event = "payment_confirmed"
2236
  if paystack_event == "subscription.disable":
2237
- event = "subscription_cancelled"
2238
- elif paystack_event == "charge.success" and channel == "recurring":
2239
- event = "monthly_renewal"
2240
 
2241
- logger.info(f"WEBHOOK | Internal event classified as: '{event}'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2242
 
2243
- if event == "payment_confirmed":
2244
- billing = plan["billing"]
2245
- logger.info(f"WEBHOOK | Processing payment_confirmed. billing={billing}")
 
 
 
2246
 
2247
- if billing == "topup":
2248
- credits = plan["credits"]
2249
- credits_before = user_data.get("credits", 0)
2250
- credits_after = credits_before + credits
2251
- logger.info(
2252
- f"WEBHOOK | TOP-UP: uid={uid} +{credits} credits. "
2253
- f"Before={credits_before} After={credits_after}"
2254
- )
2255
- try:
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({"error": "Database write error."}), 500
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
- elif billing in ("monthly", "lifetime"):
2269
- credits = plan["monthly_credits"]
2270
- update = {
2271
- "credits": credits,
2272
- "plan": plan_id,
2273
- "plan_label": plan["label"],
2274
- "plan_billing": billing,
2275
- "plan_activated_at": _now_iso(),
2276
- "last_credit_refresh": _now_iso(),
2277
- }
2278
- if billing == "lifetime":
2279
- update["is_lifetime"] = True
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 | SUBSCRIPTION ACTIVATE: Firebase write SUCCESS. "
2288
- f"uid={uid} now on plan='{plan_id}' credits={credits}"
2289
  )
2290
- except Exception as e:
2291
- logger.critical(
2292
- f"WEBHOOK | SUBSCRIPTION ACTIVATE: Firebase write FAILED uid={uid} "
2293
- f"payment={payment_id} plan={plan_id}: {e}\n{traceback.format_exc()}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2294
  )
2295
- return jsonify({"error": "Database write error."}), 500
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 | UNHANDLED billing type '{billing}' plan='{plan_id}' "
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 | MONTHLY RENEWAL: Firebase write FAILED uid={uid} "
2324
- f"payment={payment_id}: {e}\n{traceback.format_exc()}"
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