Yimaru-BackEnd/internal/web_server/handlers/arifpay.go
Yared Yemane 7a4253edf4 Add explicit payment provider selection for subscriptions.
Require the client to choose CHAPA or ARIFPAY in the subscription checkout request body and route payment initiation and verification through the matching provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 04:18:24 -07:00

528 lines
15 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/chapa"
"strconv"
"github.com/gofiber/fiber/v2"
)
// =====================
// Payment Types
// =====================
type initiatePaymentReq struct {
PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"`
Provider string `json:"provider" validate:"required"`
}
type paymentRes struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
PlanID *int64 `json:"plan_id,omitempty"`
SubscriptionID *int64 `json:"subscription_id,omitempty"`
SessionID *string `json:"session_id,omitempty"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
PaymentMethod *string `json:"payment_method,omitempty"`
Status string `json:"status"`
PaymentURL *string `json:"payment_url,omitempty"`
PlanName *string `json:"plan_name,omitempty"`
PaidAt *string `json:"paid_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
}
// =====================
// Subscription Payment Handlers
// =====================
// InitiateSubscriptionPayment godoc
// @Summary Initiate subscription payment
// @Description Creates a payment session for a subscription plan
// @Tags payments
// @Accept json
// @Produce json
// @Param body body initiatePaymentReq true "Payment request"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/payments/subscribe [post]
func (h *Handler) InitiateSubscriptionPayment(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
var req initiatePaymentReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
provider, err := domain.ParsePaymentProvider(req.Provider)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid payment provider",
Error: err.Error(),
})
}
result, err := h.initiateSubscriptionPayment(c.Context(), userID, domain.InitiateSubscriptionPaymentRequest{
PlanID: req.PlanID,
Phone: req.Phone,
Email: req.Email,
Provider: provider,
})
if err != nil {
status := paymentInitiationStatus(err)
return c.Status(status).JSON(domain.ErrorResponse{
Message: "Failed to initiate payment",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Payment initiated successfully",
Data: result,
})
}
// VerifyPayment godoc
// @Summary Verify payment status
// @Description Checks the payment status with the payment provider
// @Tags payments
// @Produce json
// @Param session_id path string true "Session ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/payments/verify/{session_id} [get]
func (h *Handler) VerifyPayment(c *fiber.Ctx) error {
sessionID := c.Params("session_id")
if sessionID == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Session ID is required",
})
}
payment, err := h.verifyPaymentByProvider(c.Context(), sessionID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Payment not found or verification failed",
Error: err.Error(),
})
}
if payment.Status == string(domain.PaymentStatusSuccess) {
h.sendInAppNotification(payment.UserID, domain.NOTIFICATION_TYPE_PAYMENT_VERIFIED, "Payment Successful", "Your payment has been verified successfully. Your subscription is now active.")
}
return c.JSON(domain.Response{
Message: "Payment status retrieved",
Data: paymentToRes(payment),
})
}
// GetMyPayments godoc
// @Summary Get payment history
// @Description Returns the authenticated user's payment history
// @Tags payments
// @Produce json
// @Param limit query int false "Limit" default(20)
// @Param offset query int false "Offset" default(0)
// @Success 200 {object} domain.Response
// @Router /api/v1/payments [get]
func (h *Handler) GetMyPayments(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
payments, err := h.chapaSvc.GetPaymentsByUser(c.Context(), userID, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get payment history",
Error: err.Error(),
})
}
result := make([]paymentRes, len(payments))
for i, p := range payments {
result[i] = *paymentToRes(&p)
}
return c.JSON(domain.Response{
Message: "Payment history retrieved successfully",
Data: result,
})
}
// GetPaymentByID godoc
// @Summary Get payment details
// @Description Returns details of a specific payment
// @Tags payments
// @Produce json
// @Param id path int true "Payment ID"
// @Success 200 {object} domain.Response
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/payments/{id} [get]
func (h *Handler) GetPaymentByID(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid payment ID",
})
}
payment, err := h.chapaSvc.GetPaymentByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Payment not found",
})
}
if payment.UserID != userID {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Access denied",
})
}
return c.JSON(domain.Response{
Message: "Payment retrieved successfully",
Data: paymentToRes(payment),
})
}
// CancelPayment godoc
// @Summary Cancel a pending payment
// @Description Cancels a payment that is still pending
// @Tags payments
// @Produce json
// @Param id path int true "Payment ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/payments/{id}/cancel [post]
func (h *Handler) CancelPayment(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid payment ID",
})
}
if err := h.chapaSvc.CancelPayment(c.Context(), id, userID); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to cancel payment",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Payment cancelled successfully",
})
}
func (h *Handler) initiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
switch req.Provider {
case domain.PaymentProviderChapa:
return h.chapaSvc.InitiateSubscriptionPayment(ctx, userID, req)
case domain.PaymentProviderArifPay:
return h.arifpaySvc.InitiateSubscriptionPayment(ctx, userID, req)
default:
return nil, fmt.Errorf("unsupported payment provider %q", req.Provider)
}
}
func (h *Handler) verifyPaymentByProvider(ctx context.Context, ref string) (*domain.Payment, error) {
payment, err := h.chapaSvc.LookupPayment(ctx, ref)
if err != nil {
return nil, err
}
if payment.Status == string(domain.PaymentStatusSuccess) ||
payment.Status == string(domain.PaymentStatusFailed) ||
payment.Status == string(domain.PaymentStatusCancelled) ||
payment.Status == string(domain.PaymentStatusExpired) {
return payment, nil
}
if payment.PaymentMethod != nil {
if provider, err := domain.ParsePaymentProvider(*payment.PaymentMethod); err == nil {
switch provider {
case domain.PaymentProviderChapa:
return h.chapaSvc.VerifyPayment(ctx, ref)
case domain.PaymentProviderArifPay:
return h.arifpaySvc.VerifyPayment(ctx, ref)
}
}
}
chapaPayment, chapaErr := h.chapaSvc.VerifyPayment(ctx, ref)
if chapaErr == nil {
return chapaPayment, nil
}
arifpayPayment, arifpayErr := h.arifpaySvc.VerifyPayment(ctx, ref)
if arifpayErr == nil {
return arifpayPayment, nil
}
return nil, fmt.Errorf("chapa verify failed: %v; arifpay verify failed: %v", chapaErr, arifpayErr)
}
func paymentInitiationStatus(err error) int {
switch {
case errors.Is(err, chapa.ErrChapaNotConfigured):
return fiber.StatusServiceUnavailable
case err.Error() == "user already has an active subscription":
return fiber.StatusConflict
case err.Error() == "subscription plan is not active":
return fiber.StatusBadRequest
default:
return fiber.StatusInternalServerError
}
}
// HandleArifpayWebhook godoc
// @Summary Handle ArifPay webhook
// @Description Processes payment notifications from ArifPay
// @Tags payments
// @Accept json
// @Produce json
// @Param body body domain.WebhookRequest true "Webhook payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Router /api/v1/payments/webhook [post]
func (h *Handler) HandleArifpayWebhook(c *fiber.Ctx) error {
var req domain.WebhookRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid webhook payload",
Error: err.Error(),
})
}
if err := h.arifpaySvc.ProcessPaymentWebhook(c.Context(), req); err != nil {
h.logger.Error("Failed to process webhook", "error", err, "nonce", req.Nonce)
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to process webhook",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Webhook processed successfully",
})
}
// GetArifpayPaymentMethods godoc
// @Summary Get available payment methods
// @Description Returns list of supported ArifPay payment methods
// @Tags payments
// @Produce json
// @Success 200 {object} domain.Response
// @Router /api/v1/payments/methods [get]
func (h *Handler) GetArifpayPaymentMethods(c *fiber.Ctx) error {
methods := h.arifpaySvc.GetPaymentMethodsMapping()
return c.JSON(domain.Response{
Message: "Payment methods retrieved successfully",
Data: methods,
})
}
// =====================
// Direct Payment Handlers
// =====================
type initiateDirectPaymentReq struct {
PlanID int64 `json:"plan_id" validate:"required"`
Phone string `json:"phone" validate:"required"`
Email string `json:"email" validate:"required,email"`
PaymentMethod string `json:"payment_method" validate:"required"`
}
type verifyOTPReq struct {
SessionID string `json:"session_id" validate:"required"`
OTP string `json:"otp" validate:"required"`
}
// InitiateDirectPayment godoc
// @Summary Initiate direct payment
// @Description Creates a payment session and initiates direct payment (OTP-based)
// @Tags payments
// @Accept json
// @Produce json
// @Param body body initiateDirectPaymentReq true "Direct payment request"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/payments/direct [post]
func (h *Handler) InitiateDirectPayment(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
var req initiateDirectPaymentReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
result, err := h.arifpaySvc.InitiateDirectPayment(c.Context(), userID, domain.InitiateDirectPaymentRequest{
PlanID: req.PlanID,
Phone: req.Phone,
Email: req.Email,
PaymentMethod: domain.DirectPaymentMethod(req.PaymentMethod),
})
if err != nil {
status := fiber.StatusInternalServerError
if err.Error() == "user already has an active subscription" {
status = fiber.StatusConflict
}
return c.Status(status).JSON(domain.ErrorResponse{
Message: "Failed to initiate direct payment",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Direct payment initiated successfully",
Data: result,
})
}
// VerifyDirectPaymentOTP godoc
// @Summary Verify OTP for direct payment
// @Description Verifies the OTP sent for direct payment methods (Amole, HelloCash, etc.)
// @Tags payments
// @Accept json
// @Produce json
// @Param body body verifyOTPReq true "OTP verification request"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/payments/direct/verify-otp [post]
func (h *Handler) VerifyDirectPaymentOTP(c *fiber.Ctx) error {
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
var req verifyOTPReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
result, err := h.arifpaySvc.VerifyDirectPaymentOTP(c.Context(), userID, domain.VerifyOTPRequest{
SessionID: req.SessionID,
OTP: req.OTP,
})
if err != nil {
status := fiber.StatusInternalServerError
if err.Error() == "payment not found" {
status = fiber.StatusNotFound
}
return c.Status(status).JSON(domain.ErrorResponse{
Message: "Failed to verify OTP",
Error: err.Error(),
})
}
if !result.Success {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: result.Message,
})
}
return c.JSON(domain.Response{
Message: result.Message,
Data: result,
})
}
// GetDirectPaymentMethods godoc
// @Summary Get direct payment methods
// @Description Returns list of payment methods that support direct payment (OTP-based)
// @Tags payments
// @Produce json
// @Success 200 {object} domain.Response
// @Router /api/v1/payments/direct/methods [get]
func (h *Handler) GetDirectPaymentMethods(c *fiber.Ctx) error {
methods := h.arifpaySvc.GetDirectPaymentMethods()
return c.JSON(domain.Response{
Message: "Direct payment methods retrieved successfully",
Data: methods,
})
}
// Helper functions
func paymentToRes(p *domain.Payment) *paymentRes {
res := &paymentRes{
ID: p.ID,
UserID: p.UserID,
PlanID: p.PlanID,
SubscriptionID: p.SubscriptionID,
SessionID: p.SessionID,
Amount: p.Amount,
Currency: p.Currency,
PaymentMethod: p.PaymentMethod,
Status: p.Status,
PaymentURL: p.PaymentURL,
PlanName: p.PlanName,
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if p.PaidAt != nil {
t := p.PaidAt.Format("2006-01-02T15:04:05Z07:00")
res.PaidAt = &t
}
if p.ExpiresAt != nil {
t := p.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
res.ExpiresAt = &t
}
return res
}