Pant0x's picture
Add HF Space Gradio SDK backend (free tier)
34a66f3
Raw
History Blame Contribute Delete
38.5 kB
import math
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from uuid import uuid4
import requests
from fastapi import APIRouter, Body, Header, HTTPException
from starlette.responses import JSONResponse
from app.core.config import (
SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_TIMEOUT_SECONDS,
PAYMOB_BASE_URL, PAYMOB_API_KEY, PAYMOB_INTEGRATION_ID, PAYMOB_IFRAME_ID,
PAYMOB_CURRENCY, GARAGE_TOTAL_CAPACITY, VEHICLE_SELECT_FIELDS,
supabase_configured, paymob_configured, is_staff_role, can_view_global_records, LOGGER,
)
from app.security.auth import (
_normalize_spaces, _supabase_headers, require_authenticated_user,
)
from app.api.helpers import _normalize_parking_location, _compose_full_name
from app.api.parking import compute_session_time_and_fee, _parse_iso_datetime
TZ_UTC = timezone.utc
router = APIRouter(tags=["payments"])
def _error_response(status_code: int, message: str, code: str) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={"status": "error", "message": message, "code": code},
)
def _resolve_requester_role(request_user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(request_user, dict):
return None
inline_role = str(request_user.get("role") or "").strip().lower()
if inline_role:
return inline_role
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
return None
return _supabase_get_profile_role(requester_id)
def _supabase_get_profile_role(user_id: Optional[str]) -> Optional[str]:
if not user_id or not supabase_configured():
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={"select": "role", "id": f"eq.{user_id}", "limit": "1"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
return None
rows = response.json()
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
role = rows[0].get("role")
return str(role).strip().lower() if role else None
def _supabase_get_profile_by_user_id(user_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not user_id or not supabase_configured():
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={"select": "id,first_name,last_name,full_name,username,phone_number,email,national_id,role,staff_id", "id": f"eq.{user_id}", "limit": "1"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch profile for %s: %s", user_id, response.text[:240])
return None
rows = response.json()
if not isinstance(rows, list) or not rows:
return None
first_row = rows[0]
if not isinstance(first_row, dict):
return None
return first_row
def _fetch_parking_session_by_id(session_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not supabase_configured() or not session_id:
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"select": "id,vehicle_id,check_in_at,payment_confirmed_at,check_out_at,left_within_5_minutes,status,parking_location,created_by,notes,created_at,updated_at", "id": f"eq.{session_id}", "limit": "1"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch parking session by id %s: %s", session_id, response.text[:300])
return None
rows = response.json()
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
return rows[0]
def _derive_payment_status_from_session(session_row: Optional[Dict[str, Any]]) -> str:
if not isinstance(session_row, dict):
return "no_active_session"
payment_confirmed_at = session_row.get("payment_confirmed_at")
check_out_at = session_row.get("check_out_at")
left_within_5 = bool(session_row.get("left_within_5_minutes"))
status_value = str(session_row.get("status") or "").strip().lower()
if payment_confirmed_at is None and check_out_at is None:
return "unpaid"
if payment_confirmed_at is not None and check_out_at is None:
if status_value == "overstayed":
return "paid_grace_period_expired"
return "paid_waiting_exit"
if payment_confirmed_at is None and check_out_at is not None:
return "left_without_payment"
if left_within_5:
return "left_within_5_minutes_after_payment"
return "left_after_5_minutes_after_payment"
def _build_gate_decision(*, event_type: str, session_row: Optional[Dict[str, Any]], plate_detected: bool) -> Dict[str, Any]:
if event_type != "exit":
return {"allowed": None, "action": "not_applicable", "reason": "gate_rules_apply_to_exit_only"}
if not plate_detected:
return {"allowed": False, "action": "manual_open_only", "reason": "plate_not_detected"}
payment_status = _derive_payment_status_from_session(session_row)
if payment_status in {"paid_waiting_exit", "left_within_5_minutes_after_payment"}:
return {"allowed": True, "action": "open_gate", "reason": "paid_within_grace_period", "payment_status": payment_status}
if payment_status == "paid_grace_period_expired":
return {"allowed": False, "action": "deny_and_alert_security", "reason": "grace_period_expired", "payment_status": payment_status}
if payment_status == "no_active_session":
return {"allowed": False, "action": "manual_review_required", "reason": "no_active_session", "payment_status": payment_status}
return {"allowed": False, "action": "deny_gate", "reason": "payment_not_confirmed", "payment_status": payment_status}
def _insert_parking_session(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not supabase_configured():
return None
response = requests.post(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
json=payload,
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json", prefer="return=representation"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
LOGGER.error("Failed to insert parking session (%s): %s", response.status_code, response.text[:300])
return None
rows = response.json()
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
return rows[0]
def _update_parking_session(session_id: str, patch_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not supabase_configured() or not session_id:
return None
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"id": f"eq.{session_id}"},
json=patch_payload,
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json", prefer="return=representation"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(f"Failed to update parking session ({response.status_code}): {response.text[:300]}")
if response.status_code == 204:
return _fetch_parking_session_by_id(session_id)
rows = response.json()
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return _fetch_parking_session_by_id(session_id)
def _link_unmatched_events_to_session(plate_ar: str, session_id: str) -> None:
if not supabase_configured() or not plate_ar or not session_id:
return
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/car_events",
params={"session_id": "is.null", "ocr_arabic": f"eq.{plate_ar}"},
json={"session_id": session_id},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
LOGGER.warning("Failed to link unmatched events for plate %s to session %s: %s", plate_ar, session_id, response.text[:300])
def _format_vehicle_plate_label(vehicle_row: Optional[Dict[str, Any]]) -> str:
if not isinstance(vehicle_row, dict):
return ""
letters_ar = _normalize_spaces(str(vehicle_row.get("plate_letters_ar") or ""))
numbers_ar = _normalize_spaces(str(vehicle_row.get("plate_numbers_ar") or ""))
if letters_ar or numbers_ar:
return _normalize_spaces(f"{letters_ar} {numbers_ar}")
letters_en = _normalize_spaces(str(vehicle_row.get("plate_letters_en") or ""))
numbers_en = _normalize_spaces(str(vehicle_row.get("plate_numbers_en") or ""))
if letters_en or numbers_en:
return _normalize_spaces(f"{letters_en} {numbers_en}")
return ""
def _fetch_vehicle_map_by_ids(vehicle_ids: List[str]) -> Dict[str, Dict[str, Any]]:
if not vehicle_ids or not supabase_configured():
return {}
response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={"select": VEHICLE_SELECT_FIELDS, "id": f"in.({','.join(sorted(set(vehicle_ids)))})"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch vehicle map: %s", response.text[:280])
return {}
rows = response.json()
if not isinstance(rows, list):
return {}
result: Dict[str, Dict[str, Any]] = {}
for row in rows:
if isinstance(row, dict) and row.get("id"):
result[str(row["id"])] = row
return result
def _resolve_plate_label_for_session(session_row: Optional[Dict[str, Any]]) -> str:
if not isinstance(session_row, dict):
return ""
plate_text = _normalize_spaces(str(session_row.get("plate_arabic") or ""))
if plate_text:
return plate_text
plate_text = _normalize_spaces(str(session_row.get("plate_english") or ""))
if plate_text:
return plate_text
vehicle_id = str(session_row.get("vehicle_id") or "").strip()
if not vehicle_id:
return ""
vehicle_map = _fetch_vehicle_map_by_ids([vehicle_id])
return _format_vehicle_plate_label(vehicle_map.get(vehicle_id))
def _get_parking_session_owner_id(session_id: str) -> Optional[str]:
if not supabase_configured() or not session_id:
return None
session_response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"select": "vehicle_id", "id": f"eq.{session_id}", "limit": "1"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if session_response.status_code != 200:
return None
session_rows = session_response.json()
if not isinstance(session_rows, list) or not session_rows:
return None
vehicle_id = session_rows[0].get("vehicle_id")
if not vehicle_id:
return None
vehicle_response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={"select": "owner_id", "id": f"eq.{vehicle_id}", "limit": "1"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if vehicle_response.status_code != 200:
return None
vehicle_rows = vehicle_response.json()
if not isinstance(vehicle_rows, list) or not vehicle_rows:
return None
owner_id = vehicle_rows[0].get("owner_id")
return str(owner_id) if owner_id else None
def _insert_notification_event(*, user_id: Optional[str], event_type: str, title: str, body: str, data: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not supabase_configured() or not user_id:
return None
payload: Dict[str, Any] = {"user_id": user_id, "event_type": event_type, "title": title, "body": body, "data": data or {}}
response = requests.post(
f"{SUPABASE_URL}/rest/v1/notification_events",
json=payload,
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json", prefer="return=representation"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
LOGGER.warning("Failed to insert notification event: %s", response.text[:220])
return None
rows = response.json() if response.text else []
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
notification_id = rows[0].get("id")
if notification_id:
return str(notification_id)
return None
def _list_security_user_ids() -> List[str]:
if not supabase_configured():
return []
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={"select": "id", "role": "eq.security", "limit": "1000"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch security profiles: %s", response.text[:240])
return []
rows = response.json()
if not isinstance(rows, list):
return []
return [str(row.get("id")) for row in rows if isinstance(row, dict) and row.get("id")]
def _list_security_user_ids_with_tokens() -> List[str]:
security_user_ids = _list_security_user_ids()
if not security_user_ids:
return []
response = requests.get(
f"{SUPABASE_URL}/rest/v1/notification_device_tokens",
params={"select": "user_id", "user_id": f"in.({','.join(sorted(set(security_user_ids)))})", "is_active": "eq.true", "limit": "10000"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch security device tokens: %s", response.text[:240])
return []
rows = response.json()
if not isinstance(rows, list):
return []
return sorted({str(row.get("user_id")) for row in rows if isinstance(row, dict) and row.get("user_id")})
def _notify_security_staff(title: str, body: str, data: Optional[Dict[str, Any]] = None) -> int:
security_user_ids = _list_security_user_ids_with_tokens()
if not security_user_ids:
return 0
count = 0
for user_id in security_user_ids:
notification_id = _insert_notification_event(user_id=user_id, event_type="cash_payment_needed", title=title, body=body, data=data)
if notification_id:
count += 1
return count
def _set_parking_session_location(session_id: str, parking_location: Optional[str]) -> None:
if not supabase_configured() or not session_id or not parking_location:
return
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"id": f"eq.{session_id}"},
json={"parking_location": parking_location},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(f"Failed to set parking location ({response.status_code}): {response.text[:280]}")
def _mark_parking_session_paid(session_id: str, payment_reference: Optional[str] = None) -> None:
if not supabase_configured() or not session_id:
return
patch_payload: Dict[str, Any] = {"payment_confirmed_at": datetime.now(timezone.utc).isoformat(), "status": "paid"}
if payment_reference:
patch_payload["notes"] = f"Paymob ref: {payment_reference}"
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"id": f"eq.{session_id}"},
json=patch_payload,
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(f"Failed to update parking session as paid ({response.status_code}): {response.text[:280]}")
def _insert_payment_transaction_record(*, created_by: Optional[str], session_id: str, amount_cents: int, currency: str, merchant_order_id: str, paymob_order_id: Optional[str], parking_location: Optional[str], request_payload: Dict[str, Any], response_payload: Dict[str, Any], provider: str = "paymob", status: str = "pending", payment_reference: Optional[str] = None) -> Optional[str]:
if not supabase_configured():
return None
payload: Dict[str, Any] = {"provider": provider, "status": status, "session_id": session_id, "amount_cents": amount_cents, "currency": currency, "merchant_order_id": merchant_order_id, "request_payload": request_payload, "response_payload": response_payload}
if created_by:
payload["created_by"] = created_by
if paymob_order_id:
payload["paymob_order_id"] = paymob_order_id
if payment_reference:
payload["payment_reference"] = payment_reference
if parking_location:
payload["parking_location"] = parking_location
response = requests.post(
f"{SUPABASE_URL}/rest/v1/payment_transactions",
json=payload,
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json", prefer="return=representation"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
raise RuntimeError(f"Failed to insert payment_transactions row ({response.status_code}): {response.text[:400]}")
rows = response.json()
if isinstance(rows, list) and rows:
payment_id = rows[0].get("id")
return str(payment_id) if payment_id else None
return None
def _update_payment_transaction_by_merchant_order_id(*, merchant_order_id: str, status: str, payment_reference: Optional[str], paymob_order_id: Optional[str], response_payload: Dict[str, Any]) -> None:
if not supabase_configured() or not merchant_order_id:
return
patch_payload: Dict[str, Any] = {"status": status, "response_payload": response_payload}
if payment_reference:
patch_payload["payment_reference"] = payment_reference
if paymob_order_id:
patch_payload["paymob_order_id"] = paymob_order_id
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/payment_transactions",
params={"merchant_order_id": f"eq.{merchant_order_id}"},
json=patch_payload,
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY, content_type="application/json"),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(f"Failed to update payment transaction ({response.status_code}): {response.text[:280]}")
def _get_payment_session_id_by_merchant_order_id(merchant_order_id: str) -> Optional[str]:
if not supabase_configured() or not merchant_order_id:
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/payment_transactions",
params={"select": "session_id", "merchant_order_id": f"eq.{merchant_order_id}", "limit": "1"},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
return None
rows = response.json()
if not isinstance(rows, list) or not rows:
return None
session_id = rows[0].get("session_id")
return str(session_id) if session_id else None
def _call_paymob_api(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
url = f"{PAYMOB_BASE_URL}{path}"
try:
response = requests.post(url, json=payload, timeout=SUPABASE_TIMEOUT_SECONDS)
except requests.RequestException as exc:
raise RuntimeError(f"Paymob API call failed: {exc}") from exc
if response.status_code not in {200, 201}:
raise RuntimeError(f"Paymob API error ({response.status_code}): {response.text[:400]}")
try:
body = response.json()
except ValueError as exc:
raise RuntimeError("Paymob returned a non-JSON response.") from exc
if not isinstance(body, dict):
raise RuntimeError("Unexpected Paymob response format.")
return body
def _paymob_create_checkout(*, amount_cents: int, currency: str, merchant_order_id: str, request_user: Dict[str, Any]) -> Dict[str, Any]:
auth_payload = {"api_key": PAYMOB_API_KEY}
auth_response = _call_paymob_api("/auth/tokens", auth_payload)
auth_token = str(auth_response.get("token") or "").strip()
if not auth_token:
raise RuntimeError("Paymob did not return an auth token.")
order_payload = {"auth_token": auth_token, "delivery_needed": False, "amount_cents": str(amount_cents), "currency": currency, "merchant_order_id": merchant_order_id, "items": []}
order_response = _call_paymob_api("/ecommerce/orders", order_payload)
order_id = order_response.get("id")
if order_id is None:
raise RuntimeError("Paymob did not return an order id.")
billing_data = {"first_name": "Ain", "last_name": "User", "email": request_user.get("email") or "user@example.com", "phone_number": "NA", "apartment": "NA", "floor": "NA", "street": "NA", "building": "NA", "shipping_method": "NA", "postal_code": "NA", "city": "Cairo", "country": "EG", "state": "Cairo"}
payment_key_payload = {"auth_token": auth_token, "amount_cents": str(amount_cents), "expiration": 3600, "order_id": order_id, "currency": currency, "integration_id": int(PAYMOB_INTEGRATION_ID), "billing_data": billing_data}
payment_key_response = _call_paymob_api("/acceptance/payment_keys", payment_key_payload)
payment_token = str(payment_key_response.get("token") or "").strip()
if not payment_token:
raise RuntimeError("Paymob did not return a payment token.")
return {
"paymob_order_id": str(order_id),
"payment_token": payment_token,
"iframe_url": f"{PAYMOB_BASE_URL}/acceptance/iframes/{PAYMOB_IFRAME_ID}?payment_token={payment_token}",
"auth_response": auth_response,
"order_response": order_response,
"payment_key_response": payment_key_response,
}
@router.post("/payments/manual/cash-confirm")
def manual_cash_confirm_payment(payload: Dict[str, Any] = Body(...), authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
request_user = require_authenticated_user(authorization)
if request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required.")
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
raise HTTPException(status_code=401, detail="Authenticated user id is missing.")
requester_role = _resolve_requester_role(request_user)
if not is_staff_role(requester_role):
raise HTTPException(status_code=403, detail="Only admin/security can confirm manual cash payments.")
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id is required.")
parking_location = _normalize_parking_location(payload.get("parking_location"))
session_row = _fetch_parking_session_by_id(session_id)
if not isinstance(session_row, dict):
raise HTTPException(status_code=404, detail="parking session was not found.")
if parking_location:
_set_parking_session_location(session_id, parking_location)
session_row = _fetch_parking_session_by_id(session_id) or session_row
pricing = compute_session_time_and_fee(session_row)
amount_raw = payload.get("amount_cents")
if amount_raw is None:
amount_cents = max(int(pricing.get("total_fee_cents") or 0), 1)
else:
try:
amount_cents = int(amount_raw)
except Exception:
raise HTTPException(status_code=400, detail="amount_cents must be a positive integer.")
if amount_cents <= 0:
raise HTTPException(status_code=400, detail="amount_cents must be greater than zero.")
currency = str(payload.get("currency") or PAYMOB_CURRENCY).strip().upper() or PAYMOB_CURRENCY
manual_reference = str(payload.get("manual_reference") or f"cash-{uuid4().hex[:18]}").strip()
manual_reference = manual_reference or f"cash-{uuid4().hex[:18]}"
already_paid = _parse_iso_datetime(session_row.get("payment_confirmed_at")) is not None
if not already_paid:
_mark_parking_session_paid(session_id, payment_reference=manual_reference)
payment_transaction_id: Optional[str] = None
if not already_paid:
payment_transaction_id = _insert_payment_transaction_record(
created_by=requester_id,
session_id=session_id,
amount_cents=amount_cents,
currency=currency,
merchant_order_id=f"cash-{session_id[:8]}-{uuid4().hex[:12]}",
paymob_order_id=None,
parking_location=parking_location,
request_payload={"session_id": session_id, "amount_cents": amount_cents, "currency": currency, "manual_reference": manual_reference, "parking_location": parking_location},
response_payload={"manual_reference": manual_reference, "confirmed_by": requester_id, "confirmed_by_role": requester_role},
provider="cash",
status="paid",
payment_reference=manual_reference,
)
updated_session = _fetch_parking_session_by_id(session_id) or session_row
updated_pricing = compute_session_time_and_fee(updated_session)
return {
"status": "ok",
"provider": "cash",
"already_paid": already_paid,
"session_id": session_id,
"payment_transaction_id": payment_transaction_id,
"amount_cents": amount_cents,
"currency": currency,
"payment_reference": manual_reference,
"pricing": updated_pricing,
"payment_status": _derive_payment_status_from_session(updated_session),
"gate_decision": _build_gate_decision(event_type="exit", session_row=updated_session, plate_detected=True),
}
@router.post("/payments/demo/confirm")
def demo_confirm_payment(payload: Dict[str, Any] = Body(...), authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
request_user = require_authenticated_user(authorization)
if request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required.")
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
raise HTTPException(status_code=401, detail="Authenticated user id is missing.")
requester_role = _resolve_requester_role(request_user)
requester_is_staff = is_staff_role(requester_role)
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id is required.")
session_row = _fetch_parking_session_by_id(session_id)
if not isinstance(session_row, dict):
raise HTTPException(status_code=404, detail="parking session was not found.")
owner_id = _get_parking_session_owner_id(session_id)
if owner_id and not requester_is_staff and owner_id != requester_id:
raise HTTPException(status_code=403, detail="You can only confirm payments for your own sessions.")
already_paid = _parse_iso_datetime(session_row.get("payment_confirmed_at")) is not None
if not already_paid:
_mark_parking_session_paid(session_id)
pricing = compute_session_time_and_fee(session_row)
amount_cents = max(int(pricing.get("total_fee_cents") or 0), 1)
_insert_payment_transaction_record(
created_by=requester_id,
session_id=session_id,
amount_cents=amount_cents,
currency=PAYMOB_CURRENCY,
merchant_order_id=f"demo-{session_id[:8]}-{uuid4().hex[:10]}",
paymob_order_id=None,
parking_location=_normalize_parking_location(session_row.get("parking_location")),
request_payload={"session_id": session_id, "provider": "demo_online"},
response_payload={"status": "paid"},
provider="demo_online",
status="paid",
)
if owner_id:
_insert_notification_event(
user_id=owner_id,
event_type="payment_confirmed",
title="Payment Confirmed :white_check_mark:",
body="تم تأكيد دفعك — عندك 5 دقايق للخروج",
data={"session_id": session_id, "payment_method": "demo_online"},
)
updated_session = _fetch_parking_session_by_id(session_id) or session_row
return {
"session_id": session_id,
"payment_status": _derive_payment_status_from_session(updated_session),
"gate_decision": _build_gate_decision(event_type="exit", session_row=updated_session, plate_detected=True),
"left_within_5_minutes": bool(updated_session.get("left_within_5_minutes")),
}
@router.post("/payments/cash/request")
def request_cash_payment(payload: Dict[str, Any] = Body(...), authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
request_user = require_authenticated_user(authorization)
if request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required.")
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
raise HTTPException(status_code=401, detail="Authenticated user id is missing.")
requester_role = _resolve_requester_role(request_user)
requester_is_staff = is_staff_role(requester_role)
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id is required.")
session_row = _fetch_parking_session_by_id(session_id)
if not isinstance(session_row, dict):
raise HTTPException(status_code=404, detail="parking session was not found.")
owner_id = _get_parking_session_owner_id(session_id)
if owner_id and not requester_is_staff and owner_id != requester_id:
raise HTTPException(status_code=403, detail="You can only request cash for your own sessions.")
profile = _supabase_get_profile_by_user_id(requester_id) or {}
requester_name = _normalize_spaces(str(profile.get("full_name") or profile.get("username") or profile.get("first_name") or ""))
if not requester_name:
requester_name = "اليوزر"
plate_label = _resolve_plate_label_for_session(session_row)
if not plate_label:
plate_label = _normalize_spaces(str(payload.get("plate_arabic") or payload.get("plate") or ""))
if not plate_label:
plate_label = "غير معروف"
_notification_title = ":dollar: Cash Payment Needed"
_notification_body = f"اليوزر {requester_name} عايز يدفع كاش — {plate_label}"
_notify_security_staff(
title=_notification_title,
body=_notification_body,
data={"session_id": session_id, "plate": plate_label, "requester_id": requester_id, "payment_method": "cash"},
)
old_notes = session_row.get("notes") or ""
new_notes = old_notes
if "intent_cash" not in old_notes:
new_notes = f"{old_notes} | intent_cash".strip(" |")
update_payload = {"notes": new_notes}
if session_row.get("status") == "paid":
update_payload["status"] = "entered"
update_payload["payment_confirmed_at"] = None
update_payload["notes"] = f"{new_notes} | switched_to_cash".strip(" |")
_update_parking_session(session_id, update_payload)
return {"status": "notified", "message": "تم إبلاغ الأمن"}
@router.post("/payments/paymob/create")
def create_paymob_payment(payload: Dict[str, Any] = Body(...), authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
if not paymob_configured():
raise HTTPException(status_code=503, detail="Paymob is not configured. Set PAYMOB_API_KEY, PAYMOB_INTEGRATION_ID, PAYMOB_IFRAME_ID.")
request_user = require_authenticated_user(authorization)
if request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required.")
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
raise HTTPException(status_code=401, detail="Authenticated user id is missing.")
requester_role = _resolve_requester_role(request_user)
requester_is_staff = is_staff_role(requester_role)
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id is required.")
session_row = _fetch_parking_session_by_id(session_id)
if not isinstance(session_row, dict):
raise HTTPException(status_code=404, detail="parking session was not found.")
parking_location = _normalize_parking_location(payload.get("parking_location"))
pricing = compute_session_time_and_fee(session_row, parking_location_override=parking_location)
amount_raw = payload.get("amount_cents")
if amount_raw is None:
amount_cents = max(int(pricing.get("total_fee_cents") or 0), 1)
else:
try:
amount_cents = int(amount_raw)
except Exception:
raise HTTPException(status_code=400, detail="amount_cents must be a positive integer.")
if amount_cents <= 0:
raise HTTPException(status_code=400, detail="amount_cents must be greater than zero.")
currency = str(payload.get("currency") or PAYMOB_CURRENCY).strip().upper() or PAYMOB_CURRENCY
owner_id = _get_parking_session_owner_id(session_id)
if not owner_id:
raise HTTPException(status_code=404, detail="parking session was not found.")
if not requester_is_staff and requester_id != owner_id:
raise HTTPException(status_code=403, detail="You can only create payments for your own sessions.")
try:
if parking_location:
_set_parking_session_location(session_id, parking_location)
merchant_order_id = f"ain-{session_id[:8]}-{uuid4().hex[:12]}"
checkout = _paymob_create_checkout(amount_cents=amount_cents, currency=currency, merchant_order_id=merchant_order_id, request_user=request_user)
payment_id = _insert_payment_transaction_record(
created_by=requester_id,
session_id=session_id,
amount_cents=amount_cents,
currency=currency,
merchant_order_id=merchant_order_id,
paymob_order_id=checkout.get("paymob_order_id"),
parking_location=parking_location,
request_payload={"session_id": session_id, "amount_cents": amount_cents, "currency": currency, "merchant_order_id": merchant_order_id, "parking_location": parking_location},
response_payload={"paymob_order_id": checkout.get("paymob_order_id"), "iframe_url": checkout.get("iframe_url")},
)
return {
"status": "ok",
"provider": "paymob",
"payment_transaction_id": payment_id,
"merchant_order_id": merchant_order_id,
"paymob_order_id": checkout.get("paymob_order_id"),
"session_id": session_id,
"amount_cents": amount_cents,
"currency": currency,
"pricing": pricing,
"parking_location": parking_location,
"payment_url": checkout.get("iframe_url"),
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to create Paymob payment: {exc}") from exc
@router.post("/payments/paymob/webhook")
def paymob_webhook(payload: Dict[str, Any] = Body(...)) -> Dict[str, Any]:
obj = payload.get("obj") if isinstance(payload, dict) else None
if not isinstance(obj, dict):
obj = payload if isinstance(payload, dict) else {}
order_obj = obj.get("order") if isinstance(obj.get("order"), dict) else {}
merchant_order_id = str(order_obj.get("merchant_order_id") or payload.get("merchant_order_id") or "").strip()
if not merchant_order_id:
raise HTTPException(status_code=400, detail="merchant_order_id was not found in webhook payload.")
paymob_order_id = str(order_obj.get("id") or obj.get("order_id") or payload.get("order_id") or "").strip() or None
payment_reference = str(obj.get("id") or payload.get("id") or "").strip() or None
is_pending = bool(obj.get("pending"))
is_success = bool(obj.get("success")) and not is_pending
status = "pending"
if is_success:
status = "paid"
elif not is_pending:
status = "failed"
try:
_update_payment_transaction_by_merchant_order_id(
merchant_order_id=merchant_order_id,
status=status,
payment_reference=payment_reference,
paymob_order_id=paymob_order_id,
response_payload=payload if isinstance(payload, dict) else {},
)
session_id = _get_payment_session_id_by_merchant_order_id(merchant_order_id)
if is_success and session_id:
_mark_parking_session_paid(session_id, payment_reference=payment_reference)
return {
"status": "ok",
"payment_status": status,
"merchant_order_id": merchant_order_id,
"paymob_order_id": paymob_order_id,
"session_id": session_id,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to process Paymob webhook: {exc}") from exc