Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions scripts/fetch-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" <<EOF
INTERNAL_SECRET=$(s INTERNAL_SECRET)
EOF
chmod 600 "$REPO_DIR/services/$svc/.env"
done

# ── Produce Service ───────────────────────────────────────────────────────────
cat > "$REPO_DIR/services/produce/.env" <<EOF
INTERNAL_SECRET=$(s INTERNAL_SECRET)
CLOUDINARY_CLOUD_NAME=$(s CLOUDINARY_CLOUD_NAME)
CLOUDINARY_API_KEY=$(s CLOUDINARY_API_KEY)
CLOUDINARY_API_SECRET=$(s CLOUDINARY_API_SECRET)
EOF
chmod 600 "$REPO_DIR/services/produce/.env"

# ── Blog Service ──────────────────────────────────────────────────────────────
cat > "$REPO_DIR/services/blog/.env" <<EOF
INTERNAL_SECRET=$(s INTERNAL_SECRET)
CLOUDINARY_CLOUD_NAME=$(s CLOUDINARY_CLOUD_NAME)
CLOUDINARY_API_KEY=$(s CLOUDINARY_API_KEY)
CLOUDINARY_API_SECRET=$(s CLOUDINARY_API_SECRET)
EOF
chmod 600 "$REPO_DIR/services/blog/.env"

echo "[fetch-secrets] All service .env files written successfully."
1 change: 1 addition & 0 deletions services/order/app/models/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions services/order/app/schemas/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions services/payment/app/models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 20 additions & 32 deletions services/payment/app/routers/webhook.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,7 +14,6 @@
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Webhook"])

# PesaPal status codes
PESAPAL_COMPLETED = "COMPLETED"
PESAPAL_FAILED = "FAILED"
PESAPAL_REVERSED = "REVERSED"
Expand All @@ -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()

Expand Down Expand Up @@ -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}


Expand All @@ -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"
)
return RedirectResponse(f"{base}?orderId={order_id}&status=pending")
1 change: 1 addition & 0 deletions services/payment/app/schemas/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading