package handlers import ( "bytes" "context" "errors" "fmt" "html/template" "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 := arifpaySuccessPageData{ Title: "Subscription Payment Successful", Headline: "Your Yimaru Academy payment was received", Body: "Thank you for your payment. Your subscription is being activated and you can return to Yimaru Academy shortly.", BadgeLabel: "Payment successful", StatusLabel: "Activation in progress", ActionLabel: "Continue learning", ActionHref: "/", } 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 := renderArifpaySuccessPage(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 } type arifpaySuccessPageData struct { Title string Headline string Body string Helper string BadgeLabel string StatusLabel string Reference string PlanName string ActionLabel string ActionHref string } func renderArifpaySuccessPage(data arifpaySuccessPageData) (string, error) { const tpl = ` {{.Title}}
{{.BadgeLabel}}

Yimaru Academy

{{.Headline}}

{{.Body}}

{{if .Helper}}

{{.Helper}}

{{end}}

Status

{{.StatusLabel}}

{{if .PlanName}}

Plan: {{.PlanName}}

{{end}} {{if .Reference}}

Reference: {{.Reference}}

{{end}}

Yimaru Academy subscription payments are verified securely before access is granted.

` t, err := template.New("arifpay-success").Parse(tpl) if err != nil { return "", err } var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { return "", err } return buf.String(), nil } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } func derefString(value *string) string { if value == nil { return "" } return *value }