@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,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loyalty Engine Compiler
|
|
3
|
+
*
|
|
4
|
+
* Validates loyalty rulesets and resolves defaults.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CompilationError } from '../../core/errors';
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
LoyaltyRuleSet,
|
|
11
|
+
CompiledLoyaltyRuleSet,
|
|
12
|
+
CompiledEarningRuleSet,
|
|
13
|
+
CompiledRedemptionRuleSet,
|
|
14
|
+
CompiledTierEvaluationRuleSet
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compile and validate a loyalty ruleset.
|
|
19
|
+
*/
|
|
20
|
+
export function compileLoyaltyRuleSet(
|
|
21
|
+
ruleSet: LoyaltyRuleSet
|
|
22
|
+
): CompiledLoyaltyRuleSet {
|
|
23
|
+
if (!ruleSet.id) {
|
|
24
|
+
throw new CompilationError('Loyalty ruleset requires an id');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (ruleSet.mode !== 'loyalty') {
|
|
28
|
+
throw new CompilationError(`Expected mode 'loyalty', got '${ruleSet.mode}'`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
switch (ruleSet.strategy) {
|
|
32
|
+
case 'earning':
|
|
33
|
+
return compileEarning(ruleSet);
|
|
34
|
+
case 'redemption':
|
|
35
|
+
return compileRedemption(ruleSet);
|
|
36
|
+
case 'tier-evaluation':
|
|
37
|
+
return compileTierEvaluation(ruleSet);
|
|
38
|
+
default:
|
|
39
|
+
throw new CompilationError(`Unknown loyalty strategy: '${(ruleSet as any).strategy}'`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compileEarning(
|
|
44
|
+
ruleSet: LoyaltyRuleSet & { strategy: 'earning' }
|
|
45
|
+
): CompiledEarningRuleSet {
|
|
46
|
+
if (!ruleSet.earningRules || ruleSet.earningRules.length === 0) {
|
|
47
|
+
throw new CompilationError('Earning strategy requires at least one earning rule');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validate unique rule IDs
|
|
51
|
+
const ruleIds = new Set<string>();
|
|
52
|
+
for (const rule of ruleSet.earningRules) {
|
|
53
|
+
if (!rule.id) {
|
|
54
|
+
throw new CompilationError('Each earning rule requires an id');
|
|
55
|
+
}
|
|
56
|
+
if (ruleIds.has(rule.id)) {
|
|
57
|
+
throw new CompilationError(`Duplicate earning rule id: '${rule.id}'`);
|
|
58
|
+
}
|
|
59
|
+
if (rule.baseRate < 0) {
|
|
60
|
+
throw new CompilationError(`Earning rule '${rule.id}' baseRate must be non-negative`);
|
|
61
|
+
}
|
|
62
|
+
ruleIds.add(rule.id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Validate tier IDs are unique
|
|
66
|
+
validateTierIds(ruleSet.tiers);
|
|
67
|
+
|
|
68
|
+
// Validate promotions
|
|
69
|
+
validatePromotions(ruleSet.promotions);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: ruleSet.id,
|
|
73
|
+
name: ruleSet.name,
|
|
74
|
+
mode: 'loyalty',
|
|
75
|
+
strategy: 'earning',
|
|
76
|
+
earningRules: ruleSet.earningRules,
|
|
77
|
+
defaultRate: ruleSet.defaultRate ?? 1,
|
|
78
|
+
tiers: ruleSet.tiers ?? [],
|
|
79
|
+
expirationPolicy: ruleSet.expirationPolicy ?? {},
|
|
80
|
+
promotions: ruleSet.promotions ?? []
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compileRedemption(
|
|
85
|
+
ruleSet: LoyaltyRuleSet & { strategy: 'redemption' }
|
|
86
|
+
): CompiledRedemptionRuleSet {
|
|
87
|
+
if (!ruleSet.redemptionOptions || ruleSet.redemptionOptions.length === 0) {
|
|
88
|
+
throw new CompilationError('Redemption strategy requires at least one redemption option');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Validate unique option IDs
|
|
92
|
+
const optionIds = new Set<string>();
|
|
93
|
+
for (const option of ruleSet.redemptionOptions) {
|
|
94
|
+
if (!option.id) {
|
|
95
|
+
throw new CompilationError('Each redemption option requires an id');
|
|
96
|
+
}
|
|
97
|
+
if (optionIds.has(option.id)) {
|
|
98
|
+
throw new CompilationError(`Duplicate redemption option id: '${option.id}'`);
|
|
99
|
+
}
|
|
100
|
+
if (option.pointsRequired <= 0) {
|
|
101
|
+
throw new CompilationError(`Redemption option '${option.id}' pointsRequired must be positive`);
|
|
102
|
+
}
|
|
103
|
+
optionIds.add(option.id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: ruleSet.id,
|
|
108
|
+
name: ruleSet.name,
|
|
109
|
+
mode: 'loyalty',
|
|
110
|
+
strategy: 'redemption',
|
|
111
|
+
redemptionOptions: ruleSet.redemptionOptions,
|
|
112
|
+
tiers: ruleSet.tiers ?? [],
|
|
113
|
+
expirationPolicy: ruleSet.expirationPolicy ?? {},
|
|
114
|
+
promotions: ruleSet.promotions ?? []
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function compileTierEvaluation(
|
|
119
|
+
ruleSet: LoyaltyRuleSet & { strategy: 'tier-evaluation' }
|
|
120
|
+
): CompiledTierEvaluationRuleSet {
|
|
121
|
+
if (!ruleSet.tiers || ruleSet.tiers.length === 0) {
|
|
122
|
+
throw new CompilationError('Tier-evaluation strategy requires at least one tier definition');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Validate tier IDs are unique
|
|
126
|
+
validateTierIds(ruleSet.tiers);
|
|
127
|
+
|
|
128
|
+
// Validate tiers are sorted by threshold ascending
|
|
129
|
+
for (let i = 1; i < ruleSet.tiers.length; i++) {
|
|
130
|
+
if (ruleSet.tiers[i].qualifyingThreshold < ruleSet.tiers[i - 1].qualifyingThreshold) {
|
|
131
|
+
throw new CompilationError(
|
|
132
|
+
`Tier '${ruleSet.tiers[i].id}' threshold (${ruleSet.tiers[i].qualifyingThreshold}) ` +
|
|
133
|
+
`is less than previous tier '${ruleSet.tiers[i - 1].id}' (${ruleSet.tiers[i - 1].qualifyingThreshold}). ` +
|
|
134
|
+
`Tiers must be ordered by ascending threshold.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
id: ruleSet.id,
|
|
141
|
+
name: ruleSet.name,
|
|
142
|
+
mode: 'loyalty',
|
|
143
|
+
strategy: 'tier-evaluation',
|
|
144
|
+
evaluationPeriod: ruleSet.evaluationPeriod ?? 'rolling-12-months',
|
|
145
|
+
tiers: ruleSet.tiers,
|
|
146
|
+
expirationPolicy: ruleSet.expirationPolicy ?? {},
|
|
147
|
+
promotions: ruleSet.promotions ?? []
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateTierIds(tiers?: { id: string }[]): void {
|
|
152
|
+
if (!tiers) return;
|
|
153
|
+
const tierIds = new Set<string>();
|
|
154
|
+
for (const tier of tiers) {
|
|
155
|
+
if (tierIds.has(tier.id)) {
|
|
156
|
+
throw new CompilationError(`Duplicate tier id: '${tier.id}'`);
|
|
157
|
+
}
|
|
158
|
+
tierIds.add(tier.id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validatePromotions(promotions?: { id: string }[]): void {
|
|
163
|
+
if (!promotions) return;
|
|
164
|
+
const promoIds = new Set<string>();
|
|
165
|
+
for (const promo of promotions) {
|
|
166
|
+
if (!promo.id) {
|
|
167
|
+
throw new CompilationError('Each promotion requires an id');
|
|
168
|
+
}
|
|
169
|
+
if (promoIds.has(promo.id)) {
|
|
170
|
+
throw new CompilationError(`Duplicate promotion id: '${promo.id}'`);
|
|
171
|
+
}
|
|
172
|
+
promoIds.add(promo.id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loyalty Engine
|
|
3
|
+
*
|
|
4
|
+
* Point ledger engine that manages earning rules with category multipliers,
|
|
5
|
+
* point transactions, tier-qualified balances, and promotion stacking.
|
|
6
|
+
* Unlike other BOE engines, the Loyalty engine maintains a running balance
|
|
7
|
+
* ledger across operations.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const engine = new LoyaltyEngine();
|
|
12
|
+
* const compiled = compileLoyaltyRuleSet({ ... });
|
|
13
|
+
*
|
|
14
|
+
* // Stream purchases as they arrive
|
|
15
|
+
* const r1 = engine.ingest({ memberId: 'M001', amount: 250, category: 'dining' }, compiled);
|
|
16
|
+
* // r1.pointsEarned: 500, r1.newBalance: 500
|
|
17
|
+
*
|
|
18
|
+
* engine.getBalance('M001'); // { currentBalance: 500, ... }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
WorkingMemory,
|
|
24
|
+
Fact,
|
|
25
|
+
FactInput,
|
|
26
|
+
FactChange
|
|
27
|
+
} from '../../core';
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
CompiledLoyaltyRuleSet,
|
|
31
|
+
CompiledEarningRuleSet,
|
|
32
|
+
LoyaltyOptions,
|
|
33
|
+
LoyaltyResult,
|
|
34
|
+
LoyaltyIngestResult,
|
|
35
|
+
MemberBalance
|
|
36
|
+
} from './types';
|
|
37
|
+
|
|
38
|
+
import { LoyaltyExecutor } from './strategy';
|
|
39
|
+
|
|
40
|
+
export class LoyaltyEngine {
|
|
41
|
+
private wm: WorkingMemory;
|
|
42
|
+
private strategy: LoyaltyExecutor;
|
|
43
|
+
private _ledger: Map<string, MemberBalance> = new Map();
|
|
44
|
+
|
|
45
|
+
constructor(workingMemory?: WorkingMemory) {
|
|
46
|
+
this.wm = workingMemory ?? new WorkingMemory();
|
|
47
|
+
this.strategy = new LoyaltyExecutor();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ========================================
|
|
51
|
+
// IWorkingMemory Implementation
|
|
52
|
+
// ========================================
|
|
53
|
+
|
|
54
|
+
add<T = Record<string, any>>(input: FactInput<T>): Fact<T> {
|
|
55
|
+
return this.wm.add(input);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
remove(factId: string): Fact | undefined {
|
|
59
|
+
return this.wm.remove(factId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
update<T = Record<string, any>>(input: FactInput<T>): Fact<T> {
|
|
63
|
+
return this.wm.update(input);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get(factId: string): Fact | undefined {
|
|
67
|
+
return this.wm.get(factId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getByType(type: string): Fact[] {
|
|
71
|
+
return this.wm.getByType(type);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getAll(): Fact[] {
|
|
75
|
+
return this.wm.getAll();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
has(factId: string): boolean {
|
|
79
|
+
return this.wm.has(factId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
size(): number {
|
|
83
|
+
return this.wm.size();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clear(): void {
|
|
87
|
+
this.wm.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getChanges(): FactChange[] {
|
|
91
|
+
return this.wm.getChanges();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
clearChanges(): void {
|
|
95
|
+
this.wm.clearChanges();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ========================================
|
|
99
|
+
// Engine Execution
|
|
100
|
+
// ========================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Execute a loyalty ruleset against all facts in working memory.
|
|
104
|
+
*
|
|
105
|
+
* @param ruleSet - Compiled loyalty ruleset
|
|
106
|
+
* @param options - Runtime options (asOf date)
|
|
107
|
+
* @returns Loyalty result (earning, redemption, or tier-evaluation)
|
|
108
|
+
*/
|
|
109
|
+
execute(
|
|
110
|
+
ruleSet: CompiledLoyaltyRuleSet,
|
|
111
|
+
options: LoyaltyOptions = {}
|
|
112
|
+
): LoyaltyResult {
|
|
113
|
+
return this.strategy.run(ruleSet, this.wm, this._ledger, options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ========================================
|
|
117
|
+
// Streaming Ingest
|
|
118
|
+
// ========================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Process a single purchase/activity event incrementally.
|
|
122
|
+
*
|
|
123
|
+
* Each call computes points earned, updates the internal ledger,
|
|
124
|
+
* evaluates tier status, and returns the result with the new balance.
|
|
125
|
+
* Only works with earning strategy rulesets.
|
|
126
|
+
*
|
|
127
|
+
* @param eventData - Event data with memberId, amount, and optional category
|
|
128
|
+
* @param ruleSet - Compiled earning ruleset
|
|
129
|
+
* @param asOf - Reference time (default: now)
|
|
130
|
+
* @returns Earning result with updated balance
|
|
131
|
+
*/
|
|
132
|
+
ingest(
|
|
133
|
+
eventData: Record<string, any>,
|
|
134
|
+
ruleSet: CompiledEarningRuleSet,
|
|
135
|
+
asOf: Date = new Date()
|
|
136
|
+
): LoyaltyIngestResult {
|
|
137
|
+
return this.strategy.earnSingle(eventData, ruleSet, this._ledger, asOf);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ========================================
|
|
141
|
+
// Ledger Operations
|
|
142
|
+
// ========================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get the current balance for a member.
|
|
146
|
+
* Returns undefined if the member has no transactions.
|
|
147
|
+
*/
|
|
148
|
+
getBalance(memberId: string): MemberBalance | undefined {
|
|
149
|
+
return this._ledger.get(memberId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get the full ledger state (all member balances).
|
|
154
|
+
*/
|
|
155
|
+
getLedger(): Map<string, MemberBalance> {
|
|
156
|
+
return new Map(this._ledger);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reset the ledger, clearing all member balances.
|
|
161
|
+
* Does not affect working memory.
|
|
162
|
+
*/
|
|
163
|
+
resetLedger(): void {
|
|
164
|
+
this._ledger.clear();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ========================================
|
|
168
|
+
// Utility Methods
|
|
169
|
+
// ========================================
|
|
170
|
+
|
|
171
|
+
getWorkingMemory(): WorkingMemory {
|
|
172
|
+
return this.wm;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loyalty Engine -- Point Ledger Management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Types
|
|
6
|
+
export type {
|
|
7
|
+
LoyaltyStrategy,
|
|
8
|
+
PointTransactionType,
|
|
9
|
+
TierStatus,
|
|
10
|
+
QualifyingMetric,
|
|
11
|
+
EvaluationPeriod,
|
|
12
|
+
EarningRule,
|
|
13
|
+
RedemptionOption,
|
|
14
|
+
LoyaltyTierDefinition,
|
|
15
|
+
PromotionRule,
|
|
16
|
+
ExpirationPolicy,
|
|
17
|
+
PointTransaction,
|
|
18
|
+
MemberBalance,
|
|
19
|
+
EarningRuleSet,
|
|
20
|
+
RedemptionRuleSet,
|
|
21
|
+
TierEvaluationRuleSet,
|
|
22
|
+
LoyaltyRuleSet,
|
|
23
|
+
CompiledEarningRuleSet,
|
|
24
|
+
CompiledRedemptionRuleSet,
|
|
25
|
+
CompiledTierEvaluationRuleSet,
|
|
26
|
+
CompiledLoyaltyRuleSet,
|
|
27
|
+
EarningResult,
|
|
28
|
+
RedemptionResult,
|
|
29
|
+
MemberTierResult,
|
|
30
|
+
TierEvaluationResult,
|
|
31
|
+
LoyaltyResult,
|
|
32
|
+
LoyaltyOptions,
|
|
33
|
+
LoyaltyIngestResult
|
|
34
|
+
} from './types';
|
|
35
|
+
|
|
36
|
+
// Compiler
|
|
37
|
+
export { compileLoyaltyRuleSet } from './compiler';
|
|
38
|
+
|
|
39
|
+
// Strategy
|
|
40
|
+
export { LoyaltyExecutor, loyaltyStrategy } from './strategy';
|
|
41
|
+
|
|
42
|
+
// Engine
|
|
43
|
+
export { LoyaltyEngine } from './engine';
|