Graize tipping platform — feature specification, v5
Internal document. The solution architect has confirmed the approach. A separate, client-facing version will be shared with Graize.
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.
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
points = floor(tipCents × earnRateBps / 1,000) where earnRateBps = 100 means 1%.
tipCents ÷ 10 — whole numbers for every whole-cent tip. Sub-dollar remainders are floored ($1.37 → 13 pts).| Tip amount | Points earned | Cash value |
|---|---|---|
| $1.00 | 10 | $0.01 |
| $2.00 | 20 | $0.02 |
| $5.00 | 50 | $0.05 |
| $20.00 | 200 | $0.20 |
| $50.00 | 500 | $0.50 |
| $100.00 | 1,000 | $1.00 |
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% | 10 | 1 |
| Points on a $5 tip at 1% | 50 | 5 |
| Points on a $20 tip at 1% | 200 | 20 |
| Points on a $100 tip at 1% | 1,000 | 100 |
| Minimum redemption ($1.00) | 1,000 pts | 100 pts |
| Formula | floor(tipCents × rateBps / 1,000) | floor(tipCents × rateBps / 10,000) |
| Mental math | points ÷ 1,000 = dollars | points ÷ 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.
pointsRedemptions carries kind and valueCents from day one so a partner reward and a cash reward look the same to the ledger and to reconciliation.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.
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:
| Tip | Fee (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).
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.
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.
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.
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.
Proposed by Povio from Nolan's thread and follow-ups. Each one needs Nolan's explicit yes.
floor(500 × 100 / 1000) = 50 points at the current rate.floor(200 × 100 / 1000) = 20 points.× 0.01 × 1,000 formula guarantees whole points for whole-cent tips. Implemented as a basis-points formula so the rate is a real knob.kind and valueCents and the terms cover non-cash rewards, so partners slot in without a migration or re-acceptance.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.
api/lib/payment-projection.ts → projectPayment(), after projectTipLedger (line 99). The caller already holds the attempt lock; everything commits in one transaction. projectPayment is re-entered from six call sites (tips, webhooks, recovery, closure, refunds), so the award row's unique idempotencyKey is what prevents double awards. Rate is read inside the transaction (no in-process cache).projectPoints(tx, attempt) recomputes the target points for the tip: floor((amountCents − succeeded refunds − lost disputes) × storedRateBps / 1000), and writes the delta as a ledger row keyed by the triggering refund/dispute id. This handles partial refunds, disputes won after a reversal, replays, and a refund arriving before the award. It must run after the refund row is upserted (refund-events.ts:152) and after the dispute upsert — not inside projectPayment, which processRefundEvent calls before writing the refund. A reversal consumes the tip's pending points first and only then available; the row records the split in pendingDelta so both balances can be rebuilt from the ledger.availableAt has passed and whose tip has no open dispute: sets maturedAt and moves the amount from pending to available. Nothing is redeemed automatically; a push ("You can now redeem $X") when a user crosses minRedeemPoints is optional. An open dispute pauses maturation for that tip's rows.recipient/payouts.ts. Transaction 1: lock pointsBalances (FOR UPDATE), check available ≥ requested, create pointsRedemptions row (kind cash, status pending), debit points. Commit. Transaction 2: lock the redemption row, look for an existing Stripe Transfer by stored id / metadata, else create one with Idempotency-Key: points_redemption:{id} and metadata.graize_points_redemption_id, then mark completed and write a ledgerEntries row (kind points_redemption) for history. On definite failure: mark failed, write a compensating credit. The row lock is also the guard against two concurrent redemptions.wallets table is not the balancewallets 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.
transfer.* webhooks are currently unhandled in stripe-event-routing.ts; add a handler. Extend activityCategory (graize.ts) and HistoryKind (financial-history.ts) for the new ledger kind so the financial sweep doesn't flag tr_ objects.audit() helper (admin-operations.ts) in the same transaction. Adjust takes a requestId for idempotency and rejects deductions below zero with 409; no amount cap.RATE_RULES; gated on recipient role and on paymentAccounts.closing. Nightly reconciliation job asserts available + pending = sum(ledger.amount), pending = sum(unmatured earn rows) − sum(pendingDelta of their reversals), and completed redemptions = Stripe transfers; exposes total liability.closure-payout.apps/admin) — config page, user points list with liability total, manual adjustment form, redemption list, and an audit log view (who changed the rate or adjusted which worker's points, when, and why).apps/mobile/modules/recipient) — available and pending balances, rate, progress to the minimum, history (earn/reversal/adjustment/redemption lines, with "available on" dates for pending earns), redemption screen (one cash option in v1, built as a list).| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/recipient/points | required | Available and pending balances, current rate, threshold, recent history |
| POST | /api/recipient/points/redeem | required | Redeem (all, or ≥ minimum) |
| GET | /api/admin/points/config | admin | Current config |
| PUT | /api/admin/points/config | admin | Update rate / threshold (capped, audited) |
| GET | /api/admin/points/users | admin | Balances per user + total liability |
| POST | /api/admin/points/adjust | admin | Manual 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.
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.
| Area | Work items | Estimate |
|---|---|---|
| Backend | Tables, award + target-recompute reversal, maturation job, redemption attempt flow with recovery, transfer webhook + sweep, config/adjust/audit, closure policy, openapi regen | 28–40h |
| Admin panel | Config page, user list + liability, adjustment form, redemption list | 10–14h |
| Mobile | Available/pending balances, rate, progress, history, redeem flow (blocked on the open UX questions) | 14–20h |
| Testing | apps/fe/tests/api for all six paths; tip→award, refund/dispute replay, concurrent redeem, transfer failure, admin audit — against Stripe test mode | 8–12h |
| Design | Rewards UI, redemption, history | By Eva |
| PM/QA | Acceptance criteria, QA pass | Separate |
| Total dev | 60–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.
| Risk | Note | Action |
|---|---|---|
| Unbounded liability | No 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 property | Non-expiring, cash-redeemable value may fall under state escheat rules. | Flag to Graize's counsel before launch, with program terms. |
| Tax | Redemptions 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 credit | Only 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. |
| Farming | Not 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 / typo | A 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 low | Fees pending for days; early redemptions may fail. | Retryable failure path; Top-ups if needed. |
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.