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>
308 lines
9.6 KiB
Go
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
|
|
}
|