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
- Build idempotency key:
{provider}:{merchantId}:{providerTxnId} - Check
idempotency_keysfor prior processing — returnduplicateif found - Resolve user from customer ref (email/phone)
- Insert canonical
transactionsrow (onConflictDoNothing) - If user resolved:
a. Load active tenant reward rule
b. Evaluate points using
@salesarc/reward-enginec. If points > 0: credit wallet, record reward event, fire email notification - Record idempotency key with 30-day expiry
- Return outcome
Provider-Specific Entry Points
| Function | Source | Description |
|---|---|---|
processSquareWebhook() | Square webhook async handler | Fetch raw event, normalize, enrich customer, ingest |
processCloverWebhook() | Clover webhook async handler | Fetch 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 + poll | Thin wrapper around executeIngestion() |
executeCloverIngestion() | Clover webhook | Thin 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_READOAuth 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:
- Enforce
minSpendMinorUnits— reject if below threshold - Convert minor units to dollars:
amountMinorUnits / 100 - Apply
conversionRate:dollars * conversionRate - Apply
campaignMultiplier(currently default1) - Cap by
maxPointsPerTransaction - Apply rounding policy (
floor|ceil|round)
Redemption Eligibility
checkRedemptionEligibility(balance, cartAmount, rule) computes:
- Min redeemable balance threshold (
minBalanceToRedeem) - Cart-percentage cap:
maxRedemptionPercentageof 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:
- Upsert wallet record (tenant + user) — creates on first transaction
- Fetch wallet ID
- Append ledger
creditentry with source transaction and rule version - Increment snapshot
points_balancevia SQL atomic update - Insert
reward_eventslinkage (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:
- Fetches user email, tenant name, and updated wallet balance
- Calls
sendPointsEarnedEmail()with points earned, purchase amount, and new balance - 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:
- Lock wallet row (
SELECT ... FOR UPDATE) to prevent concurrent over-redemption - Validate balance >= requested points
- Append ledger
debitentry - 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:
| Outcome | Meaning |
|---|---|
credited | User resolved, reward rule active, points > 0, wallet credited |
stored_anonymous | Transaction stored but no matching SalesArck user found |
duplicate | Idempotency key already exists (safe skip) |
no_rule_no_credit | User resolved but no active rule, or points = 0 (below min spend) |
These outcomes are operationally useful for dashboarding and incident triage.
Financial Integrity Invariants
- Transactions dedup unique key:
(tenant_id, provider, provider_transaction_id) - Append-only wallet ledger semantics — no UPDATE or DELETE
- Positive-only ledger
pointswith directionalentry_type(credit/debit) FOR UPDATErow lock on redemption to prevent race overspend- Reward events linked to wallet + transaction + rule version for auditability
- Idempotency keys with 30-day TTL prevent replay attacks
- 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