package handlers import ( "errors" "fmt" "strconv" "strings" "time" "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/services/chapa" "github.com/gofiber/fiber/v2" ) type adminPaymentRes 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"` TransactionID *string `json:"transaction_id,omitempty"` Nonce string `json:"nonce"` 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"` PlanCategory *string `json:"plan_category,omitempty"` UserEmail *string `json:"user_email,omitempty"` UserFirstName *string `json:"user_first_name,omitempty"` UserLastName *string `json:"user_last_name,omitempty"` PaidAt *string `json:"paid_at,omitempty"` ExpiresAt *string `json:"expires_at,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt *string `json:"updated_at,omitempty"` } type listAdminPaymentsRes struct { Payments []adminPaymentRes `json:"payments"` TotalCount int64 `json:"total_count"` Limit int32 `json:"limit"` Offset int32 `json:"offset"` } // ListAdminPayments godoc // @Summary List all payments (admin) // @Description Returns paginated payments across Chapa and ArifPay with optional filters // @Tags payments // @Produce json // @Param user_id query int false "Filter by learner user ID" // @Param plan_id query int false "Filter by subscription plan ID" // @Param subscription_id query int false "Filter by user subscription ID" // @Param status query string false "Payment status (PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED, EXPIRED)" // @Param provider query string false "Payment provider (CHAPA, ARIFPAY)" // @Param payment_method query string false "Alias for provider" // @Param currency query string false "Currency code (e.g. ETB)" // @Param plan_category query string false "Plan category (LEARN_ENGLISH, IELTS, DUOLINGO)" // @Param reference query string false "Search session_id, nonce, or transaction_id" // @Param created_from query string false "Created at from (RFC3339 or YYYY-MM-DD)" // @Param created_to query string false "Created at to (exclusive, RFC3339 or YYYY-MM-DD)" // @Param paid_from query string false "Paid at from (RFC3339 or YYYY-MM-DD)" // @Param paid_to query string false "Paid at to (exclusive, RFC3339 or YYYY-MM-DD)" // @Param min_amount query number false "Minimum amount" // @Param max_amount query number false "Maximum amount" // @Param limit query int false "Page size" default(20) // @Param offset query int false "Page offset" default(0) // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Router /api/v1/admin/payments [get] func (h *Handler) ListAdminPayments(c *fiber.Ctx) error { filter, err := parsePaymentListFilter(c) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid query parameters", Error: err.Error(), }) } page, err := h.chapaSvc.ListPaymentsAdmin(c.Context(), filter) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to list payments", Error: err.Error(), }) } out := make([]adminPaymentRes, len(page.Items)) for i := range page.Items { out[i] = adminPaymentToRes(&page.Items[i]) } return c.JSON(domain.Response{ Message: "Payments retrieved successfully", Data: listAdminPaymentsRes{ Payments: out, TotalCount: page.Total, Limit: page.Limit, Offset: page.Offset, }, }) } // GetAdminPayment godoc // @Summary Get payment by ID (admin) // @Description Returns any payment record by ID without learner ownership restriction // @Tags payments // @Produce json // @Param id path int true "Payment ID" // @Success 200 {object} domain.Response // @Failure 404 {object} domain.ErrorResponse // @Router /api/v1/admin/payments/{id} [get] func (h *Handler) GetAdminPayment(c *fiber.Ctx) error { 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.GetPaymentAdminByID(c.Context(), id) if err != nil { if errors.Is(err, chapa.ErrPaymentNotFound) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Payment not found", }) } return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to get payment", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Payment retrieved successfully", Data: adminPaymentToRes(payment), }) } func parsePaymentListFilter(c *fiber.Ctx) (domain.PaymentListFilter, error) { limit, err := strconv.Atoi(c.Query("limit", "20")) if err != nil || limit < 1 { return domain.PaymentListFilter{}, fmt.Errorf("invalid limit") } if limit > 100 { limit = 100 } offset, err := strconv.Atoi(c.Query("offset", "0")) if err != nil || offset < 0 { return domain.PaymentListFilter{}, fmt.Errorf("invalid offset") } filter := domain.PaymentListFilter{ Limit: int32(limit), Offset: int32(offset), } if v := strings.TrimSpace(c.Query("user_id")); v != "" { id, err := strconv.ParseInt(v, 10, 64) if err != nil { return domain.PaymentListFilter{}, fmt.Errorf("invalid user_id") } filter.UserID = &id } if v := strings.TrimSpace(c.Query("plan_id")); v != "" { id, err := strconv.ParseInt(v, 10, 64) if err != nil { return domain.PaymentListFilter{}, fmt.Errorf("invalid plan_id") } filter.PlanID = &id } if v := strings.TrimSpace(c.Query("subscription_id")); v != "" { id, err := strconv.ParseInt(v, 10, 64) if err != nil { return domain.PaymentListFilter{}, fmt.Errorf("invalid subscription_id") } filter.SubscriptionID = &id } if v := strings.TrimSpace(c.Query("status")); v != "" { status := strings.ToUpper(v) if !isValidPaymentStatus(status) { return domain.PaymentListFilter{}, fmt.Errorf("invalid status") } filter.Status = &status } provider := firstNonEmpty(strings.TrimSpace(c.Query("provider")), strings.TrimSpace(c.Query("payment_method"))) if provider != "" { p, err := domain.ParsePaymentProvider(provider) if err != nil { return domain.PaymentListFilter{}, err } method := string(p) filter.PaymentMethod = &method } if v := strings.TrimSpace(c.Query("currency")); v != "" { cur := strings.ToUpper(v) filter.Currency = &cur } if v := strings.TrimSpace(c.Query("plan_category")); v != "" { cat := strings.ToUpper(v) if cat != string(domain.SubscriptionCategoryLearnEnglish) && cat != string(domain.SubscriptionCategoryIELTS) && cat != string(domain.SubscriptionCategoryDuolingo) { return domain.PaymentListFilter{}, fmt.Errorf("invalid plan_category") } filter.PlanCategory = &cat } if v := strings.TrimSpace(c.Query("reference")); v != "" { filter.Reference = &v } for _, pair := range []struct { q string dest **time.Time }{ {"created_from", &filter.CreatedFrom}, {"created_to", &filter.CreatedTo}, {"paid_from", &filter.PaidFrom}, {"paid_to", &filter.PaidTo}, } { if v := strings.TrimSpace(c.Query(pair.q)); v != "" { t, err := parseQueryTime(v) if err != nil { return domain.PaymentListFilter{}, fmt.Errorf("invalid %s", pair.q) } *pair.dest = &t } } if v := strings.TrimSpace(c.Query("min_amount")); v != "" { amount, err := strconv.ParseFloat(v, 64) if err != nil { return domain.PaymentListFilter{}, fmt.Errorf("invalid min_amount") } filter.MinAmount = &amount } if v := strings.TrimSpace(c.Query("max_amount")); v != "" { amount, err := strconv.ParseFloat(v, 64) if err != nil { return domain.PaymentListFilter{}, fmt.Errorf("invalid max_amount") } filter.MaxAmount = &amount } return filter, nil } func isValidPaymentStatus(status string) bool { switch status { case string(domain.PaymentStatusPending), string(domain.PaymentStatusProcessing), string(domain.PaymentStatusSuccess), string(domain.PaymentStatusFailed), string(domain.PaymentStatusCancelled), string(domain.PaymentStatusExpired): return true default: return false } } func parseQueryTime(raw string) (time.Time, error) { if t, err := time.Parse(time.RFC3339, raw); err == nil { return t.UTC(), nil } if t, err := time.Parse("2006-01-02", raw); err == nil { return t.UTC(), nil } return time.Time{}, fmt.Errorf("unsupported time format") } func adminPaymentToRes(p *domain.Payment) adminPaymentRes { res := adminPaymentRes{ ID: p.ID, UserID: p.UserID, PlanID: p.PlanID, SubscriptionID: p.SubscriptionID, SessionID: p.SessionID, TransactionID: p.TransactionID, Nonce: p.Nonce, Amount: p.Amount, Currency: p.Currency, PaymentMethod: p.PaymentMethod, Status: p.Status, PaymentURL: p.PaymentURL, PlanName: p.PlanName, PlanCategory: p.PlanCategory, UserEmail: p.UserEmail, UserFirstName: p.UserFirstName, UserLastName: p.UserLastName, CreatedAt: p.CreatedAt.Format(time.RFC3339), } if p.PaidAt != nil { t := p.PaidAt.Format(time.RFC3339) res.PaidAt = &t } if p.ExpiresAt != nil { t := p.ExpiresAt.Format(time.RFC3339) res.ExpiresAt = &t } if p.UpdatedAt != nil { t := p.UpdatedAt.Format(time.RFC3339) res.UpdatedAt = &t } return res }