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>
This commit is contained in:
Yared Yemane 2026-05-29 05:50:46 -07:00
parent 6423bb261e
commit fbad083ca4
10 changed files with 786 additions and 3 deletions

View File

@ -93,3 +93,77 @@ WHERE id = $1;
-- name: CountUserPayments :one
SELECT COUNT(*) FROM payments WHERE user_id = $1;
-- name: ListPaymentsAdmin :many
SELECT
p.id,
p.user_id,
p.plan_id,
p.subscription_id,
p.session_id,
p.transaction_id,
p.nonce,
p.amount,
p.currency,
p.payment_method,
p.status,
p.payment_url,
p.paid_at,
p.expires_at,
p.created_at,
p.updated_at,
sp.name AS plan_name,
sp.category AS plan_category,
u.email AS user_email,
u.first_name AS user_first_name,
u.last_name AS user_last_name
FROM payments p
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
LEFT JOIN users u ON u.id = p.user_id
WHERE (sqlc.narg('payment_id')::bigint IS NULL OR p.id = sqlc.narg('payment_id')::bigint)
AND (sqlc.narg('user_id')::bigint IS NULL OR p.user_id = sqlc.narg('user_id')::bigint)
AND (sqlc.narg('plan_id')::bigint IS NULL OR p.plan_id = sqlc.narg('plan_id')::bigint)
AND (sqlc.narg('subscription_id')::bigint IS NULL OR p.subscription_id = sqlc.narg('subscription_id')::bigint)
AND (sqlc.narg('status')::varchar IS NULL OR p.status = sqlc.narg('status')::varchar)
AND (sqlc.narg('payment_method')::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER(sqlc.narg('payment_method')::varchar))
AND (sqlc.narg('currency')::varchar IS NULL OR UPPER(p.currency) = UPPER(sqlc.narg('currency')::varchar))
AND (sqlc.narg('plan_category')::varchar IS NULL OR sp.category = sqlc.narg('plan_category')::varchar)
AND (sqlc.narg('created_from')::timestamptz IS NULL OR p.created_at >= sqlc.narg('created_from')::timestamptz)
AND (sqlc.narg('created_to')::timestamptz IS NULL OR p.created_at < sqlc.narg('created_to')::timestamptz)
AND (sqlc.narg('paid_from')::timestamptz IS NULL OR p.paid_at >= sqlc.narg('paid_from')::timestamptz)
AND (sqlc.narg('paid_to')::timestamptz IS NULL OR p.paid_at < sqlc.narg('paid_to')::timestamptz)
AND (sqlc.narg('min_amount')::numeric IS NULL OR p.amount >= sqlc.narg('min_amount')::numeric)
AND (sqlc.narg('max_amount')::numeric IS NULL OR p.amount <= sqlc.narg('max_amount')::numeric)
AND (
sqlc.narg('reference')::text IS NULL
OR p.session_id ILIKE '%' || sqlc.narg('reference')::text || '%'
OR p.nonce ILIKE '%' || sqlc.narg('reference')::text || '%'
OR p.transaction_id ILIKE '%' || sqlc.narg('reference')::text || '%'
)
ORDER BY p.created_at DESC
LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset');
-- name: CountPaymentsAdmin :one
SELECT COUNT(*)::bigint AS total
FROM payments p
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
WHERE (sqlc.narg('payment_id')::bigint IS NULL OR p.id = sqlc.narg('payment_id')::bigint)
AND (sqlc.narg('user_id')::bigint IS NULL OR p.user_id = sqlc.narg('user_id')::bigint)
AND (sqlc.narg('plan_id')::bigint IS NULL OR p.plan_id = sqlc.narg('plan_id')::bigint)
AND (sqlc.narg('subscription_id')::bigint IS NULL OR p.subscription_id = sqlc.narg('subscription_id')::bigint)
AND (sqlc.narg('status')::varchar IS NULL OR p.status = sqlc.narg('status')::varchar)
AND (sqlc.narg('payment_method')::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER(sqlc.narg('payment_method')::varchar))
AND (sqlc.narg('currency')::varchar IS NULL OR UPPER(p.currency) = UPPER(sqlc.narg('currency')::varchar))
AND (sqlc.narg('plan_category')::varchar IS NULL OR sp.category = sqlc.narg('plan_category')::varchar)
AND (sqlc.narg('created_from')::timestamptz IS NULL OR p.created_at >= sqlc.narg('created_from')::timestamptz)
AND (sqlc.narg('created_to')::timestamptz IS NULL OR p.created_at < sqlc.narg('created_to')::timestamptz)
AND (sqlc.narg('paid_from')::timestamptz IS NULL OR p.paid_at >= sqlc.narg('paid_from')::timestamptz)
AND (sqlc.narg('paid_to')::timestamptz IS NULL OR p.paid_at < sqlc.narg('paid_to')::timestamptz)
AND (sqlc.narg('min_amount')::numeric IS NULL OR p.amount >= sqlc.narg('min_amount')::numeric)
AND (sqlc.narg('max_amount')::numeric IS NULL OR p.amount <= sqlc.narg('max_amount')::numeric)
AND (
sqlc.narg('reference')::text IS NULL
OR p.session_id ILIKE '%' || sqlc.narg('reference')::text || '%'
OR p.nonce ILIKE '%' || sqlc.narg('reference')::text || '%'
OR p.transaction_id ILIKE '%' || sqlc.narg('reference')::text || '%'
);

View File

@ -45,6 +45,33 @@ Configure the same webhook URL in the Chapa dashboard:
| GET | `/api/v1/payments/chapa/success` | No | Chapa learner success page (HTML) |
| GET | `/payment/success` | No | Same HTML success page (`CHAPA_RETURN_URL`) |
| GET | `/api/v1/payments/methods` | No | Supported Chapa methods |
| GET | `/api/v1/admin/payments` | Yes (admin) | List/filter all gateway payments (Chapa + ArifPay) |
| GET | `/api/v1/admin/payments/:id` | Yes (admin) | Get any payment by ID |
### Admin: list all gateway payments
`GET /api/v1/admin/payments` requires permission `payments.list_all` (assigned to `ADMIN` by default).
Query filters (all optional):
| Parameter | Description |
|-----------|-------------|
| `user_id` | Learner user ID |
| `plan_id` | Subscription plan ID |
| `subscription_id` | Linked subscription ID |
| `status` | `PENDING`, `PROCESSING`, `SUCCESS`, `FAILED`, `CANCELLED`, `EXPIRED` |
| `provider` or `payment_method` | `CHAPA` or `ARIFPAY` |
| `currency` | e.g. `ETB` |
| `plan_category` | `LEARN_ENGLISH`, `IELTS`, `DUOLINGO` |
| `reference` | Partial match on `session_id`, `nonce`, or `transaction_id` |
| `created_from`, `created_to` | RFC3339 or `YYYY-MM-DD` |
| `paid_from`, `paid_to` | RFC3339 or `YYYY-MM-DD` |
| `min_amount`, `max_amount` | Amount range |
| `limit`, `offset` | Pagination (default limit 20, max 100) |
Example:
`GET /api/v1/admin/payments?provider=CHAPA&status=SUCCESS&limit=50&offset=0`
### Initiate payment request

View File

@ -11,6 +11,73 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const CountPaymentsAdmin = `-- name: CountPaymentsAdmin :one
SELECT COUNT(*)::bigint AS total
FROM payments p
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
WHERE ($1::bigint IS NULL OR p.id = $1::bigint)
AND ($2::bigint IS NULL OR p.user_id = $2::bigint)
AND ($3::bigint IS NULL OR p.plan_id = $3::bigint)
AND ($4::bigint IS NULL OR p.subscription_id = $4::bigint)
AND ($5::varchar IS NULL OR p.status = $5::varchar)
AND ($6::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER($6::varchar))
AND ($7::varchar IS NULL OR UPPER(p.currency) = UPPER($7::varchar))
AND ($8::varchar IS NULL OR sp.category = $8::varchar)
AND ($9::timestamptz IS NULL OR p.created_at >= $9::timestamptz)
AND ($10::timestamptz IS NULL OR p.created_at < $10::timestamptz)
AND ($11::timestamptz IS NULL OR p.paid_at >= $11::timestamptz)
AND ($12::timestamptz IS NULL OR p.paid_at < $12::timestamptz)
AND ($13::numeric IS NULL OR p.amount >= $13::numeric)
AND ($14::numeric IS NULL OR p.amount <= $14::numeric)
AND (
$15::text IS NULL
OR p.session_id ILIKE '%' || $15::text || '%'
OR p.nonce ILIKE '%' || $15::text || '%'
OR p.transaction_id ILIKE '%' || $15::text || '%'
)
`
type CountPaymentsAdminParams struct {
PaymentID pgtype.Int8 `json:"payment_id"`
UserID pgtype.Int8 `json:"user_id"`
PlanID pgtype.Int8 `json:"plan_id"`
SubscriptionID pgtype.Int8 `json:"subscription_id"`
Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"`
Currency pgtype.Text `json:"currency"`
PlanCategory pgtype.Text `json:"plan_category"`
CreatedFrom pgtype.Timestamptz `json:"created_from"`
CreatedTo pgtype.Timestamptz `json:"created_to"`
PaidFrom pgtype.Timestamptz `json:"paid_from"`
PaidTo pgtype.Timestamptz `json:"paid_to"`
MinAmount pgtype.Numeric `json:"min_amount"`
MaxAmount pgtype.Numeric `json:"max_amount"`
Reference pgtype.Text `json:"reference"`
}
func (q *Queries) CountPaymentsAdmin(ctx context.Context, arg CountPaymentsAdminParams) (int64, error) {
row := q.db.QueryRow(ctx, CountPaymentsAdmin,
arg.PaymentID,
arg.UserID,
arg.PlanID,
arg.SubscriptionID,
arg.Status,
arg.PaymentMethod,
arg.Currency,
arg.PlanCategory,
arg.CreatedFrom,
arg.CreatedTo,
arg.PaidFrom,
arg.PaidTo,
arg.MinAmount,
arg.MaxAmount,
arg.Reference,
)
var total int64
err := row.Scan(&total)
return total, err
}
const CountUserPayments = `-- name: CountUserPayments :one
SELECT COUNT(*) FROM payments WHERE user_id = $1
`
@ -391,6 +458,160 @@ func (q *Queries) LinkPaymentToSubscription(ctx context.Context, arg LinkPayment
return err
}
const ListPaymentsAdmin = `-- name: ListPaymentsAdmin :many
SELECT
p.id,
p.user_id,
p.plan_id,
p.subscription_id,
p.session_id,
p.transaction_id,
p.nonce,
p.amount,
p.currency,
p.payment_method,
p.status,
p.payment_url,
p.paid_at,
p.expires_at,
p.created_at,
p.updated_at,
sp.name AS plan_name,
sp.category AS plan_category,
u.email AS user_email,
u.first_name AS user_first_name,
u.last_name AS user_last_name
FROM payments p
LEFT JOIN subscription_plans sp ON sp.id = p.plan_id
LEFT JOIN users u ON u.id = p.user_id
WHERE ($1::bigint IS NULL OR p.id = $1::bigint)
AND ($2::bigint IS NULL OR p.user_id = $2::bigint)
AND ($3::bigint IS NULL OR p.plan_id = $3::bigint)
AND ($4::bigint IS NULL OR p.subscription_id = $4::bigint)
AND ($5::varchar IS NULL OR p.status = $5::varchar)
AND ($6::varchar IS NULL OR UPPER(COALESCE(p.payment_method, '')) = UPPER($6::varchar))
AND ($7::varchar IS NULL OR UPPER(p.currency) = UPPER($7::varchar))
AND ($8::varchar IS NULL OR sp.category = $8::varchar)
AND ($9::timestamptz IS NULL OR p.created_at >= $9::timestamptz)
AND ($10::timestamptz IS NULL OR p.created_at < $10::timestamptz)
AND ($11::timestamptz IS NULL OR p.paid_at >= $11::timestamptz)
AND ($12::timestamptz IS NULL OR p.paid_at < $12::timestamptz)
AND ($13::numeric IS NULL OR p.amount >= $13::numeric)
AND ($14::numeric IS NULL OR p.amount <= $14::numeric)
AND (
$15::text IS NULL
OR p.session_id ILIKE '%' || $15::text || '%'
OR p.nonce ILIKE '%' || $15::text || '%'
OR p.transaction_id ILIKE '%' || $15::text || '%'
)
ORDER BY p.created_at DESC
LIMIT $17 OFFSET $16
`
type ListPaymentsAdminParams struct {
PaymentID pgtype.Int8 `json:"payment_id"`
UserID pgtype.Int8 `json:"user_id"`
PlanID pgtype.Int8 `json:"plan_id"`
SubscriptionID pgtype.Int8 `json:"subscription_id"`
Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"`
Currency pgtype.Text `json:"currency"`
PlanCategory pgtype.Text `json:"plan_category"`
CreatedFrom pgtype.Timestamptz `json:"created_from"`
CreatedTo pgtype.Timestamptz `json:"created_to"`
PaidFrom pgtype.Timestamptz `json:"paid_from"`
PaidTo pgtype.Timestamptz `json:"paid_to"`
MinAmount pgtype.Numeric `json:"min_amount"`
MaxAmount pgtype.Numeric `json:"max_amount"`
Reference pgtype.Text `json:"reference"`
Offset int32 `json:"offset"`
Limit int32 `json:"limit"`
}
type ListPaymentsAdminRow struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
PlanID pgtype.Int8 `json:"plan_id"`
SubscriptionID pgtype.Int8 `json:"subscription_id"`
SessionID pgtype.Text `json:"session_id"`
TransactionID pgtype.Text `json:"transaction_id"`
Nonce string `json:"nonce"`
Amount pgtype.Numeric `json:"amount"`
Currency string `json:"currency"`
PaymentMethod pgtype.Text `json:"payment_method"`
Status string `json:"status"`
PaymentUrl pgtype.Text `json:"payment_url"`
PaidAt pgtype.Timestamptz `json:"paid_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
PlanName pgtype.Text `json:"plan_name"`
PlanCategory pgtype.Text `json:"plan_category"`
UserEmail pgtype.Text `json:"user_email"`
UserFirstName pgtype.Text `json:"user_first_name"`
UserLastName pgtype.Text `json:"user_last_name"`
}
func (q *Queries) ListPaymentsAdmin(ctx context.Context, arg ListPaymentsAdminParams) ([]ListPaymentsAdminRow, error) {
rows, err := q.db.Query(ctx, ListPaymentsAdmin,
arg.PaymentID,
arg.UserID,
arg.PlanID,
arg.SubscriptionID,
arg.Status,
arg.PaymentMethod,
arg.Currency,
arg.PlanCategory,
arg.CreatedFrom,
arg.CreatedTo,
arg.PaidFrom,
arg.PaidTo,
arg.MinAmount,
arg.MaxAmount,
arg.Reference,
arg.Offset,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPaymentsAdminRow
for rows.Next() {
var i ListPaymentsAdminRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.PlanID,
&i.SubscriptionID,
&i.SessionID,
&i.TransactionID,
&i.Nonce,
&i.Amount,
&i.Currency,
&i.PaymentMethod,
&i.Status,
&i.PaymentUrl,
&i.PaidAt,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.PlanName,
&i.PlanCategory,
&i.UserEmail,
&i.UserFirstName,
&i.UserLastName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdatePaymentSessionID = `-- name: UpdatePaymentSessionID :exec
UPDATE payments
SET

View File

@ -35,6 +35,38 @@ type Payment struct {
CreatedAt time.Time
UpdatedAt *time.Time
PlanName *string
PlanCategory *string
UserEmail *string
UserFirstName *string
UserLastName *string
}
// PaymentListFilter is used by admin payment listing.
type PaymentListFilter struct {
PaymentID *int64
UserID *int64
PlanID *int64
SubscriptionID *int64
Status *string
PaymentMethod *string
Currency *string
PlanCategory *string
Reference *string
CreatedFrom *time.Time
CreatedTo *time.Time
PaidFrom *time.Time
PaidTo *time.Time
MinAmount *float64
MaxAmount *float64
Limit int32
Offset int32
}
type PaymentListPage struct {
Items []Payment
Total int64
Limit int32
Offset int32
}
type CreatePaymentInput struct {

View File

@ -20,4 +20,5 @@ type PaymentStore interface {
LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error
GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error)
ExpirePayment(ctx context.Context, id int64) error
ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error)
}

View File

@ -166,6 +166,103 @@ func (s *Store) ExpirePayment(ctx context.Context, id int64) error {
return s.queries.ExpirePayment(ctx, id)
}
func (s *Store) ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error) {
params := buildPaymentsAdminFilterParams(filter)
total, err := s.queries.CountPaymentsAdmin(ctx, dbgen.CountPaymentsAdminParams(params))
if err != nil {
return domain.PaymentListPage{}, err
}
rows, err := s.queries.ListPaymentsAdmin(ctx, dbgen.ListPaymentsAdminParams{
PaymentID: int64PtrToPgInt8(filter.PaymentID),
UserID: params.UserID,
PlanID: params.PlanID,
SubscriptionID: params.SubscriptionID,
Status: params.Status,
PaymentMethod: params.PaymentMethod,
Currency: params.Currency,
PlanCategory: params.PlanCategory,
CreatedFrom: params.CreatedFrom,
CreatedTo: params.CreatedTo,
PaidFrom: params.PaidFrom,
PaidTo: params.PaidTo,
MinAmount: params.MinAmount,
MaxAmount: params.MaxAmount,
Reference: params.Reference,
Limit: filter.Limit,
Offset: filter.Offset,
})
if err != nil {
return domain.PaymentListPage{}, err
}
items := make([]domain.Payment, len(rows))
for i, row := range rows {
items[i] = paymentAdminRowToDomain(row)
}
return domain.PaymentListPage{
Items: items,
Total: total,
Limit: filter.Limit,
Offset: filter.Offset,
}, nil
}
func buildPaymentsAdminFilterParams(filter domain.PaymentListFilter) dbgen.CountPaymentsAdminParams {
return dbgen.CountPaymentsAdminParams{
PaymentID: int64PtrToPgInt8(filter.PaymentID),
UserID: int64PtrToPgInt8(filter.UserID),
PlanID: int64PtrToPgInt8(filter.PlanID),
SubscriptionID: int64PtrToPgInt8(filter.SubscriptionID),
Status: toPgText(filter.Status),
PaymentMethod: toPgText(filter.PaymentMethod),
Currency: toPgText(filter.Currency),
PlanCategory: toPgText(filter.PlanCategory),
CreatedFrom: toPgTimestamptzPtr(filter.CreatedFrom),
CreatedTo: toPgTimestamptzPtr(filter.CreatedTo),
PaidFrom: toPgTimestamptzPtr(filter.PaidFrom),
PaidTo: toPgTimestamptzPtr(filter.PaidTo),
MinAmount: toPgNumericPtr(filter.MinAmount),
MaxAmount: toPgNumericPtr(filter.MaxAmount),
Reference: toPgText(filter.Reference),
}
}
func paymentAdminRowToDomain(row dbgen.ListPaymentsAdminRow) domain.Payment {
return domain.Payment{
ID: row.ID,
UserID: row.UserID,
PlanID: int8PtrToInt64Ptr(row.PlanID),
SubscriptionID: int8PtrToInt64Ptr(row.SubscriptionID),
SessionID: fromPgTextPtr(row.SessionID),
TransactionID: fromPgTextPtr(row.TransactionID),
Nonce: row.Nonce,
Amount: fromPgNumeric(row.Amount),
Currency: row.Currency,
PaymentMethod: fromPgTextPtr(row.PaymentMethod),
Status: row.Status,
PaymentURL: fromPgTextPtr(row.PaymentUrl),
PaidAt: timePtr(row.PaidAt),
ExpiresAt: timePtr(row.ExpiresAt),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: timePtr(row.UpdatedAt),
PlanName: fromPgTextPtr(row.PlanName),
PlanCategory: fromPgTextPtr(row.PlanCategory),
UserEmail: fromPgTextPtr(row.UserEmail),
UserFirstName: fromPgTextPtr(row.UserFirstName),
UserLastName: fromPgTextPtr(row.UserLastName),
}
}
func toPgNumericPtr(val *float64) pgtype.Numeric {
if val == nil {
return pgtype.Numeric{Valid: false}
}
return toPgNumeric(*val)
}
// Helper functions
func paymentToDomain(p dbgen.Payment) *domain.Payment {

View File

@ -403,6 +403,25 @@ func (s *Service) GetPaymentsByUser(ctx context.Context, userID int64, limit, of
return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset)
}
func (s *Service) ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error) {
return s.paymentStore.ListPaymentsAdmin(ctx, filter)
}
func (s *Service) GetPaymentAdminByID(ctx context.Context, id int64) (*domain.Payment, error) {
page, err := s.paymentStore.ListPaymentsAdmin(ctx, domain.PaymentListFilter{
PaymentID: &id,
Limit: 1,
Offset: 0,
})
if err != nil {
return nil, err
}
if len(page.Items) == 0 {
return nil, ErrPaymentNotFound
}
return &page.Items[0], nil
}
func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) {
return s.paymentStore.GetPaymentByID(ctx, id)
}

View File

@ -172,6 +172,7 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "payments.cancel", Name: "Cancel Payment", Description: "Cancel a payment", GroupName: "Payments"},
{Key: "payments.direct_initiate", Name: "Initiate Direct Payment", Description: "Initiate direct payment", GroupName: "Payments"},
{Key: "payments.direct_verify_otp", Name: "Verify Direct Payment OTP", Description: "Verify OTP for direct payment", GroupName: "Payments"},
{Key: "payments.list_all", Name: "List All Payments", Description: "List and filter all gateway payments (admin)", GroupName: "Payments"},
// Users
{Key: "users.list", Name: "List Users", Description: "List all users", GroupName: "Users"},
@ -444,7 +445,7 @@ var DefaultRolePermissions = map[string][]string{
"subscriptions.create", "subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
"payments.direct_initiate", "payments.direct_verify_otp",
"payments.direct_initiate", "payments.direct_verify_otp", "payments.list_all",
// Users (full access)
"users.list", "users.get", "users.update_self", "users.update_status", "users.delete", "users.delete_self", "users.cancel_delete_self", "users.purge_due_deletions", "users.deletion_requests.list", "users.search",

View File

@ -0,0 +1,307 @@
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
}

View File

@ -229,6 +229,10 @@ func (a *App) initAppRoutes() {
groupV1.Put("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.update"), h.UpdateFieldOption)
groupV1.Delete("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.delete"), h.DeleteFieldOption)
// Admin payments (register before /admin/:id<int> so "payments" is not captured as an admin id)
groupV1.Get("/admin/payments", a.authMiddleware, a.RequirePermission("payments.list_all"), h.ListAdminPayments)
groupV1.Get("/admin/payments/:id", a.authMiddleware, a.RequirePermission("payments.list_all"), h.GetAdminPayment)
// Question Sets
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
@ -348,9 +352,9 @@ func (a *App) initAppRoutes() {
// Admin management
groupV1.Get("/admin", a.authMiddleware, a.RequirePermission("admins.list"), h.GetAllAdmins)
groupV1.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
groupV1.Get("/admin/:id<int>", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin)
groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin)
groupV1.Put("/admin/:id<int>", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin)
groupV1.Post("/admin/roles/:role/bulk-deactivate", a.authMiddleware, h.BulkDeactivateAccountsByRole)
groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole)