Skip to main content

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​

  1. Build the idempotency key from provider, merchant, and provider transaction ID.
  2. Skip the write if idempotency_keys already contains the key.
  3. Resolve a consumer from email, phone, or provider customer references.
  4. Insert the canonical transactions row.
  5. If a consumer exists, load the active reward rule and evaluate points with @salesarc/reward-engine.
  6. If points are earned, credit the wallet, append a reward event, and fire the points-earned email asynchronously.
  7. Record the idempotency key with a 30-day expiry.

The return value is one of:

  • credited
  • stored_anonymous
  • duplicate
  • no_rule_no_credit

Entry Points​

FunctionPurpose
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:

  1. Reject transactions below minSpendMinorUnits.
  2. Convert minor units to dollars.
  3. Multiply by conversionRate.
  4. Apply the campaign multiplier (currently 1 in ingestion).
  5. Cap at maxPointsPerTransaction.
  6. Apply the rounding policy (floor, ceil, or round).

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:

  1. Upsert the wallet row for (tenantId, userId).
  2. Append a credit row in wallet_ledger.
  3. Update the wallet snapshot balance.
  4. Insert the linked reward_events row.

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:

  1. Locks the wallet row with FOR UPDATE.
  2. Validates the requested redemption amount.
  3. Inserts a debit row into wallet_ledger.
  4. Decrements the wallet snapshot.
  5. Creates a redemption code with a 15-minute expiry.
  6. Emails the code to the consumer.

The POS operator later verifies the code through /api/v1/pos/redemptions/*.

Invariants​

  1. Wallet ledger rows are append-only.
  2. Transaction deduplication is enforced by the database.
  3. All amounts are stored in minor units.
  4. Reward events link transactions to the exact rule version used.
  5. Redemption uses a row lock to prevent concurrent overspend.
  6. Square polling clamps its watermark to real time so future-dated timestamps cannot skip transactions.
  7. Clover polling holds clover_payments_watermark_at while 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.
Written byDhruv Doshi