feat(ECR-009): Ops-D order filters, plan display prices, refund status
LOOP-RUN-002 sample: admin order multi-filter, membership display price catalog, read-only refund_status. No real payment or RBAC. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -414,17 +415,30 @@ func (r *AdminRepo) GrantAskQuotaWithAudit(ctx context.Context, adminID, userID
|
||||
|
||||
// OrderListItem for admin order tables.
|
||||
type OrderListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Plan *string `json:"plan,omitempty"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Plan *string `json:"plan,omitempty"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
Status string `json:"status"`
|
||||
RefundStatus string `json:"refund_status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListOrders lists orders; optional user filter.
|
||||
func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]OrderListItem, error) {
|
||||
// OrderListFilter filters admin order lists (Ops-D).
|
||||
type OrderListFilter struct {
|
||||
UserID *uuid.UUID
|
||||
Status string
|
||||
Kind string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ListOrders lists orders with optional multi-dimensional filters.
|
||||
func (r *AdminRepo) ListOrders(ctx context.Context, f OrderListFilter) ([]OrderListItem, error) {
|
||||
limit, offset := f.Limit, f.Offset
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
@@ -432,12 +446,18 @@ func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, of
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, user_id, kind, plan, amount_cents, status, created_at
|
||||
SELECT id, user_id, kind, plan, amount_cents, status,
|
||||
coalesce(refund_status, 'none'), created_at
|
||||
FROM orders
|
||||
WHERE deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR user_id = $1)
|
||||
AND ($2::text = '' OR status = $2)
|
||||
AND ($3::text = '' OR kind = $3)
|
||||
AND ($4::timestamptz IS NULL OR created_at >= $4)
|
||||
AND ($5::timestamptz IS NULL OR created_at < $5)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, userID, limit, offset)
|
||||
LIMIT $6 OFFSET $7`,
|
||||
f.UserID, f.Status, f.Kind, f.From, f.To, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -445,7 +465,10 @@ func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, of
|
||||
var out []OrderListItem
|
||||
for rows.Next() {
|
||||
var o OrderListItem
|
||||
if err := rows.Scan(&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status, &o.CreatedAt); err != nil {
|
||||
if err := rows.Scan(
|
||||
&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status,
|
||||
&o.RefundStatus, &o.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
@@ -453,6 +476,71 @@ func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, of
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PlanPrice is a membership catalog display price (not order amount).
|
||||
type PlanPrice struct {
|
||||
Plan string `json:"plan"`
|
||||
DisplayCents int `json:"display_cents"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListPlanPrices returns all membership display prices.
|
||||
func (r *AdminRepo) ListPlanPrices(ctx context.Context) ([]PlanPrice, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT plan, display_cents, updated_at
|
||||
FROM membership_plan_prices
|
||||
ORDER BY plan`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PlanPrice
|
||||
for rows.Next() {
|
||||
var p PlanPrice
|
||||
if err := rows.Scan(&p.Plan, &p.DisplayCents, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertPlanPricesWithAudit replaces display prices and writes audit.
|
||||
func (r *AdminRepo) UpsertPlanPricesWithAudit(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
items []PlanPrice,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
for _, it := range items {
|
||||
if it.Plan == "" || it.DisplayCents < 0 {
|
||||
return fmt.Errorf("invalid plan price")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO membership_plan_prices(plan, display_cents, updated_at)
|
||||
VALUES ($1,$2,now())
|
||||
ON CONFLICT (plan) DO UPDATE SET
|
||||
display_cents=EXCLUDED.display_cents,
|
||||
updated_at=now()`, it.Plan, it.DisplayCents); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'plan_price.upsert','membership_plan_prices','catalog',$2)`,
|
||||
adminID, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// GrantMembershipWithAudit upserts membership and appends audit in one transaction.
|
||||
func (r *AdminRepo) GrantMembershipWithAudit(
|
||||
ctx context.Context,
|
||||
|
||||
Reference in New Issue
Block a user