From e3661dfb8af0e3dc1f20987e0e51826b5ee5ccf7 Mon Sep 17 00:00:00 2001 From: Ntale Samad Date: Mon, 1 Jun 2026 20:10:50 +0300 Subject: [PATCH 1/6] feat(secrets): add Cloudinary env vars to produce and blog services --- scripts/fetch-secrets.sh | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/fetch-secrets.sh b/scripts/fetch-secrets.sh index eb01afe..bf218f0 100755 --- a/scripts/fetch-secrets.sh +++ b/scripts/fetch-secrets.sh @@ -68,7 +68,7 @@ PESAPAL_CONSUMER_KEY=$(s PESAPAL_CONSUMER_KEY) PESAPAL_CONSUMER_SECRET=$(s PESAPAL_CONSUMER_SECRET) PESAPAL_SANDBOX=$(s PESAPAL_SANDBOX) PESAPAL_IPN_URL=${FRONTEND_URL}/payments/webhook/pesapal/ipn -PESAPAL_CALLBACK_URL=${FRONTEND_URL}/payments/callback +PESAPAL_CALLBACK_URL=${FRONTEND_URL}/payments/webhook/pesapal/callback ORDER_SERVICE_URL=http://order_service:8004 USER_SERVICE_URL=http://user_service:8002 NOTIFICATION_SERVICE_URL=http://notification_service:8007 @@ -112,12 +112,30 @@ NOTIFICATION_SERVICE_URL=http://notification_service:8007 EOF chmod 600 "$REPO_DIR/services/message/.env" -# ── User / Produce / Order / Blog — only need INTERNAL_SECRET ───────────────── -for svc in user produce order blog; do +# ── User / Order — only need INTERNAL_SECRET ────────────────────────────────── +for svc in user order; do cat > "$REPO_DIR/services/$svc/.env" < "$REPO_DIR/services/produce/.env" < "$REPO_DIR/services/blog/.env" < Date: Mon, 1 Jun 2026 20:12:38 +0300 Subject: [PATCH 2/6] feat(order): add pesapal as a payment method type --- services/order/app/models/order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/order/app/models/order.py b/services/order/app/models/order.py index 5a1b7d9..37ddab9 100644 --- a/services/order/app/models/order.py +++ b/services/order/app/models/order.py @@ -20,6 +20,7 @@ class OrderStatus(str, enum.Enum): class PaymentMethodType(str, enum.Enum): + pesapal = "pesapal" mobile_money = "mobile_money" cash_on_delivery = "cash_on_delivery" bank_transfer = "bank_transfer" From 30c5f6bc06a8f9d87be64be6d97bbdbc6a50a7d7 Mon Sep 17 00:00:00 2001 From: Ntale Samad Date: Mon, 1 Jun 2026 20:13:21 +0300 Subject: [PATCH 3/6] feat(order): expose paymentUrl in OrderOut schema --- services/order/app/schemas/order.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/order/app/schemas/order.py b/services/order/app/schemas/order.py index afa2a6a..264d6ad 100644 --- a/services/order/app/schemas/order.py +++ b/services/order/app/schemas/order.py @@ -13,6 +13,7 @@ class OrderStatus(str, Enum): class PaymentMethodType(str, Enum): + pesapal = "pesapal" mobile_money = "mobile_money" cash_on_delivery = "cash_on_delivery" bank_transfer = "bank_transfer" @@ -107,6 +108,7 @@ class OrderOut(BaseModel): createdAt: str updatedAt: str estimatedDelivery: Optional[str] = None + paymentUrl: Optional[str] = None # PesaPal redirect — frontend uses to redirect buyer # ── Order summary for history list (lighter than full OrderOut) From b745ab097d084b99dc1fe789e19eec1a8f9afadb Mon Sep 17 00:00:00 2001 From: Ntale Samad Date: Mon, 1 Jun 2026 20:14:08 +0300 Subject: [PATCH 4/6] feat(payment): add pesapal to PaymentMethodType enum --- services/payment/app/models/payment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/payment/app/models/payment.py b/services/payment/app/models/payment.py index 08cf1bb..51e3e74 100644 --- a/services/payment/app/models/payment.py +++ b/services/payment/app/models/payment.py @@ -18,6 +18,7 @@ class PaymentStatus(str, enum.Enum): class PaymentMethodType(str, enum.Enum): + pesapal = "pesapal" mobile_money = "mobile_money" cash_on_delivery = "cash_on_delivery" bank_transfer = "bank_transfer" From c859bedbb0fd596262d0521fbc6f0699384b12aa Mon Sep 17 00:00:00 2001 From: Ntale Samad Date: Mon, 1 Jun 2026 20:14:37 +0300 Subject: [PATCH 5/6] fix(webhook): redirect callback to /checkout/confirmation with orderId and status --- services/payment/app/routers/webhook.py | 52 ++++++++++--------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/services/payment/app/routers/webhook.py b/services/payment/app/routers/webhook.py index b0f42d2..a508722 100644 --- a/services/payment/app/routers/webhook.py +++ b/services/payment/app/routers/webhook.py @@ -1,8 +1,11 @@ import logging +import uuid from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session +from app.core.config import settings from app.db.database import get_db from app.helpers.pesapal import get_transaction_status from app.models.payment import Transaction, PaymentStatus @@ -11,7 +14,6 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=["Webhook"]) -# PesaPal status codes PESAPAL_COMPLETED = "COMPLETED" PESAPAL_FAILED = "FAILED" PESAPAL_REVERSED = "REVERSED" @@ -25,42 +27,33 @@ async def pesapal_ipn( orderNotifyType: str = Query(...), db: Session = Depends(get_db) ): - """ - PesaPal sends a GET request here when payment status changes. - We verify the status directly with PesaPal API — never trust the IPN alone. - """ logger.info(f"IPN received: tracking_id={orderTrackingId} ref={orderMerchantRef}") - # ── 1. Find the transaction tx = db.query(Transaction).filter( Transaction.pesapal_order_tracking_id == orderTrackingId ).first() if not tx: logger.warning(f"IPN for unknown tracking id: {orderTrackingId}") - # Still return 200 — PesaPal will retry on non-200 return {"status": "ok", "message": "Transaction not found"} - # Skip if already finalised if tx.status in (PaymentStatus.completed, PaymentStatus.failed, PaymentStatus.refunded): return {"status": "ok", "message": "Already processed"} - # ── 2. Verify status directly with PesaPal — never trust IPN payload alone try: status_data = await get_transaction_status(orderTrackingId) except Exception as e: logger.error(f"PesaPal status check failed: {e}") raise HTTPException(status_code=502, detail="Could not verify payment status") - pesapal_status = status_data.get("payment_status_description", "").upper() - payment_method = status_data.get("payment_method") + pesapal_status = status_data.get("payment_status_description", "").upper() + payment_method = status_data.get("payment_method") logger.info(f"PesaPal status for {orderTrackingId}: {pesapal_status}") - # ── 3. Update transaction and notify Order Service if pesapal_status == PESAPAL_COMPLETED: - tx.status = PaymentStatus.completed - tx.paid_at = datetime.utcnow() + tx.status = PaymentStatus.completed + tx.paid_at = datetime.utcnow() tx.pesapal_payment_method = payment_method db.commit() @@ -91,10 +84,8 @@ async def pesapal_ipn( ) else: - # Still pending — PesaPal will IPN again logger.info(f"Payment still pending for order {tx.order_id}") - # PesaPal expects 200 — anything else triggers retries return {"status": "ok", "orderNotificationType": orderNotifyType} @@ -104,32 +95,29 @@ async def pesapal_callback( OrderTrackingId: str = Query(...), db: Session = Depends(get_db) ): - """ - Buyer lands here after completing or cancelling on PesaPal's page. - We check status and redirect them to the frontend. - """ - from fastapi.responses import RedirectResponse - from app.core.config import settings + base = f"{settings.FRONTEND_URL}/checkout/confirmation" + + try: + order_uuid = uuid.UUID(order_id) + except ValueError: + return RedirectResponse(f"{base}?orderId={order_id}&status=error") tx = db.query(Transaction).filter( - Transaction.order_id == order_id + Transaction.order_id == order_uuid ).first() if not tx: - return RedirectResponse(f"{settings.FRONTEND_URL}/orders?status=error") + return RedirectResponse(f"{base}?orderId={order_id}&status=error") - # Check live status try: status_data = await get_transaction_status(OrderTrackingId) pesapal_status = status_data.get("payment_status_description", "").upper() except Exception: - return RedirectResponse(f"{settings.FRONTEND_URL}/orders/{order_id}?status=error") + return RedirectResponse(f"{base}?orderId={order_id}&status=error") if pesapal_status == PESAPAL_COMPLETED: - return RedirectResponse( - f"{settings.FRONTEND_URL}/orders/{order_id}?status=success" - ) + return RedirectResponse(f"{base}?orderId={order_id}&status=success") + elif pesapal_status in (PESAPAL_FAILED, PESAPAL_INVALID, PESAPAL_REVERSED): + return RedirectResponse(f"{base}?orderId={order_id}&status=error") else: - return RedirectResponse( - f"{settings.FRONTEND_URL}/orders/{order_id}?status=pending" - ) \ No newline at end of file + return RedirectResponse(f"{base}?orderId={order_id}&status=pending") \ No newline at end of file From 762e2c87dc1592902db25495cf172c43665a9d69 Mon Sep 17 00:00:00 2001 From: Ntale Samad Date: Mon, 1 Jun 2026 20:15:07 +0300 Subject: [PATCH 6/6] refactor(webhook): clean up IPN handler comments and whitespace --- services/payment/app/schemas/payment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/payment/app/schemas/payment.py b/services/payment/app/schemas/payment.py index 04ecbc3..0ae1d10 100644 --- a/services/payment/app/schemas/payment.py +++ b/services/payment/app/schemas/payment.py @@ -12,6 +12,7 @@ class PaymentStatus(str, Enum): class PaymentMethodType(str, Enum): + pesapal = "pesapal" mobile_money = "mobile_money" cash_on_delivery = "cash_on_delivery" bank_transfer = "bank_transfer"