Points Reward System PRD

Points Reward System

Graize tipping platform — feature specification, v5

Internal SA confirmed the approach 29 Oct 2026 Gasper Okrslar, Povio Source: Nolan Heatley, Oct 28

Internal document. The solution architect has confirmed the approach. A separate, client-facing version will be shared with Graize.

Summary — read this first

Overview

A credit-card-style points reward system where workers accumulate points on every tip they receive. Points can later be redeemed for cash loaded back into the worker's Graize balance.

Scope decision: v1 redeems points for cash only; partner redemptions are a planned later phase. The ledger, hold, liability tracking and program terms below are the foundation for that program, not overhead for a cashback feature — that is how the estimate should be read.

Points formula

Tip amount × 0.01 × 1,000 = points awarded

Each point = $0.001 → every 1,000 points = $1.00

Example: $2.00 tip → 20 points → worth $0.02

Implementation form

points = floor(tipCents × earnRateBps / 1,000) where earnRateBps = 100 means 1%.

Tip amountPoints earnedCash value
$1.0010$0.01
$2.0020$0.02
$5.0050$0.05
$20.00200$0.20
$50.00500$0.50
$100.001,000$1.00

Denomination: 100 points per dollar — decide before build

Nolan's proposal values a point at $0.001. Most consumer programs value a point at one cent (100 points = $1.00) — the usual card-rewards denomination and the one people already know how to read. Povio recommends it. This is a v1 decision, not a preference: once partner rewards are priced in points, changing the denomination means repricing a live catalog and re-explaining every balance. It needs an answer before the first backend PR. Everything else stays the same; every point figure simply divides by ten.

1,000 pts / $ (proposed)100 pts / $ (Povio)
Value of one point$0.001$0.01
Points on a $1 tip at 1%101
Points on a $5 tip at 1%505
Points on a $20 tip at 1%20020
Points on a $100 tip at 1%1,000100
Minimum redemption ($1.00)1,000 pts100 pts
Formulafloor(tipCents × rateBps / 1,000)floor(tipCents × rateBps / 10,000)
Mental mathpoints ÷ 1,000 = dollarspoints ÷ 100 = dollars (1 pt = 1¢)

What changes in behaviour: only rounding granularity. At 100 pts/$ a point is a whole cent, so the reward truncates to whole cents instead of tenths of a cent. At 1% that means a $1.50 tip earns 1 point (losing half a cent) and anything under $1.00 earns nothing — harmless, since the minimum tip is $1.00 (payment-policy.ts rejects anything under 100 cents) and tips are almost always whole dollars. It only matters if the rate ever drops below 1% (at 0.5% a $1 tip would earn 0 points); a "minimum 1 point per tip" rule covers that.

Why we prefer it: smaller numbers read as honest ("+5 pts" on a $5 tip is visibly a nickel, where "+50 pts" invites a double-take), the balance never needs a thousands separator, and workers can convert to dollars in their head. Cost to Graize is identical. If Nolan prefers 1,000/$ for the bigger numbers, nothing in the design changes — the denomination is a single constant.

Redemption

Reward funding model

Where the money comes from: Graize's platform Stripe balance, funded by the platform fee on each tip. The admin dashboard shows the total amount owed in points (as points and as dollars) next to the current platform balance, so Graize can always see that there are enough funds to cover redemptions and top up when needed.

A dedicated reward pool (a separate Stripe balance or Treasury account that rewards are paid from) is the alternative. It gives cleaner accounting but adds integration work and another balance to fund; we recommend it as a future step once volume justifies it, not for v1.

Fee model is changing

Graize is moving to a fixed amount + percentage platform fee. The table below uses 5% + $0.40 per tip as a working example — not yet confirmed by the client; the final amounts will be set so the platform is profitable. This matters for points:

TipFee (5% + $0.40)Stripe processing*Reward at 1%Net to Graize
$5$0.65$0.46$0.05$0.14
$20$1.40$0.92$0.20$0.28
$50$2.90$1.83$0.50$0.57
$100$5.40$3.36$1.00$1.04

* 2.9% + $0.30 on tip + fee. Example fee only; per-payout and monthly-account Connect fees are not per tip and are left out. At these numbers the reward takes 8–19% of the fee and every tip size stays positive.

Promo rates are tight. At 2% the net on a $20 tip falls to $0.08 and on $100 to $0.04; at 3% it is negative on anything above $5. The ceiling is the fee's percentage component minus Stripe's processing share — about 1.9% with this example fee. The earn rate itself stays admin-configurable; only its ceiling is derived from the configured fee rather than hard-coded, so an admin cannot set a rate that loses money (see Decisions).

Cost of a redemption

Close to zero. A redemption is a Transfer from the platform account to the worker's connected account; Stripe's Connect price list has no per-transfer charge. The reward then sits in the worker's Stripe balance alongside their tips and reaches their bank on their next "Transfer to bank". Graize already pays Stripe for that payout (0.25% + $0.25, plus $2 for the account's active month, under the platform-pays-fees model Graize uses), so the reward only adds 0.25% of its own value — a quarter of a cent per $1. Redemption must never create a bank payout of its own; that would cost at least $0.25 each time.

Platform balance timing

Application fees sit in pending for several days after each tip. Early on, the available platform balance may be too small to cover a redemption and the Transfer fails with balance_insufficient. The redemption flow treats this as a retryable failure, and Stripe's Top-ups API can pre-fund the balance from Graize's bank if needed.

Non-cash redemptions (v2 idea, not in scope)

A redemption with high perceived value and low real cost would justify points better than cash. The obvious candidate is waiving the 1.5% instant-payout fee ($3 of perceived value on a $200 cash-out), but at Stripe's current pricing Graize is billed that same 1.5%, so it costs about as much as cash — and instant payouts are not enabled in the current config. Parked: revisit if Graize negotiates instant-payout pricing or finds a perk whose real cost is clearly below its perceived value.

Liability tracking

Admin dashboard shows total outstanding points as a dollar figure, side by side with the platform's available balance. That number is a liability on Graize's books — see Risks and legal flags.

Decisions

Proposed by Povio from Nolan's thread and follow-ups. Each one needs Nolan's explicit yes.

Open questions for Nolan and UX

Scenarios

S1

Worker receives a tip → points awarded

  1. Tipper sends a $5.00 tip.
  2. Payment is confirmed (Stripe webhook).
  3. System awards floor(500 × 100 / 1000) = 50 points at the current rate.
  4. Worker's tip notification reads "+50 pts"; rewards screen shows them as pending (available in 30 days), next to the available balance and progress to the redemption minimum.
S2

Points become available and the worker redeems

  1. 30 days after a tip, its points move from pending to available.
  2. Available balance is 6,200 points ($6.20), above the 1,000 minimum. Worker taps "Redeem"; 6,200 points are reserved.
  3. Backend transfers $6.20 from Graize's platform account to the worker's connected account.
  4. On success the redemption is marked completed and appears in the worker's activity as "Points reward · $6.20". The money leaves with the next regular payout.
  5. If the transfer fails (e.g. platform balance insufficient), the points are released back to available and the worker sees "Redemption pending — try again shortly".
S3

Tip is refunded → points reversed

  1. Worker received 50 points from a $5.00 tip.
  2. Tip is refunded in full inside the 30-day hold. System reverses the 50 pending points — nothing was paid out. History shows "−50 pts · $5.00 tip refunded".
  3. Partial refund of $2.00 → reverse floor(200 × 100 / 1000) = 20 points.
  4. If the refund lands after the points were credited (rare), the reversal comes out of the available balance and can take it negative; future earnings pay it down.
S4

Dispute

  1. Chargeback is opened → no change to the balance, but the tip's pending points stop maturing until the dispute closes.
  2. Dispute lost → points reversed as in S3.
  3. Dispute won after a reversal → points re-awarded.
S5

Admin changes earn rate

  1. Admin sets the rate to 1.5% (promotion). The panel shows the derived cap (~1.9% at the example fee) and a margin warning; confirmation dialog; change is audit-logged. A value above the cap is refused.
  2. New tips earn at 1.5%. Rewards screen shows "Earning 1.5% on tips". Workers get a push.
  3. The promo ends on its announced date and the rate returns to 1% (a promo with a stated end needs no separate notice). Points earned during the promo are unaffected.
S6

Admin manually adjusts points

  1. Admin awards 1,000 bonus points with reason "Welcome bonus".
  2. Worker sees "+1,000 pts · Welcome bonus" and a push.
  3. A deduction works the same way; the reason is mandatory and visible.
S7

Chargeback after the points were credited (the backstop case)

  1. Day 0: worker earns 5,000 pending pts from a $500 tip.
  2. Day 30: no dispute open, points become available; the worker redeems $5.00.
  3. Day 45: chargeback opened. Day 75: lost. Reversal of −5,000 takes the balance negative; Graize is out $5.00 until future earnings cover it.
  4. Had the chargeback arrived on day 20 instead, the pending points would have been frozen and then reversed with nothing paid out — the normal case.

Notes on the original proposal

Implementation approach

Data model (new tables in database.ts)

pointsBalances
  userId          text PK
  available       integer NOT NULL DEFAULT 0   -- matured points; negative only via a late reversal (backstop)
  pending         integer NOT NULL DEFAULT 0   -- earn rows whose availableAt is still in the future
  updatedAt       text

pointsLedger
  id              uuid PK
  userId          text NOT NULL
  amount          integer NOT NULL            -- + earn/bonus, - reversal/redemption
  kind            text NOT NULL               -- tip_earn | reversal | redemption | admin_adjustment | bonus | closure_forfeit
  referenceId     text                        -- paymentAttempts.id | pointsRedemptions.id | refund/dispute id
  earnRateBps     integer                     -- rate at time of earn; reused for reversals
  availableAt     text                        -- earn rows: createdAt + holdDays; null = available now
  maturedAt       text                        -- set by the maturation job when the row becomes available
  pendingDelta    integer                     -- reversal rows: portion taken from pending; the rest came from available
  actorUserId     text                        -- admin who made a manual adjustment
  description     text                        -- mandatory for admin adjustments; shown to worker
  idempotencyKey  text NOT NULL UNIQUE        -- e.g. {connectedAccountId}:{intentId}:points_earn
  createdAt       text
  INDEX (userId, createdAt DESC)
  INDEX (referenceId, kind)

pointsRedemptions                              -- modelled on payoutAttempts; partner-shaped from day one
  id              uuid PK
  userId          text NOT NULL
  kind            text NOT NULL               -- 'cash' in v1; 'partner:' in v2
  points          integer NOT NULL
  valueCents      integer NOT NULL            -- cash delivered, or the catalog value of a partner reward
  status          text NOT NULL               -- created | pending | completed | failed
  connectedAccountId text                     -- cash only
  transferId      text                        -- cash only: Stripe tr_...
  fulfilmentRef   text                        -- partner only (v2): partner order / voucher id
  failureCode     text
  createdAt / updatedAt

Config (earnRateBps default 100, holdDays default 30, minRedeemPoints default 1000, fee parameters, and maxEarnRateBps derived from the fee) is declared in code with typed defaults and overridden by DB rows, same pattern as feature-flags.ts. Lifetime earned/redeemed are derived from the ledger, not stored.

Where it hooks into existing code

The wallets table is not the balance

wallets in database.ts is a template leftover with zero references in api/. The worker's balance is the live Stripe connected-account balance (recipient-balance.ts). The Transfer is the credit; ledgerEntries is history only. v2 of this PRD described crediting wallets.balanceCents — that would have double-counted.

API endpoints (new)

MethodPathAuthDescription
GET/api/recipient/pointsrequiredAvailable and pending balances, current rate, threshold, recent history
POST/api/recipient/points/redeemrequiredRedeem (all, or ≥ minimum)
GET/api/admin/points/configadminCurrent config
PUT/api/admin/points/configadminUpdate rate / threshold (capped, audited)
GET/api/admin/points/usersadminBalances per user + total liability
POST/api/admin/points/adjustadminManual award/deduct (reason required, audited)

Per repo rules: bun openapi:snapshot + bun openapi:gen after routes change, and an apps/fe/tests/api test naming all six paths or bun test:api fails.

No additional libraries needed

Integer arithmetic on the existing Drizzle/Postgres stack, following the ledgerEntries / payoutAttempts patterns already in the codebase. A loyalty SaaS (LoyaltyLion, Smile.io) would be overkill for one earn action and one redemption type.

Scope estimate

AreaWork itemsEstimate
BackendTables, award + target-recompute reversal, maturation job, redemption attempt flow with recovery, transfer webhook + sweep, config/adjust/audit, closure policy, openapi regen28–40h
Admin panelConfig page, user list + liability, adjustment form, redemption list10–14h
MobileAvailable/pending balances, rate, progress, history, redeem flow (blocked on the open UX questions)14–20h
Testingapps/fe/tests/api for all six paths; tip→award, refund/dispute replay, concurrent redeem, transfer failure, admin audit — against Stripe test mode8–12h
DesignRewards UI, redemption, historyBy Eva
PM/QAAcceptance criteria, QA passSeparate
Total dev60–86h

The bulk of the backend hours is the redemption flow (attempt table, two-transaction pattern, recovery, Stripe webhook handling) and proper test coverage for money-moving code.

Risks and legal flags

RiskNoteAction
Unbounded liabilityNo expiration → outstanding points grow forever.Report liability monthly in dollars; inactivity expiry is planned for v2 with partner rewards (config flag, announced in the terms from day one).
Unclaimed propertyNon-expiring, cash-redeemable value may fall under state escheat rules.Flag to Graize's counsel before launch, with program terms.
TaxRedemptions are Graize paying the worker (1099-NEC/MISC territory), separate from tip 1099-Ks. Federal threshold $2,000 in 2026 — needs $200k of tips at 1% — but must be tracked per worker per year.One line to Graize's CPA; track per-worker annual redemptions.
Chargeback after creditOnly possible for disputes lost after the 30-day hold; Graize is out the reward until recovered.Hold covers the normal window; negative-balance backstop for late disputes.
FarmingNot profitable with real money (self-tipping $100 costs more in fees than the $1 reward). Real vector is stolen cards.Covered by the 30-day hold.
Admin abuse / typoA mistyped adjustment, or a promo rate above the fee margin, would mint cash. At the example fee anything over ~1.9% loses money on large tips.Earn-rate cap derived from the configured fee (refused above it, warning above half of it) plus confirmation; audit log and alert on every manual award (adjustments themselves are uncapped by client decision).
Platform balance lowFees pending for days; early redemptions may fail.Retryable failure path; Top-ups if needed.

Program terms TBD

The PRD has no program terms yet, and they matter legally. Before launch, Graize needs a short terms page (and acceptance in the app) that states at least:

Terms vs. books: "no cash value" is the worker's legal position — no claim against Graize until a redemption is credited. Graize still accounts for outstanding points as a liability (summary, risks table) because it intends to honour them. The two are not in conflict; one governs the worker's rights, the other Graize's own accounting.

Cash redeemability is also what raises the unclaimed-property exposure flagged above. That is a further argument for keeping the path from points to money short (a $1 minimum and a prompt to redeem, so little value sits as stored balance) — or for non-cash redemptions, which carry no stored-value exposure at all. Counsel to confirm.