@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.
- package/dist/engines/decay/engine.d.ts +32 -1
- package/dist/engines/decay/engine.d.ts.map +1 -1
- package/dist/engines/decay/engine.js +69 -0
- package/dist/engines/decay/engine.js.map +1 -1
- package/dist/engines/decay/index.d.ts +1 -1
- package/dist/engines/decay/index.d.ts.map +1 -1
- package/dist/engines/decay/index.js.map +1 -1
- package/dist/engines/decay/strategy.d.ts +4 -2
- package/dist/engines/decay/strategy.d.ts.map +1 -1
- package/dist/engines/decay/strategy.js +24 -0
- package/dist/engines/decay/strategy.js.map +1 -1
- package/dist/engines/decay/types.d.ts +9 -0
- package/dist/engines/decay/types.d.ts.map +1 -1
- package/dist/engines/loyalty/compiler.d.ts +11 -0
- package/dist/engines/loyalty/compiler.d.ts.map +1 -0
- package/dist/engines/loyalty/compiler.js +144 -0
- package/dist/engines/loyalty/compiler.js.map +1 -0
- package/dist/engines/loyalty/engine.d.ts +76 -0
- package/dist/engines/loyalty/engine.d.ts.map +1 -0
- package/dist/engines/loyalty/engine.js +132 -0
- package/dist/engines/loyalty/engine.js.map +1 -0
- package/dist/engines/loyalty/index.d.ts +8 -0
- package/dist/engines/loyalty/index.d.ts.map +1 -0
- package/dist/engines/loyalty/index.js +17 -0
- package/dist/engines/loyalty/index.js.map +1 -0
- package/dist/engines/loyalty/strategy.d.ts +35 -0
- package/dist/engines/loyalty/strategy.d.ts.map +1 -0
- package/dist/engines/loyalty/strategy.js +405 -0
- package/dist/engines/loyalty/strategy.js.map +1 -0
- package/dist/engines/loyalty/types.d.ts +190 -0
- package/dist/engines/loyalty/types.d.ts.map +1 -0
- package/dist/engines/loyalty/types.js +11 -0
- package/dist/engines/loyalty/types.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engines/decay/engine.ts +93 -1
- package/src/engines/decay/index.ts +2 -1
- package/src/engines/decay/strategy.ts +33 -0
- package/src/engines/decay/types.ts +10 -0
- package/src/engines/loyalty/compiler.ts +174 -0
- package/src/engines/loyalty/engine.ts +174 -0
- package/src/engines/loyalty/index.ts +43 -0
- package/src/engines/loyalty/strategy.ts +532 -0
- package/src/engines/loyalty/types.ts +252 -0
- 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();
|