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:
parent
6423bb261e
commit
fbad083ca4
|
|
@ -93,3 +93,77 @@ WHERE id = $1;
|
||||||
|
|
||||||
-- name: CountUserPayments :one
|
-- name: CountUserPayments :one
|
||||||
SELECT COUNT(*) FROM payments WHERE user_id = $1;
|
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 || '%'
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 | `/api/v1/payments/chapa/success` | No | Chapa learner success page (HTML) |
|
||||||
| GET | `/payment/success` | No | Same HTML success page (`CHAPA_RETURN_URL`) |
|
| GET | `/payment/success` | No | Same HTML success page (`CHAPA_RETURN_URL`) |
|
||||||
| GET | `/api/v1/payments/methods` | No | Supported Chapa methods |
|
| 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
|
### Initiate payment request
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,73 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const CountUserPayments = `-- name: CountUserPayments :one
|
||||||
SELECT COUNT(*) FROM payments WHERE user_id = $1
|
SELECT COUNT(*) FROM payments WHERE user_id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -391,6 +458,160 @@ func (q *Queries) LinkPaymentToSubscription(ctx context.Context, arg LinkPayment
|
||||||
return err
|
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
|
const UpdatePaymentSessionID = `-- name: UpdatePaymentSessionID :exec
|
||||||
UPDATE payments
|
UPDATE payments
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,38 @@ type Payment struct {
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt *time.Time
|
UpdatedAt *time.Time
|
||||||
PlanName *string
|
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 {
|
type CreatePaymentInput struct {
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,5 @@ type PaymentStore interface {
|
||||||
LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error
|
LinkPaymentToSubscription(ctx context.Context, paymentID, subscriptionID int64) error
|
||||||
GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error)
|
GetExpiredPendingPayments(ctx context.Context) ([]domain.Payment, error)
|
||||||
ExpirePayment(ctx context.Context, id int64) error
|
ExpirePayment(ctx context.Context, id int64) error
|
||||||
|
ListPaymentsAdmin(ctx context.Context, filter domain.PaymentListFilter) (domain.PaymentListPage, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,103 @@ func (s *Store) ExpirePayment(ctx context.Context, id int64) error {
|
||||||
return s.queries.ExpirePayment(ctx, id)
|
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
|
// Helper functions
|
||||||
|
|
||||||
func paymentToDomain(p dbgen.Payment) *domain.Payment {
|
func paymentToDomain(p dbgen.Payment) *domain.Payment {
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,25 @@ func (s *Service) GetPaymentsByUser(ctx context.Context, userID int64, limit, of
|
||||||
return s.paymentStore.GetPaymentsByUserID(ctx, userID, limit, offset)
|
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) {
|
func (s *Service) GetPaymentByID(ctx context.Context, id int64) (*domain.Payment, error) {
|
||||||
return s.paymentStore.GetPaymentByID(ctx, id)
|
return s.paymentStore.GetPaymentByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "payments.cancel", Name: "Cancel Payment", Description: "Cancel a payment", GroupName: "Payments"},
|
{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_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.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
|
// Users
|
||||||
{Key: "users.list", Name: "List Users", Description: "List all users", GroupName: "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.create", "subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
|
||||||
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
||||||
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
|
"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 (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",
|
"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",
|
||||||
|
|
|
||||||
307
internal/web_server/handlers/payments_admin.go
Normal file
307
internal/web_server/handlers/payments_admin.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -229,6 +229,10 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Put("/admin/field-options/:id", a.authMiddleware, a.RequirePermission("field_options.update"), h.UpdateFieldOption)
|
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)
|
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
|
// Question Sets
|
||||||
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
|
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)
|
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
|
||||||
|
|
@ -348,9 +352,9 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// Admin management
|
// Admin management
|
||||||
groupV1.Get("/admin", a.authMiddleware, a.RequirePermission("admins.list"), h.GetAllAdmins)
|
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.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-deactivate", a.authMiddleware, h.BulkDeactivateAccountsByRole)
|
||||||
groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole)
|
groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user