@higher.archi/boe 1.0.28 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/engines/decay/engine.d.ts +32 -1
  2. package/dist/engines/decay/engine.d.ts.map +1 -1
  3. package/dist/engines/decay/engine.js +69 -0
  4. package/dist/engines/decay/engine.js.map +1 -1
  5. package/dist/engines/decay/index.d.ts +1 -1
  6. package/dist/engines/decay/index.d.ts.map +1 -1
  7. package/dist/engines/decay/index.js.map +1 -1
  8. package/dist/engines/decay/strategy.d.ts +4 -2
  9. package/dist/engines/decay/strategy.d.ts.map +1 -1
  10. package/dist/engines/decay/strategy.js +24 -0
  11. package/dist/engines/decay/strategy.js.map +1 -1
  12. package/dist/engines/decay/types.d.ts +9 -0
  13. package/dist/engines/decay/types.d.ts.map +1 -1
  14. package/dist/engines/loyalty/compiler.d.ts +11 -0
  15. package/dist/engines/loyalty/compiler.d.ts.map +1 -0
  16. package/dist/engines/loyalty/compiler.js +144 -0
  17. package/dist/engines/loyalty/compiler.js.map +1 -0
  18. package/dist/engines/loyalty/engine.d.ts +76 -0
  19. package/dist/engines/loyalty/engine.d.ts.map +1 -0
  20. package/dist/engines/loyalty/engine.js +132 -0
  21. package/dist/engines/loyalty/engine.js.map +1 -0
  22. package/dist/engines/loyalty/index.d.ts +8 -0
  23. package/dist/engines/loyalty/index.d.ts.map +1 -0
  24. package/dist/engines/loyalty/index.js +17 -0
  25. package/dist/engines/loyalty/index.js.map +1 -0
  26. package/dist/engines/loyalty/strategy.d.ts +35 -0
  27. package/dist/engines/loyalty/strategy.d.ts.map +1 -0
  28. package/dist/engines/loyalty/strategy.js +405 -0
  29. package/dist/engines/loyalty/strategy.js.map +1 -0
  30. package/dist/engines/loyalty/types.d.ts +190 -0
  31. package/dist/engines/loyalty/types.d.ts.map +1 -0
  32. package/dist/engines/loyalty/types.js +11 -0
  33. package/dist/engines/loyalty/types.js.map +1 -0
  34. package/dist/index.d.ts +4 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -3
  37. package/dist/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/engines/decay/engine.ts +93 -1
  40. package/src/engines/decay/index.ts +2 -1
  41. package/src/engines/decay/strategy.ts +33 -0
  42. package/src/engines/decay/types.ts +10 -0
  43. package/src/engines/loyalty/compiler.ts +174 -0
  44. package/src/engines/loyalty/engine.ts +174 -0
  45. package/src/engines/loyalty/index.ts +43 -0
  46. package/src/engines/loyalty/strategy.ts +532 -0
  47. package/src/engines/loyalty/types.ts +252 -0
  48. package/src/index.ts +39 -1
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Loyalty Engine Strategy
3
+ *
4
+ * Core execution logic for all loyalty strategies:
5
+ * - earning: compute points from purchase/activity events with category multipliers and promotion stacking
6
+ * - redemption: process point redemption with balance validation
7
+ * - tier-evaluation: evaluate members against tier thresholds with upgrade/downgrade detection
8
+ */
9
+
10
+ import type { IWorkingMemory } from '../../core';
11
+
12
+ import type {
13
+ CompiledLoyaltyRuleSet,
14
+ CompiledEarningRuleSet,
15
+ CompiledRedemptionRuleSet,
16
+ CompiledTierEvaluationRuleSet,
17
+ LoyaltyOptions,
18
+ EarningResult,
19
+ RedemptionResult,
20
+ TierEvaluationResult,
21
+ LoyaltyResult,
22
+ PointTransaction,
23
+ MemberBalance,
24
+ MemberTierResult,
25
+ TierStatus,
26
+ LoyaltyTierDefinition,
27
+ PromotionRule,
28
+ EarningRule
29
+ } from './types';
30
+
31
+ // ========================================
32
+ // Helpers
33
+ // ========================================
34
+
35
+ function round(value: number, decimals: number): number {
36
+ const factor = Math.pow(10, decimals);
37
+ return Math.round(value * factor) / factor;
38
+ }
39
+
40
+ function generateTxId(): string {
41
+ return `tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
42
+ }
43
+
44
+ function resolveDotPath(obj: any, path: string): any {
45
+ const parts = path.split('.');
46
+ let current = obj;
47
+ for (const part of parts) {
48
+ if (current == null) return undefined;
49
+ current = current[part];
50
+ }
51
+ return current;
52
+ }
53
+
54
+ function isPromotionActive(promo: PromotionRule, asOf: Date): boolean {
55
+ if (promo.startDate && new Date(promo.startDate) > asOf) return false;
56
+ if (promo.endDate && new Date(promo.endDate) < asOf) return false;
57
+ return true;
58
+ }
59
+
60
+ function matchesCategory(promo: PromotionRule, category?: string): boolean {
61
+ if (!promo.categories || promo.categories.length === 0) return true;
62
+ if (!category) return false;
63
+ return promo.categories.includes(category);
64
+ }
65
+
66
+ // ========================================
67
+ // Strategy Executor
68
+ // ========================================
69
+
70
+ export class LoyaltyExecutor {
71
+ run(
72
+ ruleSet: CompiledLoyaltyRuleSet,
73
+ wm: IWorkingMemory,
74
+ ledger: Map<string, MemberBalance>,
75
+ options: LoyaltyOptions = {}
76
+ ): LoyaltyResult {
77
+ switch (ruleSet.strategy) {
78
+ case 'earning':
79
+ return this.runEarning(ruleSet, wm, ledger, options);
80
+ case 'redemption':
81
+ return this.runRedemption(ruleSet, wm, ledger, options);
82
+ case 'tier-evaluation':
83
+ return this.runTierEvaluation(ruleSet, wm, ledger, options);
84
+ default:
85
+ throw new Error(`Unknown loyalty strategy: '${(ruleSet as any).strategy}'`);
86
+ }
87
+ }
88
+
89
+ // ========================================
90
+ // Earning Strategy
91
+ // ========================================
92
+
93
+ runEarning(
94
+ ruleSet: CompiledEarningRuleSet,
95
+ wm: IWorkingMemory,
96
+ ledger: Map<string, MemberBalance>,
97
+ options: LoyaltyOptions = {}
98
+ ): EarningResult {
99
+ const startTime = performance.now();
100
+ const asOf = options.asOf ?? new Date();
101
+ const asOfIso = asOf.toISOString();
102
+
103
+ // Get all purchase/activity events from WM
104
+ const events = wm.getAll();
105
+ const allTransactions: PointTransaction[] = [];
106
+ let totalPointsEarned = 0;
107
+ let totalBonusPoints = 0;
108
+ const promotionsApplied = new Set<string>();
109
+ let memberId = '';
110
+
111
+ for (const event of events) {
112
+ const eventMemberId = resolveDotPath(event.data, 'memberId') as string | undefined;
113
+ if (!eventMemberId) continue;
114
+ memberId = eventMemberId;
115
+
116
+ const amount = (resolveDotPath(event.data, 'amount') as number) ?? 0;
117
+ const category = resolveDotPath(event.data, 'category') as string | undefined;
118
+
119
+ // Find matching earning rules
120
+ const matchingRules = this.findMatchingEarningRules(ruleSet.earningRules, category);
121
+ const rule = matchingRules.length > 0 ? matchingRules[0] : null;
122
+
123
+ // Calculate base points
124
+ const baseRate = rule ? rule.baseRate : ruleSet.defaultRate;
125
+ const multiplier = rule?.multiplier ?? 1;
126
+ let basePoints = round(amount * baseRate * multiplier, 0);
127
+ let bonusPoints = rule?.bonusPoints ?? 0;
128
+
129
+ // Apply active promotions
130
+ const activePromos = ruleSet.promotions.filter(
131
+ p => isPromotionActive(p, asOf) && matchesCategory(p, category)
132
+ );
133
+
134
+ let promoMultiplier = 1;
135
+ for (const promo of activePromos) {
136
+ if (promo.multiplier) {
137
+ promoMultiplier *= promo.multiplier;
138
+ promotionsApplied.add(promo.id);
139
+ }
140
+ if (promo.bonusPoints) {
141
+ bonusPoints += promo.bonusPoints;
142
+ promotionsApplied.add(promo.id);
143
+ }
144
+ }
145
+
146
+ // Apply tier multiplier bonus
147
+ const balance = this.getOrCreateBalance(ledger, memberId);
148
+ const tierBonus = this.getTierMultiplierBonus(balance.currentTier, ruleSet.tiers);
149
+ promoMultiplier *= (1 + tierBonus);
150
+
151
+ basePoints = round(basePoints * promoMultiplier, 0);
152
+ const totalForEvent = basePoints + bonusPoints;
153
+
154
+ if (totalForEvent > 0) {
155
+ const tx: PointTransaction = {
156
+ id: generateTxId(),
157
+ memberId,
158
+ type: 'earn',
159
+ points: totalForEvent,
160
+ category,
161
+ ruleId: rule?.id,
162
+ timestamp: asOfIso,
163
+ metadata: { amount, baseRate, multiplier: multiplier * promoMultiplier, bonusPoints }
164
+ };
165
+ allTransactions.push(tx);
166
+ totalPointsEarned += basePoints;
167
+ totalBonusPoints += bonusPoints;
168
+
169
+ // Update ledger
170
+ balance.totalEarned += totalForEvent;
171
+ balance.currentBalance += totalForEvent;
172
+ balance.qualifyingPoints += totalForEvent;
173
+ }
174
+ }
175
+
176
+ // Update tier if tiers are defined
177
+ if (memberId && ruleSet.tiers.length > 0) {
178
+ const balance = this.getOrCreateBalance(ledger, memberId);
179
+ this.evaluateMemberTier(balance, ruleSet.tiers, asOfIso);
180
+ }
181
+
182
+ const finalBalance = memberId ? this.getOrCreateBalance(ledger, memberId) : { currentBalance: 0 };
183
+ const executionTimeMs = round((performance.now() - startTime) * 100, 0) / 100;
184
+
185
+ return {
186
+ memberId,
187
+ transactions: allTransactions,
188
+ pointsEarned: totalPointsEarned,
189
+ bonusPointsEarned: totalBonusPoints,
190
+ promotionsApplied: Array.from(promotionsApplied),
191
+ newBalance: finalBalance.currentBalance,
192
+ executionTimeMs
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Process a single earning event (for streaming ingest).
198
+ */
199
+ earnSingle(
200
+ eventData: Record<string, any>,
201
+ ruleSet: CompiledEarningRuleSet,
202
+ ledger: Map<string, MemberBalance>,
203
+ asOf: Date = new Date()
204
+ ): {
205
+ memberId: string;
206
+ pointsEarned: number;
207
+ bonusPointsEarned: number;
208
+ promotionsApplied: string[];
209
+ newBalance: number;
210
+ transactions: PointTransaction[];
211
+ } {
212
+ const asOfIso = asOf.toISOString();
213
+ const memberId = eventData.memberId as string;
214
+ if (!memberId) {
215
+ return { memberId: '', pointsEarned: 0, bonusPointsEarned: 0, promotionsApplied: [], newBalance: 0, transactions: [] };
216
+ }
217
+
218
+ const amount = (eventData.amount as number) ?? 0;
219
+ const category = eventData.category as string | undefined;
220
+
221
+ const matchingRules = this.findMatchingEarningRules(ruleSet.earningRules, category);
222
+ const rule = matchingRules.length > 0 ? matchingRules[0] : null;
223
+
224
+ const baseRate = rule ? rule.baseRate : ruleSet.defaultRate;
225
+ const multiplier = rule?.multiplier ?? 1;
226
+ let basePoints = round(amount * baseRate * multiplier, 0);
227
+ let bonusPoints = rule?.bonusPoints ?? 0;
228
+
229
+ const promotionsApplied: string[] = [];
230
+ const activePromos = ruleSet.promotions.filter(
231
+ p => isPromotionActive(p, asOf) && matchesCategory(p, category)
232
+ );
233
+
234
+ let promoMultiplier = 1;
235
+ for (const promo of activePromos) {
236
+ if (promo.multiplier) {
237
+ promoMultiplier *= promo.multiplier;
238
+ promotionsApplied.push(promo.id);
239
+ }
240
+ if (promo.bonusPoints) {
241
+ bonusPoints += promo.bonusPoints;
242
+ promotionsApplied.push(promo.id);
243
+ }
244
+ }
245
+
246
+ // Apply tier multiplier bonus
247
+ const balance = this.getOrCreateBalance(ledger, memberId);
248
+ const tierBonus = this.getTierMultiplierBonus(balance.currentTier, ruleSet.tiers);
249
+ promoMultiplier *= (1 + tierBonus);
250
+
251
+ basePoints = round(basePoints * promoMultiplier, 0);
252
+ const totalForEvent = basePoints + bonusPoints;
253
+
254
+ const transactions: PointTransaction[] = [];
255
+
256
+ if (totalForEvent > 0) {
257
+ const tx: PointTransaction = {
258
+ id: generateTxId(),
259
+ memberId,
260
+ type: 'earn',
261
+ points: totalForEvent,
262
+ category,
263
+ ruleId: rule?.id,
264
+ timestamp: asOfIso,
265
+ metadata: { amount, baseRate, multiplier: multiplier * promoMultiplier, bonusPoints }
266
+ };
267
+ transactions.push(tx);
268
+
269
+ balance.totalEarned += totalForEvent;
270
+ balance.currentBalance += totalForEvent;
271
+ balance.qualifyingPoints += totalForEvent;
272
+ }
273
+
274
+ // Update tier
275
+ if (ruleSet.tiers.length > 0) {
276
+ this.evaluateMemberTier(balance, ruleSet.tiers, asOfIso);
277
+ }
278
+
279
+ return {
280
+ memberId,
281
+ pointsEarned: basePoints,
282
+ bonusPointsEarned: bonusPoints,
283
+ promotionsApplied,
284
+ newBalance: balance.currentBalance,
285
+ transactions
286
+ };
287
+ }
288
+
289
+ // ========================================
290
+ // Redemption Strategy
291
+ // ========================================
292
+
293
+ private runRedemption(
294
+ ruleSet: CompiledRedemptionRuleSet,
295
+ wm: IWorkingMemory,
296
+ ledger: Map<string, MemberBalance>,
297
+ options: LoyaltyOptions = {}
298
+ ): RedemptionResult {
299
+ const startTime = performance.now();
300
+ const asOf = options.asOf ?? new Date();
301
+ const asOfIso = asOf.toISOString();
302
+
303
+ // Get redemption request from WM
304
+ const requests = wm.getAll();
305
+ if (requests.length === 0) {
306
+ throw new Error('No redemption request found in working memory');
307
+ }
308
+
309
+ const request = requests[0];
310
+ const memberId = resolveDotPath(request.data, 'memberId') as string;
311
+ const optionId = resolveDotPath(request.data, 'optionId') as string;
312
+
313
+ if (!memberId) throw new Error('Redemption request requires memberId');
314
+ if (!optionId) throw new Error('Redemption request requires optionId');
315
+
316
+ // Find matching redemption option
317
+ const option = ruleSet.redemptionOptions.find(o => o.id === optionId);
318
+ if (!option) {
319
+ throw new Error(`Unknown redemption option: '${optionId}'`);
320
+ }
321
+
322
+ // Validate sufficient balance
323
+ const balance = this.getOrCreateBalance(ledger, memberId);
324
+ if (balance.currentBalance < option.pointsRequired) {
325
+ throw new Error(
326
+ `Insufficient balance: ${balance.currentBalance} points available, ` +
327
+ `${option.pointsRequired} required for '${option.id}'`
328
+ );
329
+ }
330
+
331
+ // Build redemption transaction
332
+ const tx: PointTransaction = {
333
+ id: generateTxId(),
334
+ memberId,
335
+ type: 'redeem',
336
+ points: -option.pointsRequired,
337
+ ruleId: option.id,
338
+ timestamp: asOfIso,
339
+ metadata: { optionId: option.id, value: option.value, unit: option.unit }
340
+ };
341
+
342
+ // Update balance
343
+ balance.totalRedeemed += option.pointsRequired;
344
+ balance.currentBalance -= option.pointsRequired;
345
+
346
+ const executionTimeMs = round((performance.now() - startTime) * 100, 0) / 100;
347
+
348
+ return {
349
+ memberId,
350
+ redemption: tx,
351
+ pointsRedeemed: option.pointsRequired,
352
+ redemptionValue: option.value,
353
+ remainingBalance: balance.currentBalance,
354
+ executionTimeMs
355
+ };
356
+ }
357
+
358
+ // ========================================
359
+ // Tier Evaluation Strategy
360
+ // ========================================
361
+
362
+ private runTierEvaluation(
363
+ ruleSet: CompiledTierEvaluationRuleSet,
364
+ wm: IWorkingMemory,
365
+ ledger: Map<string, MemberBalance>,
366
+ _options: LoyaltyOptions = {}
367
+ ): TierEvaluationResult {
368
+ const startTime = performance.now();
369
+
370
+ // Get all members from WM
371
+ const memberFacts = wm.getAll();
372
+ const members: MemberTierResult[] = [];
373
+ const tierDistribution: Record<string, number> = {};
374
+
375
+ // Initialize tier distribution
376
+ for (const tier of ruleSet.tiers) {
377
+ tierDistribution[tier.id] = 0;
378
+ }
379
+ tierDistribution['none'] = 0;
380
+
381
+ for (const fact of memberFacts) {
382
+ const memberId = resolveDotPath(fact.data, 'memberId') as string;
383
+ if (!memberId) continue;
384
+
385
+ const balance = this.getOrCreateBalance(ledger, memberId);
386
+
387
+ // If qualifying points are provided in the fact, use them
388
+ const factQualifying = resolveDotPath(fact.data, 'qualifyingPoints') as number | undefined;
389
+ if (factQualifying !== undefined) {
390
+ balance.qualifyingPoints = factQualifying;
391
+ }
392
+
393
+ const previousTier = balance.currentTier;
394
+
395
+ // Find the highest qualifying tier
396
+ let matchedTier: LoyaltyTierDefinition | undefined;
397
+ for (let i = ruleSet.tiers.length - 1; i >= 0; i--) {
398
+ if (balance.qualifyingPoints >= ruleSet.tiers[i].qualifyingThreshold) {
399
+ matchedTier = ruleSet.tiers[i];
400
+ break;
401
+ }
402
+ }
403
+
404
+ const currentTierId = matchedTier?.id ?? 'none';
405
+ const status = this.resolveTierStatus(balance, matchedTier, previousTier, ruleSet.tiers);
406
+
407
+ // Find next tier
408
+ let nextTier: LoyaltyTierDefinition | undefined;
409
+ if (matchedTier) {
410
+ const currentIdx = ruleSet.tiers.findIndex(t => t.id === matchedTier!.id);
411
+ if (currentIdx < ruleSet.tiers.length - 1) {
412
+ nextTier = ruleSet.tiers[currentIdx + 1];
413
+ }
414
+ } else if (ruleSet.tiers.length > 0) {
415
+ nextTier = ruleSet.tiers[0];
416
+ }
417
+
418
+ const result: MemberTierResult = {
419
+ memberId,
420
+ currentTier: currentTierId,
421
+ previousTier: previousTier !== currentTierId ? previousTier : undefined,
422
+ qualifyingPoints: balance.qualifyingPoints,
423
+ nextTierThreshold: nextTier?.qualifyingThreshold,
424
+ pointsToNextTier: nextTier
425
+ ? Math.max(0, nextTier.qualifyingThreshold - balance.qualifyingPoints)
426
+ : undefined,
427
+ status,
428
+ benefits: matchedTier?.benefits ?? []
429
+ };
430
+
431
+ members.push(result);
432
+ tierDistribution[currentTierId] = (tierDistribution[currentTierId] ?? 0) + 1;
433
+
434
+ // Update ledger with new tier
435
+ balance.currentTier = currentTierId;
436
+ }
437
+
438
+ const executionTimeMs = round((performance.now() - startTime) * 100, 0) / 100;
439
+
440
+ return {
441
+ members,
442
+ tierDistribution,
443
+ executionTimeMs
444
+ };
445
+ }
446
+
447
+ // ========================================
448
+ // Shared Helpers
449
+ // ========================================
450
+
451
+ private findMatchingEarningRules(rules: EarningRule[], category?: string): EarningRule[] {
452
+ if (!category) {
453
+ return rules.filter(r => !r.category);
454
+ }
455
+ const categoryMatch = rules.filter(r => r.category === category);
456
+ if (categoryMatch.length > 0) return categoryMatch;
457
+ return rules.filter(r => !r.category);
458
+ }
459
+
460
+ getOrCreateBalance(ledger: Map<string, MemberBalance>, memberId: string): MemberBalance {
461
+ let balance = ledger.get(memberId);
462
+ if (!balance) {
463
+ balance = {
464
+ memberId,
465
+ totalEarned: 0,
466
+ totalRedeemed: 0,
467
+ totalExpired: 0,
468
+ currentBalance: 0,
469
+ qualifyingPoints: 0
470
+ };
471
+ ledger.set(memberId, balance);
472
+ }
473
+ return balance;
474
+ }
475
+
476
+ private evaluateMemberTier(
477
+ balance: MemberBalance,
478
+ tiers: LoyaltyTierDefinition[],
479
+ asOfIso: string
480
+ ): void {
481
+ let matchedTier: LoyaltyTierDefinition | undefined;
482
+ for (let i = tiers.length - 1; i >= 0; i--) {
483
+ if (balance.qualifyingPoints >= tiers[i].qualifyingThreshold) {
484
+ matchedTier = tiers[i];
485
+ break;
486
+ }
487
+ }
488
+
489
+ const newTierId = matchedTier?.id;
490
+ if (newTierId && newTierId !== balance.currentTier) {
491
+ balance.currentTier = newTierId;
492
+ balance.tierQualifiedAt = asOfIso;
493
+ }
494
+ }
495
+
496
+ private getTierMultiplierBonus(
497
+ currentTierId: string | undefined,
498
+ tiers: LoyaltyTierDefinition[]
499
+ ): number {
500
+ if (!currentTierId) return 0;
501
+ const tier = tiers.find(t => t.id === currentTierId);
502
+ return tier?.multiplierBonus ?? 0;
503
+ }
504
+
505
+ private resolveTierStatus(
506
+ balance: MemberBalance,
507
+ matchedTier: LoyaltyTierDefinition | undefined,
508
+ previousTier: string | undefined,
509
+ allTiers: LoyaltyTierDefinition[]
510
+ ): TierStatus {
511
+ if (!matchedTier) return 'at-risk';
512
+
513
+ // Check if this is a downgrade
514
+ if (previousTier) {
515
+ const prevIdx = allTiers.findIndex(t => t.id === previousTier);
516
+ const currIdx = allTiers.findIndex(t => t.id === matchedTier.id);
517
+ if (prevIdx > currIdx) return 'downgrade-pending';
518
+ }
519
+
520
+ // Check if near retention threshold
521
+ if (matchedTier.retentionThreshold !== undefined) {
522
+ if (balance.qualifyingPoints < matchedTier.retentionThreshold) {
523
+ return 'at-risk';
524
+ }
525
+ }
526
+
527
+ return 'qualified';
528
+ }
529
+ }
530
+
531
+ /** Singleton instance */
532
+ export const loyaltyStrategy = new LoyaltyExecutor();