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