Yimaru-BackEnd/internal/web_server/handlers/arifpay.go
Yared Yemane d3bbd8c95a Add backend Chapa payment success HTML page.
Serve /payment/success and /api/v1/payments/chapa/success to verify tx_ref on redirect and activate subscriptions, and share the payment success template with ArifPay.

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

578 lines
17 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
}
}
// HandleArifpaySuccessPage godoc
// @Summary ArifPay payment success page
// @Description Displays the Yimaru Academy success page after ArifPay redirects the learner back to the backend.
// @Tags payments
// @Produce html
// @Param session_id query string false "ArifPay session identifier"
// @Param sessionId query string false "ArifPay session identifier"
// @Param nonce query string false "Fallback payment nonce"
// @Success 200 {string} string "HTML success page"
// @Router /api/v1/payments/arifpay/success [get]
func (h *Handler) HandleArifpaySuccessPage(c *fiber.Ctx) error {
ref := firstNonEmpty(
c.Query("session_id"),
c.Query("sessionId"),
c.Query("sessionID"),
c.Query("nonce"),
)
page := defaultPaymentSuccessPage()
if ref != "" {
payment, err := h.arifpaySvc.VerifyPayment(c.Context(), ref)
if err != nil {
h.logger.Warn("Failed to verify ArifPay success redirect", "error", err, "ref", ref)
page.Body = "Thank you for your payment. We are confirming it with ArifPay and will activate your subscription shortly."
page.Helper = "You can safely return to Yimaru Academy. If activation takes longer than expected, refresh the app in a moment."
page.Reference = ref
} else {
page.Reference = ref
page.PlanName = derefString(payment.PlanName)
if payment.Status == string(domain.PaymentStatusSuccess) {
page.StatusLabel = "Subscription active"
page.Body = "Your Yimaru Academy subscription is active. You now have access to your learning content."
} else {
page.Body = "Thank you for your payment. We received your success redirect and are finalizing subscription activation."
page.StatusLabel = "Processing confirmation"
}
}
} else {
page.Helper = "Return to Yimaru Academy and refresh your subscription status if you do not see access immediately."
}
html, err := renderPaymentSuccessPage(page)
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to render success page")
}
c.Type("html", "utf-8")
return c.SendString(html)
}
// 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
}