Money and rewards
Prevent duplicate value, unbounded issuance and unexplained balances.
On this page
Duplicate points can be spent before anyone notices. Points, vouchers, credits and cashback are value: every movement needs an owner, a reason and a record that adds up.
Ask the agent to review value movement
Review [Jira key, PR URL or diff] for how it moves value.
For a Jira ticket, read its description, acceptance criteria and comments through the connected Jira MCP or authenticated acli; ask for pasted details if access is unavailable.
Trace eligibility and amount calculation through to ledger entries and balances. Check retries, concurrent requests, budget limits and bulk-input approval against the requirements on this page.
Report each gap with synthetic test evidence.
Report which of this page's tests exist in the project's test suite. Add the missing ones that can be automated, and list the rest.
Do not issue value or change production records.Record every movement of value
-
Never use floating point for amounts. Use integer minor units or a fixed-point decimal type, from the database to the API to exports.
CautionUse strings for decimal amounts or integers within the API client’s exact numeric range, such as JavaScript’s safe-integer limit; check parsing and exports too.
- Every change in value is an append-only ledger entry. Balances come from the ledger, or are cached from it in the same transaction. A correction is a new entry that points to the original, never an edit.
- The server decides ownership, eligibility, amounts and prices. The client sends intent, never values.
- Redemption codes can’t be guessed or enumerated. They are random and long enough, and failed attempts are limited per account as well as per IP.
Example: the client sends intent, not values
Bad
POST /vouchers/V-81/redeem
{"customer_id": 42, "points": 500}
Better
POST /vouchers/V-81/redeem
{}
The server takes the customer from the session and the points from the voucher.Make retries and concurrency safe
-
Every operation that moves value has an idempotency key. A replay returns the first result and moves nothing.
CautionKeep keys in the database under a unique constraint. Cache expiry or eviction must not remove replay protection.
- The database enforces one-time and per-customer limits. Use a unique constraint or a conditional update. Never check in application code and then write.
- A timeout means the outcome is unknown. Before retrying a call that moves value or costs money, look up what happened.
- Key issuance on the business event, such as
(campaign, customer, period), not on a request ID that changes with each retry. - Scope request keys to the authenticated customer and operation type. Reject a reused key with different input.
PostgreSQL: one issuance per customer and campaign period
-- All key columns and kind must be NOT NULL.
CREATE UNIQUE INDEX ledger_issue_once
ON ledger_entries (campaign_id, customer_id, period)
WHERE kind = 'issue';
INSERT INTO ledger_entries
(campaign_id, customer_id, period, kind, amount)
VALUES ($1, $2, $3, 'issue', $4)
ON CONFLICT (campaign_id, customer_id, period)
WHERE kind = 'issue' DO NOTHING
RETURNING id;- Update any cached balance in the same transaction, and only when a row was inserted.
- No returned row means the insert did nothing: fetch the original issuance in a separate statement, compare its input, and return it. Conflict handling.
Bound issuance before launch
- Everything that issues value has caps and a kill switch. Caps per customer, per campaign and overall. The kill switch works without a deploy.
- Set a campaign budget cap and end date so forgotten campaigns stop issuing value.
- Review how the campaign could be farmed before launch; eligibility alone does not prevent repeat abuse.
- Test the kill switch before launch so the operator can stop issuance without a deploy.
- Alert on issuance rate and spend so unexpected activity is visible before the budget is exhausted.
- Reserve campaign and overall budget atomically with issuance. Reading “budget remaining” and then inserting races under load.
- When issuing value also triggers a bill, such as an SMS, apply the paid-endpoint controls too.
Approve the exact bulk input
- Bulk issuance is an idempotent, resumable job. A second person approves a dry run showing recipients, amounts and totals. Execute that exact immutable input; changed input needs new approval.
The approved file changed
An operator approves a dry run for 500 recipients. Before the worker starts, the uploaded file is replaced with 5,000 rows. The job is idempotent, but issues ten times the approved value. Binding approval to immutable input prevents the substitution.
Redeem a voucher end to end
A redemption claims value locally and delivers it through a partner, so it combines the rules above with Jobs and external calls.
- Authorize. Take customer and tenant from the authenticated session. Check ownership, eligibility and limits on the server, and limit failed code attempts (unguessable codes).
- Record the operation. In a transaction, insert an operation keyed by customer, type and idempotency key under a unique constraint, with a fingerprint of the request. On conflict, reject changed input or return the stored result.
- Claim and record value. In the same transaction, claim the voucher conditionally, write the ledger entry, update any cached balance and persist a pending fulfilment. If the claim fails, roll back.
- Deliver durably. A worker sends pending fulfilments with the operation ID as the partner’s idempotency key and records the outcome. A retry must not create a second order.
- Resolve uncertainty. After a timeout, look up the partner’s record before retrying. If it cannot tell you, leave the fulfilment pending for reconciliation.
PostgreSQL: the voucher claim within step 3
UPDATE vouchers
SET redeemed_at = now(), operation_id = $1
WHERE code = $2
AND tenant_id = $3 AND customer_id = $4
AND redeemed_at IS NULL AND expires_at > now()
RETURNING id;
-- $3 and $4 come from authenticated scope, not request fields.
-- No row: do not fulfil; roll back without disclosing other owners.- At PostgreSQL’s default Read Committed isolation, a competing update waits and then rechecks its condition, which is what makes the conditional claim safe. Concurrency behaviour.
- Expiring or refunding points is also a ledger entry; never hide or overwrite the original movement.
Claimed but never delivered
The transaction commits, then the process dies before queuing the partner request. The voucher looks redeemed but the customer receives nothing. A durable pending fulfilment lets a worker resume delivery after restart.
Test replays and reconcile balances
- Run an issuance twice with the same input. The second run issues nothing.
- Kill a bulk job halfway and resume it. Every recipient ends up with exactly one entry.
- Restart the service between a request and its retry. The retry still returns the first result.
- The ledger sum per account equals its cached balance.
- Fire the same redemption 20 times in parallel with different keys. Exactly one claim succeeds.
- Replay the same key during and after the first request. Confirm one operation and ledger effect; after completion return the stored result. Reuse with changed input is rejected.
- Crash after commit and before delivery, then resume the worker. Force a partner timeout after acceptance. Recovery finds the durable intent and existing partner order without creating another.
- Reconcile ledger totals against cached balances to catch balance updates without a ledger entry, and against independent provider records to catch what both missed. Run it on a schedule and alert on differences.