Admin balance adjustments

Reward Points

Admin balance adjustments

Adjustments are the manual override channel. Use them for goodwill credits, customer-service make-goods, audit corrections, or to back out a botched campaign.

Where it lives

/admin/rewards → Users tab.

The Users tab lists every customer who has at least one reward row, sorted by available balance descending, with a search box that matches against email, first_name, and last_name (ILIKE). Each row shows the customer's available balance and a button to drill in to detail.

The user-detail view (a click into any row) shows:

  • Current balance (4 KPIs, same shape as the customer-facing portal)
  • Full transaction history, paginated
  • An Adjust balance action

Granting points (positive adjust)

POST /api/v1/admin/rewards/users/{user_id}/adjust
{ "points": 250, "description": "Customer support goodwill — case #4521" }

Validation:

  • points cannot be zero (400 points cannot be zero)
  • description cannot be empty (400 description is required)
  • module enabled — if OFF, the service returns (nil, nil) and the handler 500s

A positive adjust writes one row of type='adjust', points=+250, order_id=NULL. It is not the same as a bonus row — bonus was reserved by the data model for marketing automation; adjust is reserved for human / programmatic corrections. Both add to total_earned, but the type discriminator lets analytics tell campaign credits from manual fix-ups.

Removing points (negative adjust)

POST /api/v1/admin/rewards/users/{user_id}/adjust
{ "points": -100, "description": "Reverting duplicate signup credit" }

Negative adjust does NOT check the user's available balance — it can push the math negative on paper. The GetBalance endpoint floors Available at zero so the customer-facing UI never shows a negative number, but the underlying ledger sum is unconstrained.

This is the right outcome: if a customer has spent a credit you need to claw back, going temporarily negative is the audit trail saying "they owe us 100 points before they earn again". The next earn rebuilds toward zero.

What gets logged

Every adjust call writes an activity log entry:

type:        content
action:      adjust
entity_type: reward_points
entity_id:   <transaction_id>
content:     { user_id: …, points: ±N }
user_id:     <admin who pressed the button>
user_email:  <admin's email>
ip:          <admin's IP>
user_agent:  <admin's browser>

So every adjust is forensically traceable to the operator who made it. Pair this with Logs → Activity filtered on entity_type=reward_points, action=adjust to audit a window.

Use cases that should reach for Adjust

SituationAdjust call
Customer service goodwill+N, "goodwill — ticket #X"
Mistakenly-credited points (op-error)−N, "reverting earn for cancelled order #X"
Migrating balance from a legacy system+N, "import from CS-Cart 2024-03-01"
Refund clawback that didn't auto-fire−N, "manual revoke for refund #X"
One-off campaign reward for top-N customers+N, "Black Friday top-100 thank-you"
Test data cleanup in staging−N, "test reset"

Use cases that should NOT reach for Adjust

  • Order completion earning — that's the listener's job. If it didn't fire, fix the listener wire-up, don't paper over it with adjusts.
  • Ageing out old points — that's ExpireOldPoints. Manual ageing creates inconsistent history.
  • Bulk import via CSV — there's no UI for this today; build a one-off backend script that calls AdjustPoints per row with a consistent description prefix (e.g. import:) so you can audit-trail the run.

Bulk + scripted adjustments

There is no bulk-adjust endpoint. If you need to credit 10 000 customers at once:

  1. Write a tenant-aware Go script that loops the user list and calls rewards.Service.AdjustPoints directly.
  2. Use a consistent description prefix — campaign:black-friday-2026: — so the activity log filter pulls one cohort.
  3. Run during off-peak hours; each adjust is one INSERT — the cost is one row + one logging row per customer.

The HTTP route (/users/{user_id}/adjust) is rate-bounded by the global admin rate limiter; for >1 000 customers, prefer the in-process path.

Privilege requirements

The adjust route uses manage_rewards ∨ manage_promotions (Wave 11 alias). Read-only operators with view_rewards can see the Users tab and balances but cannot fire an adjust — the button hides for them.