@classytic/loyalty 0.2.3 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,120 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.4.0] — addendum (2026-07-05)
7
+
8
+ ### Changed (behavioral)
9
+
10
+ - **§P8.1 relay-only session guard** — `UnmanagedSessionError` now throws
11
+ only when a host session arrives with NEITHER a `[PENDING_EVENTS]`
12
+ queue NOR an outbox. Session + outbox + no queue is legal
13
+ durable-relay-only mode: the row commits with the host transaction, the
14
+ relay delivers post-commit, the immediate transport publish is skipped.
15
+ (Previously the guard was stricter than spec and threw even with an
16
+ outbox wired.) Aligns loyalty with invoice / catalog / order /
17
+ purchase / transfer / promo / payrun.
18
+ - **Stack floors raised** — peers `@classytic/mongokit >=3.18.0`,
19
+ `@classytic/repo-core >=0.7.0`; full suite verified against those
20
+ versions.
21
+
22
+ ## [0.4.0] — 2026-07-03
23
+
24
+ ### Fixed
25
+
26
+ - **`evaluateAction` is now idempotent per recurrence key.** The order path
27
+ dedupes with `${orderId}:${ruleId}`, but the action path (signup /
28
+ referral / birthday / review) never set an `idempotencyKey` — a retried
29
+ sweep or double-fired event double-awarded points. When `referenceId` is
30
+ supplied, each rule award now carries `${referenceId}:${ruleId}`; callers
31
+ encode the recurrence period in `referenceId` (e.g.
32
+ `birthday:2026:<memberId>`) and the unique partial index on
33
+ `idempotencyKey` becomes the authoritative once-only guard.
34
+ - **Action rules honor `conditions` now.** `evaluateAction` skipped
35
+ `matchesConditions` entirely, silently ignoring admin-authored
36
+ `tiers` / `dateRange` / `dayOfWeek` on action-type rules ("only gold
37
+ members get birthday points" did nothing). Conditions now apply;
38
+ `minOrderAmount` is skipped for actions (no order to measure).
39
+ - **`allowNonTransactional` now actually works at runtime.** The flag
40
+ silenced the boot capability gate but `MongoUnitOfWork.withTransaction`
41
+ still opened a real transaction unconditionally — on standalone Mongo
42
+ every `earnPoints`/`redeem` unit failed with "does not support retryable
43
+ writes" despite the documented escape hatch. The unit of work now
44
+ delegates to mongokit's `withTransaction` with `allowFallback` so the
45
+ transactions-unsupported error re-runs the unit on a plain session
46
+ (partial-write risk accepted, exactly what the flag opts into).
47
+ - Day arithmetic (tier-downgrade grace expiry, referral rate-limit window)
48
+ now uses `addDays` from `@classytic/primitives/calendar` — exact 24h × n
49
+ durations instead of local `setDate`, which shifted with the deploy
50
+ machine's `TZ` env. Primitives peer bumped to `>=0.9.0`.
51
+
52
+ ## [0.3.0] — 2026-06-13
53
+
54
+ Stack upgrade to mongokit 3.16 / repo-core 0.6 / primitives 0.7.2 plus
55
+ verified correctness + PACKAGE_RULES compliance fixes. No new features.
56
+
57
+ ### Fixed
58
+
59
+ - **Stack actually resolves the new floor now (CRITICAL).** The committed
60
+ lockfile silently served `@classytic/mongokit@3.13.0` +
61
+ `@classytic/repo-core@0.4.0` despite the `^3.13.3` / `^0.4.2` pins, so the
62
+ whole test suite ran against the OLD stack. Clean reinstall + peer/dev bumps
63
+ pin the resolved tree to mongokit **3.16.0**, repo-core **0.6.0**, primitives
64
+ **0.7.2** (verified with `npm ls`).
65
+ - **§P8 outbox is now session-bound on the transactional verbs.** `earnPoints`,
66
+ `adjustPoints`, `reserve`, `confirm`, `release`, and `reverseConfirmed`
67
+ previously dispatched the event AFTER `withTransaction` committed, so
68
+ `outbox.save` ran OUTSIDE the unit of work — the business doc could commit
69
+ while the durable event row was lost (or vice-versa). Dispatch now runs
70
+ INSIDE the transaction (outbox row commits atomically with the write) and the
71
+ transport publish is deferred to a post-commit flush (`runLoyaltyUnit` +
72
+ `[PENDING_EVENTS]`), so in-process subscribers never see a state that may roll
73
+ back. A new integration test asserts the save is session-bound and that a
74
+ failing `outbox.save` rolls the `$inc` balance write back.
75
+ - **Redemption slot no longer permanently blocked after release.** The
76
+ `{ ownerType, ownerId }` unique index spanned ALL statuses, so a released /
77
+ reversed reservation E11000-blocked any retry on the same order (payment
78
+ retry, cart re-attempt) forever. It is now a **partial** unique index scoped
79
+ to active statuses (`reserved`, `confirmed`) — terminal states free the slot
80
+ (PACKAGE_RULES rule 32/38/39). Re-reserve-after-release and second-active-
81
+ reserve-blocked tests added.
82
+
83
+ ### Added
84
+
85
+ - **`UnmanagedSessionError` guard (§P8).** When a host passes `ctx.session`
86
+ (it owns the outer transaction) without attaching a `[PENDING_EVENTS]` queue,
87
+ dispatch throws BEFORE any side effect instead of publishing / saving outside
88
+ the host's unit of work (which would leak ghost events on rollback).
89
+ `PENDING_EVENTS` and `flushPendingLoyaltyEvents` are exported for hosts that
90
+ manage their own transaction.
91
+ - **Capability boot gate.** `createLoyaltyEngine()` asserts the backend declares
92
+ the `transactions` capability (repo-core 0.6 / mongokit 3.16+) at engine
93
+ creation — fail loud at boot rather than with a cryptic driver error on the
94
+ first transactional call. `allowNonTransactional: true` opts out for local-dev
95
+ on standalone Mongo. New `LoyaltyCapabilityError`.
96
+ - **Cooperative cancellation in cron loops.** `processExpirations`, `awardBatch`,
97
+ `cleanupExpired` (redemption + referral), `evaluateAll`, and
98
+ `sweepExpiredOverrides` check `ctx.signal` via `throwIfAborted` between items —
99
+ abortable batches; each per-item write is its own committed unit of work, so
100
+ aborting never rolls back already-processed rows. `LoyaltyContext` gained
101
+ `signal?: AbortSignal`.
102
+
103
+ ### Changed
104
+
105
+ - **Domain errors implement `HttpError`** from `@classytic/repo-core/errors`
106
+ (was a bespoke `httpStatus` shape). Clean break: `httpStatus` → `status`,
107
+ and codes are now hierarchical + lowercase (`loyalty.<entity>.<problem>`,
108
+ e.g. `loyalty.member.not_found`, `loyalty.points.insufficient`) so arc
109
+ serializes them to `ErrorContract` correctly. No deprecation alias.
110
+ - **Peer-dep ranges raised** — `@classytic/mongokit >=3.16.0`,
111
+ `@classytic/repo-core >=0.6.0` (`>=` style). devDeps bumped to match and
112
+ primitives devDep pinned to `^0.7.2`.
113
+ - **`exactOptionalPropertyTypes: true`** enabled in tsconfig (mandatory per
114
+ PACKAGE_RULES Build Hygiene / P10); fixed the three pre-existing optional-vs-
115
+ undefined violations it surfaced.
116
+ - **tsconfig no longer emits sourcemaps or declaration maps**
117
+ (`sourceMap: false`, `declarationMap: false`) — both leaked source. tsdown
118
+ was already correct; tsconfig now matches.
119
+
6
120
  ## [0.2.3] — 2026-05-08
7
121
 
8
122
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,8 @@ Framework-agnostic. Works with Fastify (Arc), Express, NestJS, Next.js, or any N
9
9
  ```bash
10
10
  npm install @classytic/loyalty
11
11
  # Peer deps:
12
- npm install mongoose@^9 @classytic/mongokit@^3.4
12
+ npm install mongoose@^9 zod@^4 \
13
+ @classytic/mongokit@^3.16 @classytic/repo-core@^0.6 @classytic/primitives@^0.7
13
14
  ```
14
15
 
15
16
  ## Quick Start
@@ -26,15 +27,15 @@ const engine = createLoyaltyEngine({
26
27
  referral: { referrerRewardPoints: 200, refereeRewardPoints: 100 },
27
28
  });
28
29
 
29
- // 6 services ready to use
30
- const member = await engine.services.member.enroll(
30
+ // Domain verbs live on the repositories (no service layer — PACKAGE_RULES §1–§3)
31
+ const member = await engine.repositories.member.enroll(
31
32
  { externalId: 'cust_1', externalType: 'customer', cardId: 'MBR-001' },
32
- { actorId: 'admin' },
33
+ { actorId: 'admin', organizationId: 'org_1' },
33
34
  );
34
35
 
35
- await engine.services.ledger.earnPoints(
36
+ await engine.repositories.pointTransaction.earnPoints(
36
37
  { memberId: member._id, points: 500, description: 'Order #123' },
37
- { actorId: 'system' },
38
+ { actorId: 'system', organizationId: 'org_1' },
38
39
  );
39
40
  ```
40
41
 
@@ -42,21 +43,29 @@ await engine.services.ledger.earnPoints(
42
43
 
43
44
  ```
44
45
  createLoyaltyEngine(config)
45
- -> models 6 Mongoose models (auto-created, OverwriteModel-safe)
46
- -> repositories 6 repos (tenant-scoped CRUD, pagination, atomic transitions)
47
- -> services 6 services (the public API)
48
- -> events In-process event bus (or plug your own)
46
+ -> models 6 Mongoose models (auto-created, collision-safe)
47
+ -> repositories 6 repos = the public API (tenant-scoped CRUD + domain verbs)
48
+ -> events Arc-compatible transport (in-process fallback, or plug your own)
49
+ -> syncIndexes() deploy-time index builder
49
50
  ```
50
51
 
51
- ### Services
52
+ The capability boot gate asserts the backend supports multi-document
53
+ `transactions` (MongoDB replica set / sharded cluster) — the point-mutation
54
+ verbs are transactional. For local-dev on standalone Mongo, pass
55
+ `allowNonTransactional: true` (accepts partial-write risk).
52
56
 
53
- | Service | Purpose |
54
- |---------|---------|
55
- | `member` | Enroll, deactivate, reactivate, suspend, getByCardId, getByExternalId |
56
- | `ledger` | Earn points, adjust points, transaction history, process expirations |
57
- | `earning` | CRUD earning rules (order, action, category, tier_bonus types) |
58
- | `tier` | CRUD tier definitions, evaluate members, set/clear overrides |
59
- | `redemption` | Validate, reserve, confirm, release, cleanup expired reservations |
57
+ ### Repositories (the public API)
58
+
59
+ Domain verbs live on the repositories themselves — there is no service layer
60
+ (PACKAGE_RULES §1–§3). Access via `engine.repositories.<name>`.
61
+
62
+ | Repository | Purpose |
63
+ |------------|---------|
64
+ | `member` | Enroll, deactivate, reactivate, suspend |
65
+ | `pointTransaction` | Earn points, adjust points, `awardBatch`, process expirations |
66
+ | `earningRule` | CRUD earning rules (order, action, category, tier_bonus); `evaluateOrder` / `evaluateAction` |
67
+ | `tierDefinition` | CRUD tier definitions, evaluate members, set/clear/sweep overrides |
68
+ | `redemption` | Validate, reserve, confirm, release, reverse, cleanup expired reservations |
60
69
  | `referral` | Generate codes, record referrals, approve/reject, rate limiting |
61
70
 
62
71
  ### Key Design Decisions
@@ -148,42 +157,67 @@ as `PlatformConfig.membership.pointsPerAmount` or `amountPerPoint`. See
148
157
 
149
158
  ## Events
150
159
 
160
+ `engine.events` is an arc-compatible `EventTransport` — subscribe glob-style.
161
+ Drop in any arc transport (Memory/Redis/Kafka) via `eventTransport` in config.
162
+
151
163
  ```typescript
152
164
  import { LoyaltyEvents } from '@classytic/loyalty/events';
153
165
 
154
- engine.events.on(LoyaltyEvents.POINTS_EARNED, (payload) => { /* sync projection */ });
155
- engine.events.on(LoyaltyEvents.TIER_UPGRADED, (payload) => { /* notify customer */ });
156
- engine.events.on(LoyaltyEvents.REFERRAL_REWARDED, (payload) => { /* notify both */ });
166
+ await engine.events.subscribe(LoyaltyEvents.POINTS_EARNED, (e) => { /* sync projection */ });
167
+ await engine.events.subscribe('loyalty.tier.*', (e) => { /* notify customer */ });
168
+ await engine.events.subscribe(LoyaltyEvents.REFERRAL_REWARDED, (e) => { /* notify both */ });
157
169
  ```
158
170
 
171
+ For durable delivery, wire a host-owned `outbox` (PACKAGE_RULES §5.5 + §P8):
172
+ the engine saves the event row inside the same transaction as the business
173
+ write (atomic), then publishes after commit. Hosts that own the outer
174
+ transaction must attach a `[PENDING_EVENTS]` queue and call
175
+ `flushPendingLoyaltyEvents(transport, queue)` after their commit, or a bare
176
+ `ctx.session` throws `UnmanagedSessionError`.
177
+
159
178
  ## Production Cron Jobs
160
179
 
180
+ All cron loops accept `ctx.signal` (an `AbortSignal`) and check it between
181
+ items, so a shutdown / timeout aborts cleanly between committed units of work.
182
+
161
183
  ```typescript
184
+ const ctx = { actorId: 'cron', organizationId: 'org_1', signal: ac.signal };
185
+
162
186
  // Every 5 min — release expired point reservations
163
- await engine.services.redemption.cleanupExpired({ actorId: 'cron' });
187
+ await engine.repositories.redemption.cleanupExpired(ctx);
164
188
 
165
189
  // Every hour — expire points past their expiresAt
166
- await engine.services.ledger.processExpirations({ actorId: 'cron' });
190
+ await engine.repositories.pointTransaction.processExpirations(ctx);
167
191
 
168
192
  // Every 24h — re-evaluate all member tiers
169
- await engine.services.tier.evaluateAll({ actorId: 'cron' });
193
+ await engine.repositories.tierDefinition.evaluateAll(ctx);
194
+
195
+ // Hourly — clear expired tier overrides
196
+ await engine.repositories.tierDefinition.sweepExpiredOverrides(ctx);
170
197
  ```
171
198
 
172
199
  ## Error Codes
173
200
 
201
+ All domain errors implement `HttpError` from `@classytic/repo-core/errors`
202
+ (arc serializes them to the canonical `ErrorContract`). Codes are hierarchical
203
+ and lowercase.
204
+
174
205
  | Code | HTTP | When |
175
206
  |------|------|------|
176
- | `MEMBER_NOT_FOUND` | 404 | Member doesn't exist |
177
- | `MEMBER_ALREADY_ENROLLED` | 409 | Duplicate enrollment |
178
- | `MEMBER_INACTIVE` | 422 | Operating on inactive member |
179
- | `INSUFFICIENT_POINTS` | 400 | Not enough points to deduct |
180
- | `RULE_NOT_FOUND` | 404 | Earning rule doesn't exist |
181
- | `TIER_NOT_FOUND` | 404 | Tier definition doesn't exist |
182
- | `REDEMPTION_EXPIRED` | 410 | Reservation TTL exceeded |
183
- | `DUPLICATE_REFERRAL` | 409 | Referee already referred |
184
- | `SELF_REFERRAL` | 422 | Referring yourself |
185
- | `REFERRAL_LIMIT_EXCEEDED` | 429 | Max referrals per period |
186
- | `TENANT_ISOLATION` | 403 | Cross-tenant access attempt |
207
+ | `loyalty.member.not_found` | 404 | Member doesn't exist |
208
+ | `loyalty.member.already_enrolled` | 409 | Duplicate enrollment |
209
+ | `loyalty.member.inactive` | 422 | Operating on inactive member |
210
+ | `loyalty.points.insufficient` | 400 | Not enough points to deduct |
211
+ | `loyalty.earning_rule.not_found` | 404 | Earning rule doesn't exist |
212
+ | `loyalty.tier.not_found` | 404 | Tier definition doesn't exist |
213
+ | `loyalty.redemption.expired` | 410 | Reservation TTL exceeded |
214
+ | `loyalty.referral.duplicate` | 409 | Referee already referred |
215
+ | `loyalty.referral.self` | 422 | Referring yourself |
216
+ | `loyalty.referral.circular` | 422 | A↔B circular referral |
217
+ | `loyalty.referral.limit_exceeded` | 429 | Max referrals per period |
218
+ | `loyalty.tenant.isolation` | 403 | Missing tenant context |
219
+ | `loyalty.transaction.unmanaged_session` | 400 | `ctx.session` passed without a `[PENDING_EVENTS]` queue |
220
+ | `loyalty.engine.missing_capabilities` | 500 | Backend lacks `transactions` at boot |
187
221
 
188
222
  ## AI Agent Skill
189
223
 
@@ -24,6 +24,17 @@ interface RepositoryPlugins {
24
24
  interface LoyaltyConfig {
25
25
  mongoose: Connection;
26
26
  tenant?: TenantConfig$1 | undefined;
27
+ /**
28
+ * IANA timezone name (e.g. `'Asia/Dhaka'`, `'America/New_York'`) used to
29
+ * evaluate wall-clock rule conditions — currently `EarningConditions.dayOfWeek`
30
+ * ("2× points on Fridays"). Resolved DST-exact per instant via
31
+ * `@classytic/primitives/timezone`. Validated at boot: an unknown zone throws
32
+ * `ValidationError`, never a silent mis-evaluation.
33
+ *
34
+ * Omitted → **UTC** (the day boundary is the UTC calendar day). Either way the
35
+ * result NEVER depends on the deploy machine's `TZ` env. PACKAGE_RULES P12.
36
+ */
37
+ timezone?: string | undefined;
27
38
  program?: {
28
39
  id?: string | undefined;
29
40
  conversionRate?: number | undefined;
@@ -99,6 +110,16 @@ interface LoyaltyConfig {
99
110
  outbox?: OutboxStore | undefined;
100
111
  notifications?: NotificationAdapter | undefined;
101
112
  plugins?: RepositoryPlugins | undefined;
113
+ /**
114
+ * Skip the boot-time capability gate that asserts the backend supports
115
+ * multi-document `transactions`. Loyalty's point-mutation verbs (earn,
116
+ * adjust, reserve, confirm, release, reverse, processExpirations) run inside
117
+ * `withTransaction`; without a replica set those writes are NOT atomic.
118
+ * Leave `false` (default) in production — set `true` only for local-dev on
119
+ * standalone Mongo, accepting partial-write risk. See PACKAGE_RULES
120
+ * "Runtime capabilities".
121
+ */
122
+ allowNonTransactional?: boolean | undefined;
102
123
  }
103
124
  interface ResolvedTenant {
104
125
  enabled: boolean;
@@ -130,6 +151,8 @@ interface ResolvedConfig {
130
151
  enabled: boolean;
131
152
  defaultDays?: number;
132
153
  };
154
+ /** Resolved IANA zone for wall-clock rule evaluation; `undefined` = UTC. */
155
+ timezone?: string;
133
156
  tenant: ResolvedTenant;
134
157
  }
135
158
  //#endregion
@@ -1,2 +1,2 @@
1
- import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, D as MultiTierReferralAwarded, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, I as RedemptionReversed, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, U as TierOverrideCleared, V as TierDowngraded, W as TierOverrideExpired, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, h as RedemptionReversedPayload, i as CampaignAwardedPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, n as EventHandler, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, r as EventTransport, s as LoyaltyEventPayload, t as DomainEvent, u as MultiTierReferralAwardedPayload, v as TierOverrideLifecyclePayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, y as CampaignAwarded, z as ReferralRejected } from "../index-DArT4zkB.mjs";
1
+ import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, D as MultiTierReferralAwarded, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, I as RedemptionReversed, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, U as TierOverrideCleared, V as TierDowngraded, W as TierOverrideExpired, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, h as RedemptionReversedPayload, i as CampaignAwardedPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, n as EventHandler, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, r as EventTransport, s as LoyaltyEventPayload, t as DomainEvent, u as MultiTierReferralAwardedPayload, v as TierOverrideLifecyclePayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, y as CampaignAwarded, z as ReferralRejected } from "../index-D_5Ojc2c.mjs";
2
2
  export { CampaignAwarded, CampaignAwardedPayload, DomainEvent, EventHandler, EventTransport, InProcessLoyaltyBus, InProcessLoyaltyBusOptions, LoyaltyEventDefinition, LoyaltyEventMap, LoyaltyEventName, LoyaltyEventPayload, LoyaltyEventPayloadOf, LoyaltyEventSchema, LoyaltyEvents, MemberDeactivated, MemberEnrolled, MemberLifecyclePayload, MemberReactivated, MemberSuspended, MultiTierReferralAwarded, MultiTierReferralAwardedPayload, PointsAdjusted, PointsAdjustedPayload, PointsEarned, PointsEarnedPayload, PointsExpired, PointsExpiredPayload, PointsExpiringSoon, PointsRedeemed, RedemptionConfirmed, RedemptionPayload, RedemptionReleased, RedemptionReserved, RedemptionReversed, RedemptionReversedPayload, ReferralApproved, ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRewarded, TierChangedPayload, TierDowngraded, TierGraceStarted, TierOverrideCleared, TierOverrideExpired, TierOverrideLifecyclePayload, TierUpgraded, createEvent, loyaltyEventDefinitions };
@@ -1,3 +1,3 @@
1
- import { C as TierOverrideExpired, E as InProcessLoyaltyBus, S as TierOverrideCleared, T as loyaltyEventDefinitions, _ as ReferralRecorded, a as MemberSuspended, b as TierDowngraded, c as PointsEarned, d as PointsRedeemed, f as RedemptionConfirmed, g as ReferralApproved, h as RedemptionReversed, i as MemberReactivated, l as PointsExpired, m as RedemptionReserved, n as MemberDeactivated, o as MultiTierReferralAwarded, p as RedemptionReleased, r as MemberEnrolled, s as PointsAdjusted, t as CampaignAwarded, u as PointsExpiringSoon, v as ReferralRejected, w as TierUpgraded, x as TierGraceStarted, y as ReferralRewarded } from "../events-BtZOdiPw.mjs";
2
- import { n as createEvent, t as LoyaltyEvents } from "../loyalty-events-Cyt9TwX6.mjs";
1
+ import { C as TierOverrideExpired, E as InProcessLoyaltyBus, S as TierOverrideCleared, T as loyaltyEventDefinitions, _ as ReferralRecorded, a as MemberSuspended, b as TierDowngraded, c as PointsEarned, d as PointsRedeemed, f as RedemptionConfirmed, g as ReferralApproved, h as RedemptionReversed, i as MemberReactivated, l as PointsExpired, m as RedemptionReserved, n as MemberDeactivated, o as MultiTierReferralAwarded, p as RedemptionReleased, r as MemberEnrolled, s as PointsAdjusted, t as CampaignAwarded, u as PointsExpiringSoon, v as ReferralRejected, w as TierUpgraded, x as TierGraceStarted, y as ReferralRewarded } from "../events-DN5_p6T0.mjs";
2
+ import { n as createEvent, t as LoyaltyEvents } from "../loyalty-events-C38JA3cU.mjs";
3
3
  export { CampaignAwarded, InProcessLoyaltyBus, LoyaltyEvents, MemberDeactivated, MemberEnrolled, MemberReactivated, MemberSuspended, MultiTierReferralAwarded, PointsAdjusted, PointsEarned, PointsExpired, PointsExpiringSoon, PointsRedeemed, RedemptionConfirmed, RedemptionReleased, RedemptionReserved, RedemptionReversed, ReferralApproved, ReferralRecorded, ReferralRejected, ReferralRewarded, TierDowngraded, TierGraceStarted, TierOverrideCleared, TierOverrideExpired, TierUpgraded, createEvent, loyaltyEventDefinitions };
@@ -1,4 +1,4 @@
1
- import { t as LoyaltyEvents } from "./loyalty-events-Cyt9TwX6.mjs";
1
+ import { t as LoyaltyEvents } from "./loyalty-events-C38JA3cU.mjs";
2
2
  import { createEvent, matchEventPattern } from "@classytic/primitives/events";
3
3
  import { z } from "zod";
4
4
  //#region src/events/in-process-bus.ts
@@ -1,4 +1,4 @@
1
- import { o as LoyaltyContext } from "./index-DWarXsmw.mjs";
1
+ import { o as LoyaltyContext } from "./index-YSsi4e_G.mjs";
2
2
  import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventHandler as EventHandler$1, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
3
3
  import { z } from "zod";
4
4
 
@@ -1,11 +1,11 @@
1
1
  import { i as ReferralStatus, r as RedemptionStatus } from "./index-DMK8a98J.mjs";
2
- import { S as EarningRule, _ as TierEvaluationBatch, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, o as LoyaltyContext, v as PointTransaction, x as TierDefinition, y as Member } from "./index-DWarXsmw.mjs";
2
+ import { S as EarningRule, _ as TierEvaluationBatch, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, o as LoyaltyContext, v as PointTransaction, x as TierDefinition, y as Member } from "./index-YSsi4e_G.mjs";
3
3
  import { t as NotificationAdapter } from "./notification.port-CJ9nXj0k.mjs";
4
- import { c as ResolvedTenant, o as RepositoryPlugins, s as ResolvedConfig } from "./config-96Sl6oNJ.mjs";
4
+ import { c as ResolvedTenant, o as RepositoryPlugins, s as ResolvedConfig } from "./config-Dtun1duw.mjs";
5
5
  import { LoyaltyModels } from "./models/index.mjs";
6
+ import { PluginType, Repository } from "@classytic/mongokit";
6
7
  import { EventTransport } from "@classytic/primitives/events";
7
8
  import { ClientSession, Model } from "mongoose";
8
- import { PluginType, Repository } from "@classytic/mongokit";
9
9
  import { OutboxStore } from "@classytic/primitives/outbox";
10
10
  import { ApprovalChain } from "@classytic/primitives/approval";
11
11
 
@@ -65,6 +65,7 @@ declare class PointTransactionRepository extends Repository<PointTransaction> {
65
65
  constructor(model: Model<PointTransaction>, plugins?: PluginType[]);
66
66
  /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
67
67
  private _dispatch;
68
+ private get _dispatchDeps();
68
69
  /**
69
70
  * Auto-inject tenant id from options into doc payload. Matches
70
71
  * `@classytic/order` / `@classytic/promo` — see MemberRepository.
@@ -158,7 +159,19 @@ declare class EarningRuleRepository extends Repository<EarningRule> {
158
159
  action: string;
159
160
  referenceId?: string;
160
161
  }, ctx: LoyaltyContext): Promise<EarningResult>;
162
+ /**
163
+ * `orderTotal` is optional — action-type evaluations have no order, so
164
+ * `minOrderAmount` only applies when a total is supplied (order paths).
165
+ */
161
166
  private matchesConditions;
167
+ /**
168
+ * The wall-clock weekday of `instant` in JS `getDay()` convention
169
+ * (0=Sun … 6=Sat — what `EarningConditions.dayOfWeek` is authored in),
170
+ * evaluated in the engine's configured `timezone` (DST-exact) or UTC when
171
+ * unset. NEVER server-local `Date.getDay()`, which shifts with the deploy
172
+ * machine's `TZ` env (PACKAGE_RULES P12).
173
+ */
174
+ private weekday;
162
175
  private calculateOrderPoints;
163
176
  private calculateCategoryPoints;
164
177
  private applyRounding;
@@ -224,6 +237,7 @@ declare class RedemptionRepository extends Repository<Redemption> {
224
237
  createMany(docs: Parameters<Repository<Redemption>['createMany']>[0], options?: Parameters<Repository<Redemption>['createMany']>[1]): Promise<Redemption[]>;
225
238
  /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
226
239
  private _dispatch;
240
+ private get _dispatchDeps();
227
241
  /**
228
242
  * Atomic compare-and-set status transition.
229
243
  * Returns null if the current status doesn't match `expectedStatus` (CAS mismatch).
@@ -366,8 +380,8 @@ interface LoyaltyRepositories {
366
380
  }
367
381
  interface CreateRepositoriesInput {
368
382
  models: LoyaltyModels;
369
- plugins?: RepositoryPlugins;
370
- tenant?: ResolvedTenant;
383
+ plugins?: RepositoryPlugins | undefined;
384
+ tenant?: ResolvedTenant | undefined;
371
385
  config: ResolvedConfig;
372
386
  events: EventTransport;
373
387
  outbox?: OutboxStore | undefined;
@@ -8,6 +8,12 @@ interface EarningConditions {
8
8
  categories?: string[];
9
9
  tiers?: string[];
10
10
  actions?: string[];
11
+ /**
12
+ * Days the rule applies, JS `getDay()` convention: 0=Sun … 6=Sat. Empty /
13
+ * omitted = every day. Evaluated against the wall-clock weekday in the
14
+ * engine's configured `timezone` (UTC when unset), never the server's local
15
+ * clock. See `LoyaltyConfig.timezone`.
16
+ */
11
17
  dayOfWeek?: number[];
12
18
  dateRange?: {
13
19
  start?: Date;
@@ -217,6 +223,15 @@ interface LoyaltyContext extends OperationContext {
217
223
  organizationId?: string;
218
224
  /** Override program ID for multi-program setups. */
219
225
  programId?: string;
226
+ /**
227
+ * Cooperative cancellation for long-running cron loops (point expiry,
228
+ * reservation cleanup, tier evaluation, override sweeps, campaign award).
229
+ * Checked at each loop boundary via `throwIfAborted` — aborting between
230
+ * iterations never rolls back an already-committed transaction, and each
231
+ * per-item write is its own unit of work. Threaded to mongokit round-trips
232
+ * by hosts that pass it; mongokit (3.16+) honours it at every op boundary.
233
+ */
234
+ signal?: AbortSignal | undefined;
220
235
  /** Dynamic tenant key support — e.g. `{ companyId: '...' }` — when a host
221
236
  * configured a custom `contextKey`. */
222
237
  [key: string]: unknown;
package/dist/index.d.mts CHANGED
@@ -1,11 +1,159 @@
1
- import { S as EarningRule, a as EnrollInput, b as MemberBalance, c as UpdateTierInput, d as EarningResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, n as CreateEarningRuleInput, o as LoyaltyContext, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, v as PointTransaction, x as TierDefinition, y as Member } from "./index-DWarXsmw.mjs";
1
+ import { S as EarningRule, a as EnrollInput, b as MemberBalance, c as UpdateTierInput, d as EarningResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, n as CreateEarningRuleInput, o as LoyaltyContext, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, v as PointTransaction, x as TierDefinition, y as Member } from "./index-YSsi4e_G.mjs";
2
2
  import { t as NotificationAdapter } from "./notification.port-CJ9nXj0k.mjs";
3
- import { c as ResolvedTenant, n as LoyaltyConfig, r as LoyaltyModelName, s as ResolvedConfig } from "./config-96Sl6oNJ.mjs";
4
- import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, V as TierDowngraded, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, s as LoyaltyEventPayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, z as ReferralRejected } from "./index-DArT4zkB.mjs";
3
+ import { c as ResolvedTenant, n as LoyaltyConfig, r as LoyaltyModelName, s as ResolvedConfig } from "./config-Dtun1duw.mjs";
4
+ import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, V as TierDowngraded, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, s as LoyaltyEventPayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, z as ReferralRejected } from "./index-D_5Ojc2c.mjs";
5
5
  import { LoyaltyModels } from "./models/index.mjs";
6
- import { a as ReferralRepository, c as Redemption, d as MemberRepository, f as UnitOfWork, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, o as Referral, s as RedemptionRepository, u as PointTransactionRepository } from "./index-Cl-WGBFJ.mjs";
7
- import { DomainEvent, EventHandler, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
6
+ import { a as ReferralRepository, c as Redemption, d as MemberRepository, f as UnitOfWork, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, o as Referral, s as RedemptionRepository, u as PointTransactionRepository } from "./index-Dngaj21_.mjs";
7
+ import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
8
+ import { OutboxStore } from "@classytic/primitives/outbox";
9
+ import { HttpError } from "@classytic/repo-core/errors";
8
10
 
11
+ //#region src/domain/errors/base.d.ts
12
+ /**
13
+ * Base for every loyalty throwable. Implements `HttpError` from
14
+ * `@classytic/repo-core/errors` (the org-canonical throwable contract) so arc
15
+ * catches and serializes them to `ErrorContract` via `toErrorContract()` with
16
+ * the right HTTP `status` and a stable, machine-readable `code`.
17
+ *
18
+ * Codes are hierarchical + lowercase per PACKAGE_RULES ("Errors and wire
19
+ * envelopes"): `loyalty.<entity>.<problem>`. The `status` field (not the old
20
+ * `httpStatus`) is what `HttpError` / arc read — a clean break, no alias.
21
+ */
22
+ declare abstract class LoyaltyError extends Error implements HttpError {
23
+ abstract readonly code: string;
24
+ abstract readonly status: number;
25
+ constructor(message: string);
26
+ toJSON(): {
27
+ code: string;
28
+ message: string;
29
+ name: string;
30
+ status: number;
31
+ };
32
+ }
33
+ //#endregion
34
+ //#region src/domain/errors/domain-errors.d.ts
35
+ declare class InsufficientPointsError extends LoyaltyError {
36
+ readonly code = "loyalty.points.insufficient";
37
+ readonly status = 400;
38
+ constructor(available: number, requested: number);
39
+ }
40
+ declare class MemberNotFoundError extends LoyaltyError {
41
+ readonly code = "loyalty.member.not_found";
42
+ readonly status = 404;
43
+ constructor(id: string);
44
+ }
45
+ declare class MemberAlreadyEnrolledError extends LoyaltyError {
46
+ readonly code = "loyalty.member.already_enrolled";
47
+ readonly status = 409;
48
+ constructor(externalId: string);
49
+ }
50
+ declare class MemberInactiveError extends LoyaltyError {
51
+ readonly code = "loyalty.member.inactive";
52
+ readonly status = 422;
53
+ constructor(id: string);
54
+ }
55
+ declare class RuleNotFoundError extends LoyaltyError {
56
+ readonly code = "loyalty.earning_rule.not_found";
57
+ readonly status = 404;
58
+ constructor(id: string);
59
+ }
60
+ declare class TierNotFoundError extends LoyaltyError {
61
+ readonly code = "loyalty.tier.not_found";
62
+ readonly status = 404;
63
+ constructor(id: string);
64
+ }
65
+ declare class RedemptionNotFoundError extends LoyaltyError {
66
+ readonly code = "loyalty.redemption.not_found";
67
+ readonly status = 404;
68
+ constructor(id: string);
69
+ }
70
+ declare class RedemptionExpiredError extends LoyaltyError {
71
+ readonly code = "loyalty.redemption.expired";
72
+ readonly status = 410;
73
+ constructor(id: string);
74
+ }
75
+ declare class ReferralNotFoundError extends LoyaltyError {
76
+ readonly code = "loyalty.referral.not_found";
77
+ readonly status = 404;
78
+ constructor(id: string);
79
+ }
80
+ declare class DuplicateReferralError extends LoyaltyError {
81
+ readonly code = "loyalty.referral.duplicate";
82
+ readonly status = 409;
83
+ constructor(refereeId: string);
84
+ }
85
+ declare class ReferralLimitExceededError extends LoyaltyError {
86
+ readonly code = "loyalty.referral.limit_exceeded";
87
+ readonly status = 429;
88
+ constructor(limit: number, periodDays: number);
89
+ }
90
+ declare class SelfReferralError extends LoyaltyError {
91
+ readonly code = "loyalty.referral.self";
92
+ readonly status = 422;
93
+ constructor();
94
+ }
95
+ declare class CircularReferralError extends LoyaltyError {
96
+ readonly code = "loyalty.referral.circular";
97
+ readonly status = 422;
98
+ constructor();
99
+ }
100
+ declare class ValidationError extends LoyaltyError {
101
+ readonly code = "loyalty.validation.invalid";
102
+ readonly status = 400;
103
+ }
104
+ declare class TenantIsolationError extends LoyaltyError {
105
+ readonly code = "loyalty.tenant.isolation";
106
+ readonly status = 403;
107
+ constructor();
108
+ }
109
+ /**
110
+ * A host passed `ctx.session` (it owns the outer transaction) but did NOT
111
+ * attach a `[PENDING_EVENTS]` queue. Publishing to in-process subscribers
112
+ * BEFORE the host's commit would leak ghost events on rollback, and the
113
+ * outbox save would not be coordinated with the host's unit of work.
114
+ *
115
+ * Two safe patterns: (1) let loyalty own the transaction — call the verb
116
+ * WITHOUT `ctx.session`; or (2) attach a fresh `[PENDING_EVENTS]` array via
117
+ * `{ ...ctx, [PENDING_EVENTS]: queue }` and call `flushPendingLoyaltyEvents`
118
+ * after your transaction commits. Both are exported from the package root.
119
+ */
120
+ declare class UnmanagedSessionError extends LoyaltyError {
121
+ readonly code = "loyalty.transaction.unmanaged_session";
122
+ readonly status = 400;
123
+ constructor();
124
+ }
125
+ /**
126
+ * The backend doesn't declare the runtime capabilities loyalty's
127
+ * transactional verbs require (earn / adjust / reserve / confirm / release /
128
+ * reverse all run inside `withTransaction`). MongoDB must run as a replica set
129
+ * or sharded cluster. Thrown at engine creation — fail loud at boot, not with
130
+ * a cryptic driver error on the first transactional call.
131
+ */
132
+ declare class LoyaltyCapabilityError extends LoyaltyError {
133
+ readonly code = "loyalty.engine.missing_capabilities";
134
+ readonly status = 500;
135
+ constructor(missing: readonly string[]);
136
+ }
137
+ //#endregion
138
+ //#region src/events/dispatch.d.ts
139
+ /**
140
+ * Internal key under which the post-commit event-publish queue is threaded
141
+ * through the context. When set, `dispatchLoyaltyEvent` APPENDS to this queue
142
+ * instead of publishing immediately — the verb that owns the transaction
143
+ * flushes it after commit. A `Symbol.for` so it never collides with
144
+ * host-supplied context fields and never appears in the public type.
145
+ */
146
+ declare const PENDING_EVENTS: unique symbol;
147
+ /**
148
+ * Drains a queue of deferred events (the one attached under `[PENDING_EVENTS]`)
149
+ * through the transport, swallowing per-event errors. Called by transactional
150
+ * verbs AFTER `withTransaction` commits, and exported so hosts that own the
151
+ * outer transaction can flush after their own commit.
152
+ */
153
+ declare function flushPendingLoyaltyEvents(events: EventTransport$1 | undefined, queue: DomainEvent$1[], logger?: {
154
+ error(message: string, ...args: unknown[]): void;
155
+ } | undefined): Promise<void>;
156
+ //#endregion
9
157
  //#region src/index.d.ts
10
158
  declare function resolveConfig(config: LoyaltyConfig): ResolvedConfig;
11
159
  /**
@@ -34,4 +182,4 @@ interface LoyaltyEngine {
34
182
  }
35
183
  declare function createLoyaltyEngine(config: LoyaltyConfig): LoyaltyEngine;
36
184
  //#endregion
37
- export { type AdjustPointsInput, type BalanceSummary, type CreateEarningRuleInput, type CreateTierInput, type DomainEvent, type EarnPointsInput, type EarningResult, type EarningRule, EarningRuleRepository, type EnrollInput, type EventHandler, type EventTransport, InProcessLoyaltyBus, type InProcessLoyaltyBusOptions, type LoyaltyConfig, type LoyaltyContext, LoyaltyEngine, type LoyaltyEventDefinition, type LoyaltyEventMap, type LoyaltyEventName, type LoyaltyEventPayload, type LoyaltyEventPayloadOf, type LoyaltyEventSchema, LoyaltyEvents, type LoyaltyModelName, type LoyaltyModels, type LoyaltyRepositories, type Member, type MemberBalance, MemberDeactivated, MemberEnrolled, type MemberLifecyclePayload, MemberReactivated, MemberRepository, MemberSuspended, type NotificationAdapter, type PointTransaction, PointTransactionRepository, PointsAdjusted, type PointsAdjustedPayload, PointsEarned, type PointsEarnedPayload, PointsExpired, type PointsExpiredPayload, PointsExpiringSoon, PointsRedeemed, type Redemption, RedemptionConfirmed, type RedemptionPayload, RedemptionReleased, RedemptionRepository, RedemptionReserved, type RedemptionValidation, type Referral, ReferralApproved, type ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRepository, ReferralRewarded, type ResolvedConfig, type ResolvedTenant, type TierChangedPayload, type TierDefinition, TierDefinitionRepository, TierDowngraded, type TierEvaluation, TierGraceStarted, TierUpgraded, type UnitOfWork, type UpdateEarningRuleInput, type UpdateTierInput, createEvent, createLoyaltyEngine, loyaltyEventDefinitions, resolveConfig };
185
+ export { type AdjustPointsInput, type BalanceSummary, CircularReferralError, type CreateEarningRuleInput, type CreateTierInput, type DomainEvent, DuplicateReferralError, type EarnPointsInput, type EarningResult, type EarningRule, EarningRuleRepository, type EnrollInput, type EventHandler, type EventTransport, InProcessLoyaltyBus, type InProcessLoyaltyBusOptions, InsufficientPointsError, LoyaltyCapabilityError, type LoyaltyConfig, type LoyaltyContext, LoyaltyEngine, type LoyaltyEventDefinition, type LoyaltyEventMap, type LoyaltyEventName, type LoyaltyEventPayload, type LoyaltyEventPayloadOf, type LoyaltyEventSchema, LoyaltyEvents, type LoyaltyModelName, type LoyaltyModels, type LoyaltyRepositories, type Member, MemberAlreadyEnrolledError, type MemberBalance, MemberDeactivated, MemberEnrolled, MemberInactiveError, type MemberLifecyclePayload, MemberNotFoundError, MemberReactivated, MemberRepository, MemberSuspended, type NotificationAdapter, PENDING_EVENTS, type PointTransaction, PointTransactionRepository, PointsAdjusted, type PointsAdjustedPayload, PointsEarned, type PointsEarnedPayload, PointsExpired, type PointsExpiredPayload, PointsExpiringSoon, PointsRedeemed, type Redemption, RedemptionConfirmed, RedemptionExpiredError, RedemptionNotFoundError, type RedemptionPayload, RedemptionReleased, RedemptionRepository, RedemptionReserved, type RedemptionValidation, type Referral, ReferralApproved, ReferralLimitExceededError, ReferralNotFoundError, type ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRepository, ReferralRewarded, type ResolvedConfig, type ResolvedTenant, RuleNotFoundError, SelfReferralError, TenantIsolationError, type TierChangedPayload, type TierDefinition, TierDefinitionRepository, TierDowngraded, type TierEvaluation, TierGraceStarted, TierNotFoundError, TierUpgraded, type UnitOfWork, UnmanagedSessionError, type UpdateEarningRuleInput, type UpdateTierInput, ValidationError, createEvent, createLoyaltyEngine, flushPendingLoyaltyEvents, loyaltyEventDefinitions, resolveConfig };