diff --git a/db/query/payments.sql b/db/query/payments.sql index 51e366c..a5b5b9a 100644 --- a/db/query/payments.sql +++ b/db/query/payments.sql @@ -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 || '%' + ); diff --git a/docs/CHAPA_INTEGRATION.md b/docs/CHAPA_INTEGRATION.md index 43da003..2155d94 100644 --- a/docs/CHAPA_INTEGRATION.md +++ b/docs/CHAPA_INTEGRATION.md @@ -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 diff --git a/gen/db/payments.sql.go b/gen/db/payments.sql.go index 7550589..5ba191a 100644 --- a/gen/db/payments.sql.go +++ b/gen/db/payments.sql.go @@ -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 diff --git a/internal/domain/payment.go b/internal/domain/payment.go index a6f3f04..f15aab0 100644 --- a/internal/domain/payment.go +++ b/internal/domain/payment.go @@ -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 { diff --git a/internal/ports/payment.go b/internal/ports/payment.go index 2570d5a..1bc7c3b 100644 --- a/internal/ports/payment.go +++ b/internal/ports/payment.go @@ -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) } diff --git a/internal/repository/payments.go b/internal/repository/payments.go index cd3957a..ee884af 100644 --- a/internal/repository/payments.go +++ b/internal/repository/payments.go @@ -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 { diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 6d983c9..254497e 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -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) } diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index d7917b7..e4a25dc 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -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", diff --git a/internal/web_server/handlers/payments_admin.go b/internal/web_server/handlers/payments_admin.go new file mode 100644 index 0000000..7e1659e --- /dev/null +++ b/internal/web_server/handlers/payments_admin.go @@ -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 +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 9ca67f1..9fe4170 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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 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", 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", 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)