Yimaru-BackEnd/internal/web_server/handlers/payments_admin.go
Yared Yemane fbad083ca4 Add admin payments list API with filters and fix /admin route conflict.
Expose GET /api/v1/admin/payments for filtered gateway transaction listing, constrain /admin/:id to integers so /admin/payments is not mistaken for an admin id, and grant payments.list_all to ADMIN.

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

308 lines
9.6 KiB
Go

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
}