Spaces:
Running on Zero
Running on Zero
| 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, | |
| } | |
| 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), | |
| } | |
| 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")), | |
| } | |
| 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": "تم إبلاغ الأمن"} | |
| 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 | |
| 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 | |