Ingestion, Wallet, and Rewards
This page covers the current payment-to-points flow in apps/api/src/services/ingestion.ts and apps/api/src/services/wallet-service.ts.
Pipeline Overview
Square and Clover converge on the same provider-agnostic ingestion core.
Current stages
- Build the idempotency key from provider, merchant, and provider transaction ID.
- Skip the write if
idempotency_keysalready contains the key. - Resolve a consumer from email, phone, or provider customer references.
- Insert the canonical
transactionsrow. - If a consumer exists, load the active reward rule and evaluate points with
@salesarc/reward-engine. - If points are earned, credit the wallet, append a reward event, and fire the points-earned email asynchronously.
- Record the idempotency key with a 30-day expiry.
The return value is one of:
creditedstored_anonymousduplicateno_rule_no_credit
Entry Points
| Function | Purpose |
|---|---|
processSquareWebhook() | Read a raw Square event, normalize it, enrich contact data when possible, and ingest it |
processCloverWebhook() | Read a raw Clover event, fetch each referenced payment, normalize it, and ingest it |
executeSquareIngestion() | Thin wrapper around the shared ingestion core |
executeCloverIngestion() | Thin wrapper around the shared ingestion core |
runSquarePaymentsPollForAllMerchants() | Poll Square ListPayments for active connections and feed the same ingest path |
Identity Resolution
resolveUserIdFromCustomerRef() handles:
- exact email matches
- exact phone matches
- last-10-digit phone matching for normalized contacts
If no matching user exists but the normalized transaction carries contact data, the ingestion path can provision a consumer identity so the transaction is not lost.
Reward Engine Behavior
packages/reward-engine/src/evaluate.ts is pure and deterministic.
Decision flow:
- Reject transactions below
minSpendMinorUnits. - Convert minor units to dollars.
- Multiply by
conversionRate. - Apply the campaign multiplier (currently
1in ingestion). - Cap at
maxPointsPerTransaction. - Apply the rounding policy (
floor,ceil, orround).
checkRedemptionEligibility() is used for redemption preview logic and enforces:
- minimum wallet balance to redeem
- cart-based redemption cap
Wallet Credit Path
creditWallet() runs in a DB transaction:
- Upsert the wallet row for
(tenantId, userId). - Append a
creditrow inwallet_ledger. - Update the wallet snapshot balance.
- Insert the linked
reward_eventsrow.
The ledger is the durable audit trail; pointsBalance is a read-optimized snapshot.
Consumer Redemption
Preview
POST /api/v1/consumer/wallets/:walletId/redeem/preview validates ownership and balance, then returns the projected discount and the new balance.
Confirm
POST /api/v1/consumer/wallets/:walletId/redeem/confirm:
- Locks the wallet row with
FOR UPDATE. - Validates the requested redemption amount.
- Inserts a
debitrow intowallet_ledger. - Decrements the wallet snapshot.
- Creates a redemption code with a 15-minute expiry.
- Emails the code to the consumer.
The POS operator later verifies the code through /api/v1/pos/redemptions/*.
Invariants
- Wallet ledger rows are append-only.
- Transaction deduplication is enforced by the database.
- All amounts are stored in minor units.
- Reward events link transactions to the exact rule version used.
- Redemption uses a row lock to prevent concurrent overspend.
- Square polling clamps its watermark to real time so future-dated timestamps cannot skip transactions.
- Clover polling holds
clover_payments_watermark_atwhile a payment is still settling, and a future-dated timestamp cannot move the watermark past now.
Current Gaps
- Idempotency keys are stored with an expiry but the lookup path currently treats them as permanent dedup entries.
- Ledger reconciliation jobs are not implemented yet.