Skip to main content

Ingestion, Wallet, and Rewards

This document describes the critical transaction-to-reward pipeline.

Pipeline Overview

Primary ingestion implementation is in apps/api/src/services/ingestion.ts.

Both Square and Clover webhook/poll flows converge to executeIngestion(...), a provider-agnostic core.

Processing stages

  1. Build idempotency key: {provider}:{merchantId}:{providerTxnId}
  2. Check idempotency_keys for prior processing — return duplicate if found
  3. Resolve user from customer ref (email/phone)
  4. Insert canonical transactions row (onConflictDoNothing)
  5. If user resolved: a. Load active tenant reward rule b. Evaluate points using @salesarc/reward-engine c. If points > 0: credit wallet, record reward event, fire email notification
  6. Record idempotency key with 30-day expiry
  7. Return outcome

Provider-Specific Entry Points

FunctionSourceDescription
processSquareWebhook()Square webhook async handlerFetch raw event, normalize, enrich customer, ingest
processCloverWebhook()Clover webhook async handlerFetch raw event, extract payment IDs, call Clover API per ID (connection scoped to each merchantId), normalize, ingest; marks processed=true only when all payments succeed
executeSquareIngestion()Square webhook + pollThin wrapper around executeIngestion()
executeCloverIngestion()Clover webhookThin wrapper around executeIngestion()

Customer Identity Resolution

resolveUserIdFromCustomerRef() in ingestion.ts supports:

  • Email exact match (lowercased, case-insensitive)
  • Phone exact match
  • Phone last-10-digits fallback using SQL normalization: right(regexp_replace(mobile, '[^0-9]', '', 'g'), 10)

Square Customer Enrichment

Webhook path may enrich missing buyer contact using Square RetrieveCustomer API when only customer_id is available in the payment. This requires:

  • Active Square connection with valid access token
  • CUSTOMERS_READ OAuth scope

Clover Customer Enrichment

Clover payments include customer data when fetched with ?expand=customers. The adapter extracts contact from payment.customers.elements[0].

Reward Engine Behavior

packages/reward-engine/src/evaluate.ts is pure and deterministic.

Decision path:

  1. Enforce minSpendMinorUnits — reject if below threshold
  2. Convert minor units to dollars: amountMinorUnits / 100
  3. Apply conversionRate: dollars * conversionRate
  4. Apply campaignMultiplier (currently default 1)
  5. Cap by maxPointsPerTransaction
  6. Apply rounding policy (floor|ceil|round)

Redemption Eligibility

checkRedemptionEligibility(balance, cartAmount, rule) computes:

  • Min redeemable balance threshold (minBalanceToRedeem)
  • Cart-percentage cap: maxRedemptionPercentage of cart amount
  • Current wallet balance
  • Returns: eligible flag, max redeemable points, or rejection reason

Wallet Credit Path

creditWallet(...) in apps/api/src/services/wallet-service.ts executes in one DB transaction:

  1. Upsert wallet record (tenant + user) — creates on first transaction
  2. Fetch wallet ID
  3. Append ledger credit entry with source transaction and rule version
  4. Increment snapshot points_balance via SQL atomic update
  5. Insert reward_events linkage (wallet + transaction + rule version)

This couples auditability (ledger/event) with fast reads (snapshot balance).

Post-Credit Email Notification

After successful credit, the pipeline fires an async email notification:

  1. Fetches user email, tenant name, and updated wallet balance
  2. Calls sendPointsEarnedEmail() with points earned, purchase amount, and new balance
  3. Fire-and-forget — email failures are logged but never block the main flow

Consumer Redemption Path

Preview

  • POST /api/v1/consumer/wallets/:walletId/redeem/preview

Checks ownership and balance, then returns projected discount and new balance.

Conversion: 1 point = $0.01 (1 minor unit).

Confirm

  • POST /api/v1/consumer/wallets/:walletId/redeem/confirm

Within transaction:

  1. Lock wallet row (SELECT ... FOR UPDATE) to prevent concurrent over-redemption
  2. Validate balance >= requested points
  3. Append ledger debit entry
  4. Decrement wallet snapshot balance

Then generate a 6-character random redemption code with 15-minute expiry and send via email using sendRedemptionCodeEmail().

Outcome States

executeIngestion() returns one of:

OutcomeMeaning
creditedUser resolved, reward rule active, points > 0, wallet credited
stored_anonymousTransaction stored but no matching SalesArck user found
duplicateIdempotency key already exists (safe skip)
no_rule_no_creditUser resolved but no active rule, or points = 0 (below min spend)

These outcomes are operationally useful for dashboarding and incident triage.

Financial Integrity Invariants

  1. Transactions dedup unique key: (tenant_id, provider, provider_transaction_id)
  2. Append-only wallet ledger semantics — no UPDATE or DELETE
  3. Positive-only ledger points with directional entry_type (credit/debit)
  4. FOR UPDATE row lock on redemption to prevent race overspend
  5. Reward events linked to wallet + transaction + rule version for auditability
  6. Idempotency keys with 30-day TTL prevent replay attacks
  7. All amounts in minor units (cents) to avoid floating-point errors

Known Gaps / Next Hardening Areas

  • Idempotency key replay snapshots are not yet returned to callers
  • Campaign multiplier source is static in ingestion path (always 1)
  • Redemption verification flow for POS operator can be expanded
  • Automated ledger reconciliation jobs are not yet implemented
  • Clover polling fallback (equivalent to Square cron poll) is not yet built
Written byDhruv Doshi