Skip to main content

Loyalty Points Accrual via EventBridge

This guide explains the complete technical flow for how loyalty points are calculated and credited when customers scan products, make purchases, or complete other campaign actions.

Architecture Overview

The loyalty points system uses AWS EventBridge for asynchronous, event-driven processing:

graph TB
A[Customer Action] --> B{Event Type}
B -->|Product Scan| C[scan-auth Event]
B -->|Order Placed| D[order-complete Event]
B -->|Profile Update| E[log-user-profile-update Event]

C --> F[EventBridge]
D --> F
E --> F

F --> G[Lambda/API Handler]
G --> H[Validation]
H --> I[Points Calculation]
I --> J[Transaction Creation]
J --> K[Balance Update]
K --> L[Campaign Progress]
L --> M[Tier Check]
M --> N[Wallet Update]
N --> O[Notification]

style F fill:#FF9900
style G fill:#4CAF50
style O fill:#2196F3

Key Components:

  • EventBridge - Central event bus for routing events
  • Lambda Functions - Process events asynchronously
  • Admin Backend API - Handles business logic
  • PostgreSQL - Stores transactions and balances
  • Redis - Caches user data and brand configs

Complete Flow Timeline

End-to-End Timing: 600ms - 1.8 seconds

gantt
title Loyalty Points Accrual via EventBridge (Complete Flow)
dateFormat HH:mm:ss.SSS
axisFormat %S.%Ls

section Event Trigger
Product Scan Completed :milestone, m1, 00:00:00.000, 0ms
Publish "scan-auth" Event :event1, 00:00:00.000, 50ms
EventBridge Receives Event :event2, 00:00:00.050, 30ms
Route to Lambda/Endpoint :event3, 00:00:00.080, 40ms
Lambda Cold Start (if needed) :event4, 00:00:00.120, 150ms
Event Received by Handler :milestone, m2, 00:00:00.270, 0ms

section User & Brand Validation
Parse Event Payload :val1, 00:00:00.270, 10ms
Query User by ID :val2, 00:00:00.280, 30ms
Fetch User's Loyalty Status :val3, 00:00:00.310, 25ms
Query Brand & Brand Level :val4, 00:00:00.335, 35ms
Verify Loyalty Active :val5, 00:00:00.370, 10ms
Validation Complete :milestone, m3, 00:00:00.380, 0ms

section Points Calculation
Get scan_reward_type (fixed/%) :calc1, 00:00:00.380, 10ms
Get scan_reward_value :calc2, 00:00:00.390, 5ms
Fetch Product Retail Price :calc3, 00:00:00.395, 20ms
Calculate Points (value * price) :calc4, 00:00:00.415, 15ms
Apply Product Override (if exists) :calc5, 00:00:00.430, 10ms
Points Calculated :milestone, m4, 00:00:00.440, 0ms

section Duplicate Check
Query loyalty_transaction Table :dup1, 00:00:00.440, 35ms
Check authCode in reason_id :dup2, 00:00:00.475, 15ms
Verify Not Previously Claimed :dup3, 00:00:00.490, 10ms
Duplicate Check Passed :milestone, m5, 00:00:00.500, 0ms

section WPLoyalty Integration (Optional)
Check for WPLoyalty Config :wp1, 00:00:00.500, 15ms
Build WPLoyalty API Request :wp2, 00:00:00.515, 20ms
Call WordPress API :wp3, 00:00:00.535, 100ms
Receive External Points Confirm :wp4, 00:00:00.635, 15ms
WPLoyalty Sync Complete :milestone, m6, 00:00:00.650, 0ms

section Transaction Creation
Begin Database Transaction :trans1, 00:00:00.650, 5ms
Insert loyalty_transaction Record :trans2, 00:00:00.655, 80ms
Set points_earned, reason, note :trans3, 00:00:00.735, 20ms
Set reason_id = authCode :trans4, 00:00:00.755, 10ms
Link to user_point_id :trans5, 00:00:00.765, 15ms
Commit Transaction :trans6, 00:00:00.780, 20ms
Transaction Created :milestone, m7, 00:00:00.800, 0ms

section Point Balance Update
Query user_points Record :bal1, 00:00:00.800, 25ms
Increment points by earned :bal2, 00:00:00.825, 30ms
Increment points_earned (lifetime) :bal3, 00:00:00.855, 20ms
Update Database :bal4, 00:00:00.875, 25ms
Balance Updated :milestone, m8, 00:00:00.900, 0ms

section Campaign Progress Check
Query Active Loyalty Campaigns :camp1, 00:00:00.900, 60ms
Filter by "authenticator_scan" Type :camp2, 00:00:00.960, 20ms
Get User's Campaign Progress :camp3, 00:00:00.980, 50ms
Calculate New Progress :camp4, 00:00:01.030, 30ms
Check Milestone Achievement :camp5, 00:00:01.060, 40ms
Update user_loyalty_campaign :camp6, 00:00:01.100, 80ms
Check Auto-Claim Rewards :camp7, 00:00:01.180, 50ms
Campaign Progress Updated :milestone, m9, 00:00:01.230, 0ms

section Level/Tier Progression
Calculate Total Lifetime Points :level1, 00:00:01.230, 20ms
Query Brand Level Thresholds :level2, 00:00:01.250, 35ms
Check for Tier Upgrade :level3, 00:00:01.285, 40ms
Update user.current_level (if needed) :level4, 00:00:01.325, 45ms
Level Check Complete :milestone, m10, 00:00:01.370, 0ms

section Wallet Pass Update (Async)
Generate Apple Wallet Payload :wallet1, 00:00:01.370, 60ms
Update via Apple Push Notification :wallet2, 00:00:01.430, 150ms
Generate Google Wallet Payload :wallet3, 00:00:01.580, 40ms
Update Google Wallet Pass :wallet4, 00:00:01.620, 100ms
Wallet Passes Updated :milestone, m11, 00:00:01.720, 0ms

section Notification Trigger (Async)
Build Notification Payload :notif1, 00:00:01.720, 20ms
Publish to EventBridge :notif2, 00:00:01.740, 40ms
Template: NOTIFY_CAMPAIGN_SUCCESS :notif3, 00:00:01.780, 15ms
Include Points Earned :notif4, 00:00:01.795, 10ms
Send Push/Email/SMS :notif5, 00:00:01.805, 60ms
User Notified :milestone, m12, 00:00:01.865, 0ms

Performance Metrics

PhaseDurationOptimization Notes
Event Publishing50msAWS SDK PutEvents call
EventBridge Routing70msRule evaluation + Lambda invocation
Lambda Cold Start0-150msCached after first run - use provisioned concurrency for critical paths
User/Brand Validation110msCan be optimized - use Redis caching for brand_level data
Points Calculation60msPure computation, minimal overhead
Duplicate Check60msDatabase query with index on reason_id
WPLoyalty API (optional)150msExternal API call - can be skipped if not configured
Transaction Creation150msDatabase transaction with ACID guarantees
Balance Update100msAtomic increment operation
Campaign Progress330msMost expensive - queries multiple campaigns
Level Update140msTier threshold checks
Wallet Update (async)350msAPN/Google Wallet API calls - non-blocking
Notification (async)145msMulti-channel delivery - non-blocking
Total (avg)~1 secondAcceptable for async operation
Performance Optimization

The total time of ~1 second is acceptable because this is an asynchronous operation. The customer receives immediate feedback from the scan, and points appear within 1-2 seconds.

For further optimization:

  • ✅ Enable provisioned concurrency for Lambda functions (eliminates cold starts)
  • ✅ Cache brand_level data in Redis (reduces DB queries by 80%)
  • ✅ Use database connection pooling (reduces connection overhead)
  • ✅ Batch campaign progress updates (update every 5 scans instead of every scan)

Event Structure

EventBridge Event Payload

When a customer scans a product, this event is published:

{
"version": "0",
"id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
"detail-type": "scan-auth",
"source": "batch.customer-app",
"account": "123456789012",
"time": "2025-11-04T10:30:00Z",
"region": "us-west-2",
"resources": [],
"detail": {
"arguments": {
"brand_id": 123,
"user_id": 456,
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
},
"payload": {
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"title": "Blue Dream 3.5g",
"retailPrice": 35.00,
"redeem_point_value": 100
},
"location": {
"latitude": 34.0522,
"longitude": -118.2437,
"city": "Los Angeles",
"region": "California"
},
"timestamp": "2025-11-04T10:30:00.000Z"
}
}
}

Event Types Matrix

Event TypeDetailTypeTriggerProcessing Time
scan-auth"scan-auth"Product QR code scan600ms-1.8s
order-complete"order-complete"Order placed/completed500ms-1.2s
log-user-profile-update"log-user-profile-update"Profile change80-200ms
log-user-login"log-user-login"User login100-250ms

API Integration

Endpoint: Check and Update Campaign Progress

POST /api/v1/brand/loyalty/campaign/event

Authentication: Required (JWT Bearer token + API key)

Rate Limit: 100 requests/minute per user

Request Headers

Authorization: Bearer {JWT_TOKEN}
api-key: {HUB_API_KEY}
Content-Type: application/json
origin: {BRAND_HOSTNAME}

Request Body

{
"user_id": 456,
"brand_id": 123,
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"title": "Blue Dream 3.5g",
"retailPrice": 35.00,
"redeem_point_value": 100
}
}

Response (200 OK)

{
"success": true,
"data": {
"points_earned": 35,
"new_balance": 1235,
"transaction_id": "txn_abc123def456",
"campaign_progress": {
"campaign_id": 50,
"campaign_name": "Summer Scan Challenge",
"progress": 7,
"target": 10,
"milestone_achieved": false,
"next_reward": "10% off discount"
},
"level_updated": false,
"current_level": "Gold",
"next_level_threshold": 2000
},
"message": "Points credited successfully"
}

Error Responses

400 Bad Request - Invalid auth code

{
"success": false,
"error": "AUTH_CODE_INVALID",
"message": "The auth code 'ABC123XYZ' is not valid or has expired"
}

409 Conflict - Duplicate scan

{
"success": false,
"error": "POINTS_ALREADY_CLAIMED",
"message": "Points have already been claimed for this auth code"
}

404 Not Found - User not found

{
"success": false,
"error": "USER_NOT_FOUND",
"message": "User with ID 456 does not exist"
}

cURL Example

curl -X POST https://admin-staging-api.batchsys.com/api/v1/brand/loyalty/campaign/event \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQ1NiwiYnJhbmRJZCI6MTIzfQ..." \
-H "api-key: hub_1234567890abcdef1234567890abcdef" \
-H "Content-Type: application/json" \
-H "origin: https://yourbrand.com" \
-d '{
"user_id": 456,
"brand_id": 123,
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"retailPrice": 35.00
}
}'

JavaScript SDK Example

import { ADMIN } from './services/APISDK';

const admin = new ADMIN();

const response = await admin.checkAndUpdateProgressForCampaign({
user_id: 456,
brand_id: 123,
type: 'authenticator_scan',
product: {
productId: 789,
authCode: 'ABC123XYZ',
retailPrice: 35.00
}
});

if (response.success) {
console.log(`User earned ${response.data.points_earned} points!`);
console.log(`New balance: ${response.data.new_balance}`);

if (response.data.campaign_progress.milestone_achieved) {
console.log('🎉 Milestone achieved!');
}
}

Python Example

import requests

url = "https://admin-staging-api.batchsys.com/api/v1/brand/loyalty/campaign/event"
headers = {
"Authorization": f"Bearer {jwt_token}",
"api-key": api_key,
"Content-Type": "application/json"
}
payload = {
"user_id": 456,
"brand_id": 123,
"type": "authenticator_scan",
"product": {
"productId": 789,
"authCode": "ABC123XYZ",
"retailPrice": 35.00
}
}

response = requests.post(url, json=payload, headers=headers)
data = response.json()

if data["success"]:
print(f"Points earned: {data['data']['points_earned']}")
print(f"New balance: {data['data']['new_balance']}")

Points Calculation Logic

Calculation Methods

1. Fixed Points Reward

Configuration:

const brandLevel = {
scan_reward_type: "points",
scan_reward_value: 10 // Fixed 10 points per scan
}

Calculation:

const pointsEarned = brandLevel.scan_reward_value;
// Result: 10 points

Use case: Simple, predictable rewards. Every scan = 10 points.


2. Percentage-based Reward

Configuration:

const brandLevel = {
scan_reward_type: "percentage",
scan_reward_value: 10 // 10% of retail price
}

const product = {
retailPrice: 35.00
}

Calculation:

const pointsEarned = Math.round(
(product.retailPrice * brandLevel.scan_reward_value) / 100
);
// Result: Math.round((35.00 * 10) / 100) = 4 points

Use case: Reward proportional to product value. Higher-priced products = more points.


3. Product-specific Override

Configuration:

const product = {
retailPrice: 35.00,
redeem_point_value: 100 // Override: always 100 points
}

Calculation:

const pointsEarned = product.redeem_point_value || calculateFromBrandLevel();
// Result: 100 points (override takes precedence)

Use case: Promotional products, premium items, limited editions.


Code Implementation

Service: /Batch-Admin-Backend/app/v1/service/userPoint_service.ts

function calculatePointsEarned(
brandLevel: BrandLevel,
product: Product
): number {
// Product override takes precedence
if (product.redeem_point_value) {
return product.redeem_point_value;
}

// Fixed points
if (brandLevel.scan_reward_type === 'points') {
return brandLevel.scan_reward_value;
}

// Percentage-based
if (brandLevel.scan_reward_type === 'percentage') {
return Math.round(
(product.retailPrice * brandLevel.scan_reward_value) / 100
);
}

// Default fallback
return 1;
}

Duplicate Prevention

Idempotency Strategy

The system prevents duplicate point accrual using the reason_id field in the loyalty_transaction table:

// Check for existing transaction
const existingTransaction = await loyaltyTransaction.findOne({
where: {
user_point_id: userPoint.id,
reason: 'earned',
reason_id: authCode // Unique auth code per product
}
});

if (existingTransaction) {
throw new Error('POINTS_ALREADY_CLAIMED');
}

Key Implementation Details:

  • ✅ Each auth code is unique per product/batch
  • reason_id is indexed for fast lookups
  • ✅ Database constraint ensures atomicity
  • ✅ Operation is idempotent - safe to retry

Database Schema

CREATE TABLE loyalty_transaction (
loyalty_transaction_id SERIAL PRIMARY KEY,
user_point_id INTEGER NOT NULL REFERENCES user_points(user_point_id),
points_earned DECIMAL(10,2),
points_redeemed DECIMAL(10,2),
reason VARCHAR(50), -- 'earned', 'redeemed', 'expired'
reason_id VARCHAR(255), -- authCode for deduplication
note TEXT,
createdAt TIMESTAMP DEFAULT NOW(),
updatedAt TIMESTAMP DEFAULT NOW(),

UNIQUE(user_point_id, reason_id) -- Prevents duplicates
);

CREATE INDEX idx_reason_id ON loyalty_transaction(reason_id);

Database Operations

Tables Involved

1. loyalty_transaction

Insert new transaction:

INSERT INTO loyalty_transaction (
user_point_id,
points_earned,
reason,
reason_id,
note,
createdAt,
updatedAt
) VALUES (
123, -- user_point_id
35, -- points_earned
'earned', -- reason
'ABC123XYZ', -- authCode for deduplication
'Scanned a Blue Dream 3.5g', -- customer-facing note
NOW(),
NOW()
) RETURNING loyalty_transaction_id;

Query transactions:

SELECT * FROM loyalty_transaction
WHERE user_point_id = 123
AND createdAt >= NOW() - INTERVAL '30 days'
ORDER BY createdAt DESC
LIMIT 50;

2. user_points

Update point balance:

UPDATE user_points
SET
points = points + 35, -- Current balance
points_earned = points_earned + 35, -- Lifetime earned
updatedAt = NOW()
WHERE user_point_id = 123
RETURNING points, points_earned;

Query user balance:

SELECT
user_point_id,
points,
points_earned,
points_redeemed,
current_level
FROM user_points
WHERE user_id = 456;

3. user_loyalty_campaign

Update campaign progress:

INSERT INTO user_loyalty_campaign (
user_id,
campaign_id,
progress,
milestones_achieved,
createdAt,
updatedAt
) VALUES (
456,
50,
1, -- First scan
'{}', -- Empty milestone array
NOW(),
NOW()
)
ON CONFLICT (user_id, campaign_id)
DO UPDATE SET
progress = user_loyalty_campaign.progress + 1,
updatedAt = NOW()
RETURNING progress;

Testing

Unit Test Example

File: userPoint_service.test.ts

import { checkAndUpdateProgressForCampaign } from './userPoint_service';
import { createMockApp, createMockUser, createMockProduct } from '../test/helpers';

describe('Loyalty Points Accrual', () => {
let app, user, brand, product;

beforeEach(async () => {
app = createMockApp();
user = await createMockUser({ user_id: 456, brand_id: 123 });
brand = await createMockBrand({ brand_id: 123 });
product = await createMockProduct({
productId: 789,
retailPrice: 35.00,
authCode: 'TEST123'
});
});

it('should credit points for valid product scan', async () => {
const payload = {
type: 'authenticator_scan',
product: {
productId: 789,
authCode: 'TEST123',
retailPrice: 35.00
}
};

const result = await checkAndUpdateProgressForCampaign(
app,
user.user_id,
brand.brand_id,
payload
);

expect(result.success).toBe(true);
expect(result.data.points_earned).toBeGreaterThan(0);
expect(result.data.transaction_id).toBeDefined();
expect(result.data.new_balance).toBe(user.points + result.data.points_earned);
});

it('should prevent duplicate point accrual', async () => {
const payload = {
type: 'authenticator_scan',
product: { authCode: 'TEST123' }
};

// First scan - should succeed
await checkAndUpdateProgressForCampaign(app, 456, 123, payload);

// Second scan - should fail
await expect(
checkAndUpdateProgressForCampaign(app, 456, 123, payload)
).rejects.toThrow('POINTS_ALREADY_CLAIMED');
});

it('should update campaign progress', async () => {
const campaign = await createMockCampaign({
type: 'authenticator_scan',
target_value: 10
});

const payload = {
type: 'authenticator_scan',
product: { authCode: 'TEST456' }
};

const result = await checkAndUpdateProgressForCampaign(app, 456, 123, payload);

expect(result.data.campaign_progress).toBeDefined();
expect(result.data.campaign_progress.progress).toBe(1);
expect(result.data.campaign_progress.target).toBe(10);
});

it('should trigger tier upgrade when threshold reached', async () => {
// Set user close to tier upgrade
await user.update({ points: 1995 }); // 5 points away from Gold tier (2000)

const payload = {
type: 'authenticator_scan',
product: { authCode: 'TEST789', retailPrice: 50.00 }
};

// Assuming 10% reward = 5 points
const result = await checkAndUpdateProgressForCampaign(app, 456, 123, payload);

expect(result.data.level_updated).toBe(true);
expect(result.data.current_level).toBe('Gold');
});
});

Integration Test Example

describe('Loyalty Points Integration', () => {
it('should handle end-to-end scan flow', async () => {
// 1. Customer scans product
const scanResponse = await request(app)
.post('/api/v1/verify/product/ABC123XYZ')
.set('Authorization', `Bearer ${userToken}`)
.send({ latitude: 34.0522, longitude: -118.2437 });

expect(scanResponse.status).toBe(200);

// 2. Points event triggered
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for async processing

// 3. Verify points credited
const pointsResponse = await request(app)
.get('/api/v1/customer/loyalty/points')
.set('Authorization', `Bearer ${userToken}`);

expect(pointsResponse.body.data.points).toBeGreaterThan(initialPoints);

// 4. Verify transaction created
const transactionsResponse = await request(app)
.get('/api/v1/customer/loyalty/transactions')
.set('Authorization', `Bearer ${userToken}`);

const latestTransaction = transactionsResponse.body.data[0];
expect(latestTransaction.reason).toBe('earned');
expect(latestTransaction.reason_id).toBe('ABC123XYZ');
});
});

Monitoring & Debugging

CloudWatch Logs

Log Group: /aws/lambda/loyalty-points-processor

Key Log Events:

{
"timestamp": "2025-11-04T10:30:01.500Z",
"level": "INFO",
"message": "Points accrual started",
"user_id": 456,
"brand_id": 123,
"auth_code": "ABC123XYZ",
"event_id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
}

{
"timestamp": "2025-11-04T10:30:02.100Z",
"level": "INFO",
"message": "Points credited successfully",
"user_id": 456,
"points_earned": 35,
"new_balance": 1235,
"duration_ms": 600
}

Metrics

CloudWatch Metrics:

  • LoyaltyPointsAccrued - Count of successful point accruals
  • LoyaltyPointsFailure - Count of failures
  • LoyaltyProcessingDuration - Average processing time
  • DuplicateScanAttempts - Count of duplicate scan attempts

Custom Dashboard:

┌─────────────────────────────────────────┐
│ Loyalty Points - Last 24 Hours │
├─────────────────────────────────────────┤
│ Total Accruals: 15,432 │
│ Avg Processing Time: 847ms │
│ Success Rate: 98.5% │
│ Duplicate Attempts: 234 (1.5%) │
└─────────────────────────────────────────┘

For Brand Managers

For Developers


Troubleshooting

Points Not Credited

Symptoms: Customer scanned product but points didn't appear

Diagnosis:

  1. Check CloudWatch Logs for errors
  2. Verify EventBridge event was published: aws events list-rule-names-by-target
  3. Check Lambda function invocation: aws lambda get-function-concurrency
  4. Query loyalty_transaction table for duplicate entries

Common Causes:

  • Duplicate scan (auth code already used)
  • User not enrolled in loyalty program
  • Brand loyalty settings disabled
  • EventBridge rule misconfigured

Slow Processing

Symptoms: Points take >3 seconds to appear

Diagnosis:

  1. Check Lambda cold start metrics
  2. Review database connection pool utilization
  3. Analyze campaign progress query performance

Solutions:

  • Enable provisioned concurrency for Lambda
  • Optimize database indexes
  • Cache brand_level configuration in Redis
  • Batch campaign progress updates

Next Steps

Now that you understand the points accrual flow, explore:

  1. Batch Range Creation - How auth codes are generated
  2. Product Verification Flow - How QR code scanning works
  3. Order Processing Flow - Points from purchases
  4. EventBridge Setup Guide - Configure event processing