Developer implementation guide for the Lula Direct and PDI partnership loyalty integration. 8 structured phases, 206 hours total effort, delivered across 3 weeks of development.
Create foundation: NestJS service, Docker setup, environment config
@Module({ imports: [ConfigModule.forRoot()], controllers: [HealthController], providers: [], }) export class AppModule {} @Controller('loyalty') export class HealthController { @Get('health') health() { return { status: 'ok', timestamp: new Date().toISOString() }; } }
Define schema: StoreLoyaltyConfig, LoyaltySession, LoyaltyTransaction entities
-- Store Loyalty Configuration CREATE TABLE store_loyalty_config ( id SERIAL PRIMARY KEY, store_id UUID NOT NULL UNIQUE, loyalty_provider VARCHAR(50), participant_id VARCHAR(100), site_id VARCHAR(100), enabled BOOLEAN DEFAULT false, credentials JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Active/Historical Loyalty Sessions CREATE TABLE loyalty_session ( id SERIAL PRIMARY KEY, session_id UUID NOT NULL UNIQUE, order_id UUID, store_id UUID NOT NULL, loyalty_id VARCHAR(100), status VARCHAR(50) DEFAULT 'active', rewards JSONB DEFAULT '[]', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
OAuth2 client credentials, PDI API client, circuit breaker resilience
@Injectable() export class PdiOAuthService { private tokenCache: { token: string; expiresAt: number } | null = null; async getToken(): Promise<string> { if (this.tokenCache && this.tokenCache.expiresAt > Date.now()) { return this.tokenCache.token; } const response = await this.requestNewToken(); this.tokenCache = { token: response.access_token, expiresAt: Date.now() + (response.expires_in - 300) * 1000 }; return response.access_token; } }
Session lifecycle: active → pending_finalization → finalized/failed
enum LoyaltySessionStatus {
ACTIVE = 'active',
PENDING_FINALIZATION = 'pending_finalization',
FINALIZED = 'finalized',
FAILED = 'failed'
}
Valid Transitions:
active → pending_finalization (checkout initiated)
pending_finalization → finalized (order confirmed)
pending_finalization → failed (error/cancel)
active → failed (cart cleared/error) New store API routes, admin endpoints, loyalty middleware
router.post('/loyalty/session', async (req, res) => { const session = await loyaltyService.createSession({ storeId: req.store.id, customerId: req.customer?.id, cartId: req.cart?.id }); res.json(session); }); router.post('/loyalty/rewards', async (req, res) => { const rewards = await loyaltyService.getRewards({ storeId: req.store.id, items: req.body.items, sessionId: req.body.sessionId }); res.json(rewards); });
React components: LoyaltyIdentifier, RewardSelector, PointsDisplay, CheckoutLoyalty
export const useLoyalty = () => {
const dispatch = useDispatch();
const { session, rewards, loading } = useSelector(s => s.loyalty);
const initializeSession = async (storeId, customerId) => {
dispatch(setLoading(true));
const session = await loyaltyAPI.createSession(storeId, customerId);
dispatch(setSession(session));
};
const selectReward = (rewardId) => {
dispatch(selectReward(rewardId));
};
return { session, rewards, loading, initializeSession, selectReward };
}; Finalization flow, order completion hook, error handling and rollback
// Pre-checkout validation export const loyaltyPreCheckoutHook = async (cart) => { const session = await loyaltyService.getSession(cart.loyaltySessionId); if (session.status !== 'active') { throw new Error('Loyalty session is not active'); } await loyaltyService.transitionSession(session.id, 'pending_finalization'); return { valid: true }; }; // Order placement finalization subscribeToEvent('order.placed', async (order) => { const finalized = await loyaltyService.finalizeRewards({ sessionId: order.loyaltySessionId, orderId: order.id, items: order.items }); await loyaltyService.transitionSession(order.loyaltySessionId, 'finalized'); });
Unit tests, integration tests, E2E tests, load testing, documentation
describe('PdiOAuthService', () => { it('should cache token and reuse until expiration', async () => { const token1 = await service.getToken(); const token2 = await service.getToken(); expect(token1).toBe(token2); expect(httpService.post).toHaveBeenCalledTimes(1); }); it('should refresh on expiration', async () => { jest.useFakeTimers(); const token1 = await service.getToken(); jest.advanceTimersByTime(3600 * 1000 + 100); const token2 = await service.getToken(); expect(token1).not.toBe(token2); }); });