@classytic/loyalty 0.2.2 → 0.3.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,107 @@
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.3.0] — 2026-06-13
7
+
8
+ Stack upgrade to mongokit 3.16 / repo-core 0.6 / primitives 0.7.2 plus
9
+ verified correctness + PACKAGE_RULES compliance fixes. No new features.
10
+
11
+ ### Fixed
12
+
13
+ - **Stack actually resolves the new floor now (CRITICAL).** The committed
14
+ lockfile silently served `@classytic/mongokit@3.13.0` +
15
+ `@classytic/repo-core@0.4.0` despite the `^3.13.3` / `^0.4.2` pins, so the
16
+ whole test suite ran against the OLD stack. Clean reinstall + peer/dev bumps
17
+ pin the resolved tree to mongokit **3.16.0**, repo-core **0.6.0**, primitives
18
+ **0.7.2** (verified with `npm ls`).
19
+ - **§P8 outbox is now session-bound on the transactional verbs.** `earnPoints`,
20
+ `adjustPoints`, `reserve`, `confirm`, `release`, and `reverseConfirmed`
21
+ previously dispatched the event AFTER `withTransaction` committed, so
22
+ `outbox.save` ran OUTSIDE the unit of work — the business doc could commit
23
+ while the durable event row was lost (or vice-versa). Dispatch now runs
24
+ INSIDE the transaction (outbox row commits atomically with the write) and the
25
+ transport publish is deferred to a post-commit flush (`runLoyaltyUnit` +
26
+ `[PENDING_EVENTS]`), so in-process subscribers never see a state that may roll
27
+ back. A new integration test asserts the save is session-bound and that a
28
+ failing `outbox.save` rolls the `$inc` balance write back.
29
+ - **Redemption slot no longer permanently blocked after release.** The
30
+ `{ ownerType, ownerId }` unique index spanned ALL statuses, so a released /
31
+ reversed reservation E11000-blocked any retry on the same order (payment
32
+ retry, cart re-attempt) forever. It is now a **partial** unique index scoped
33
+ to active statuses (`reserved`, `confirmed`) — terminal states free the slot
34
+ (PACKAGE_RULES rule 32/38/39). Re-reserve-after-release and second-active-
35
+ reserve-blocked tests added.
36
+
37
+ ### Added
38
+
39
+ - **`UnmanagedSessionError` guard (§P8).** When a host passes `ctx.session`
40
+ (it owns the outer transaction) without attaching a `[PENDING_EVENTS]` queue,
41
+ dispatch throws BEFORE any side effect instead of publishing / saving outside
42
+ the host's unit of work (which would leak ghost events on rollback).
43
+ `PENDING_EVENTS` and `flushPendingLoyaltyEvents` are exported for hosts that
44
+ manage their own transaction.
45
+ - **Capability boot gate.** `createLoyaltyEngine()` asserts the backend declares
46
+ the `transactions` capability (repo-core 0.6 / mongokit 3.16+) at engine
47
+ creation — fail loud at boot rather than with a cryptic driver error on the
48
+ first transactional call. `allowNonTransactional: true` opts out for local-dev
49
+ on standalone Mongo. New `LoyaltyCapabilityError`.
50
+ - **Cooperative cancellation in cron loops.** `processExpirations`, `awardBatch`,
51
+ `cleanupExpired` (redemption + referral), `evaluateAll`, and
52
+ `sweepExpiredOverrides` check `ctx.signal` via `throwIfAborted` between items —
53
+ abortable batches; each per-item write is its own committed unit of work, so
54
+ aborting never rolls back already-processed rows. `LoyaltyContext` gained
55
+ `signal?: AbortSignal`.
56
+
57
+ ### Changed
58
+
59
+ - **Domain errors implement `HttpError`** from `@classytic/repo-core/errors`
60
+ (was a bespoke `httpStatus` shape). Clean break: `httpStatus` → `status`,
61
+ and codes are now hierarchical + lowercase (`loyalty.<entity>.<problem>`,
62
+ e.g. `loyalty.member.not_found`, `loyalty.points.insufficient`) so arc
63
+ serializes them to `ErrorContract` correctly. No deprecation alias.
64
+ - **Peer-dep ranges raised** — `@classytic/mongokit >=3.16.0`,
65
+ `@classytic/repo-core >=0.6.0` (`>=` style). devDeps bumped to match and
66
+ primitives devDep pinned to `^0.7.2`.
67
+ - **`exactOptionalPropertyTypes: true`** enabled in tsconfig (mandatory per
68
+ PACKAGE_RULES Build Hygiene / P10); fixed the three pre-existing optional-vs-
69
+ undefined violations it surfaced.
70
+ - **tsconfig no longer emits sourcemaps or declaration maps**
71
+ (`sourceMap: false`, `declarationMap: false`) — both leaked source. tsdown
72
+ was already correct; tsconfig now matches.
73
+
74
+ ## [0.2.3] — 2026-05-08
75
+
76
+ ### Fixed
77
+
78
+ - **§P8 dispatch — `outbox.save` failures now propagate.** [src/events/dispatch.ts](src/events/dispatch.ts)
79
+ previously caught `outbox.save` errors and swallowed them silently. Per
80
+ PACKAGE_RULES §P8 the outbox row is the durability anchor for the
81
+ transactional-outbox pattern: if the save fails, the caller's transaction
82
+ MUST roll back so the business doc and the event row land atomically.
83
+ Swallowing the failure meant a parent doc could commit while the event
84
+ vanished, with no relay row to recover from. Fix re-throws after logging.
85
+ `events.publish` failures continue to be swallowed (correctly, per §P8 —
86
+ the host's outbox relay re-publishes from the durable row), but now log
87
+ before swallowing so failures are observable.
88
+ - **`DispatchDeps.logger` field added.** Optional. Both arms of the
89
+ dispatch helper log through it when set, surfacing failures in the host's
90
+ observability stack without coupling the package to a specific logger.
91
+
92
+ ### Changed
93
+
94
+ - **Peer-dep ranges tightened** — `@classytic/mongokit` `>=3.13.3`
95
+ (was `>=3.13.0`) and `@classytic/repo-core` `>=0.4.2` (was `>=0.4.0`).
96
+ 3.13.3 preserves `errorLabels` on `MongoServerError` so concurrent
97
+ transactional writes (the `claim()`-based redemption + tier-eval paths)
98
+ retry correctly under contention; 0.4.2 adds the typed `FindAllOptions`
99
+ shape with `limit?` for callers that need a bounded non-paginated read.
100
+
101
+ ### Internal
102
+
103
+ - Replaced 4 `arr.find()!` non-null assertions in [src/index.ts](src/index.ts)
104
+ + scenario tests with explicit narrowing — TS now proves what the runtime
105
+ shape already guaranteed.
106
+
6
107
  ## [0.2.0]
7
108
 
8
109
  Structural rewrite on top of the 0.1.0 shape. The public API is now a
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
 
@@ -99,6 +99,16 @@ interface LoyaltyConfig {
99
99
  outbox?: OutboxStore | undefined;
100
100
  notifications?: NotificationAdapter | undefined;
101
101
  plugins?: RepositoryPlugins | undefined;
102
+ /**
103
+ * Skip the boot-time capability gate that asserts the backend supports
104
+ * multi-document `transactions`. Loyalty's point-mutation verbs (earn,
105
+ * adjust, reserve, confirm, release, reverse, processExpirations) run inside
106
+ * `withTransaction`; without a replica set those writes are NOT atomic.
107
+ * Leave `false` (default) in production — set `true` only for local-dev on
108
+ * standalone Mongo, accepting partial-write risk. See PACKAGE_RULES
109
+ * "Runtime capabilities".
110
+ */
111
+ allowNonTransactional?: boolean | undefined;
102
112
  }
103
113
  interface ResolvedTenant {
104
114
  enabled: boolean;
@@ -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-xLLJ7jLy.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-CnESLipu.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,12 +1,13 @@
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-Bo5YQ_fu.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-D20Cn4KB.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-BdSyAdnF.mjs";
5
5
  import { LoyaltyModels } from "./models/index.mjs";
6
6
  import { EventTransport } from "@classytic/primitives/events";
7
7
  import { ClientSession, Model } from "mongoose";
8
8
  import { PluginType, Repository } from "@classytic/mongokit";
9
9
  import { OutboxStore } from "@classytic/primitives/outbox";
10
+ import { ApprovalChain } from "@classytic/primitives/approval";
10
11
 
11
12
  //#region src/domain/ports/unit-of-work.port.d.ts
12
13
  interface UnitOfWork {
@@ -64,6 +65,7 @@ declare class PointTransactionRepository extends Repository<PointTransaction> {
64
65
  constructor(model: Model<PointTransaction>, plugins?: PluginType[]);
65
66
  /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
66
67
  private _dispatch;
68
+ private get _dispatchDeps();
67
69
  /**
68
70
  * Auto-inject tenant id from options into doc payload. Matches
69
71
  * `@classytic/order` / `@classytic/promo` — see MemberRepository.
@@ -192,6 +194,18 @@ interface Redemption {
192
194
  */
193
195
  initialBalance?: number;
194
196
  remainingBalance?: number;
197
+ /**
198
+ * Optional multi-step approval chain (P7 canonical — same shape as
199
+ * `@classytic/purchase` / `@classytic/order`). High-value redemptions
200
+ * (gift cards above threshold, large discount vouchers) gate on
201
+ * `isApproved(approvals)` before `confirm()` flips the FSM. Single-tier
202
+ * flows skip the chain entirely.
203
+ */
204
+ approvals?: ApprovalChain | null;
205
+ /** Snapshot of the matrix policy id that produced this chain (host-managed). */
206
+ approvalPolicyId?: string | null;
207
+ /** Version of the matrix policy at submit time — frozen for audit. */
208
+ approvalPolicyVersion?: number | null;
195
209
  createdAt: Date;
196
210
  updatedAt: Date;
197
211
  }
@@ -211,6 +225,7 @@ declare class RedemptionRepository extends Repository<Redemption> {
211
225
  createMany(docs: Parameters<Repository<Redemption>['createMany']>[0], options?: Parameters<Repository<Redemption>['createMany']>[1]): Promise<Redemption[]>;
212
226
  /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
213
227
  private _dispatch;
228
+ private get _dispatchDeps();
214
229
  /**
215
230
  * Atomic compare-and-set status transition.
216
231
  * Returns null if the current status doesn't match `expectedStatus` (CAS mismatch).
@@ -353,8 +368,8 @@ interface LoyaltyRepositories {
353
368
  }
354
369
  interface CreateRepositoriesInput {
355
370
  models: LoyaltyModels;
356
- plugins?: RepositoryPlugins;
357
- tenant?: ResolvedTenant;
371
+ plugins?: RepositoryPlugins | undefined;
372
+ tenant?: ResolvedTenant | undefined;
358
373
  config: ResolvedConfig;
359
374
  events: EventTransport;
360
375
  outbox?: OutboxStore | undefined;
@@ -1,4 +1,4 @@
1
- import { o as LoyaltyContext } from "./index-Bo5YQ_fu.mjs";
1
+ import { o as LoyaltyContext } from "./index-D20Cn4KB.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,5 +1,6 @@
1
1
  import { a as RuleStatus, n as MemberStatus, o as RuleType, s as TransactionType } from "./index-DMK8a98J.mjs";
2
2
  import { OperationContext } from "@classytic/primitives/context";
3
+ import { ApprovalChain } from "@classytic/primitives/approval";
3
4
 
4
5
  //#region src/domain/entities/earning-rule.d.ts
5
6
  interface EarningConditions {
@@ -124,6 +125,19 @@ interface PointTransaction {
124
125
  expiredAt?: Date;
125
126
  metadata?: Record<string, unknown>;
126
127
  actorId: string;
128
+ /**
129
+ * Optional multi-step approval chain (P7 canonical — same shape as
130
+ * `@classytic/purchase` / `@classytic/order`). Manual `adjustment` /
131
+ * `bonus` transactions above a host-defined threshold gate on
132
+ * `isApproved(approvals)` before the journal entry is committed.
133
+ * Automatic transactions (earn from purchase, expiry sweeps) skip the
134
+ * chain entirely — single-tier flows leave this null.
135
+ */
136
+ approvals?: ApprovalChain | null;
137
+ /** Snapshot of the matrix policy id that produced this chain (host-managed). */
138
+ approvalPolicyId?: string | null;
139
+ /** Version of the matrix policy at submit time — frozen for audit. */
140
+ approvalPolicyVersion?: number | null;
127
141
  createdAt: Date;
128
142
  }
129
143
  //#endregion
@@ -203,6 +217,15 @@ interface LoyaltyContext extends OperationContext {
203
217
  organizationId?: string;
204
218
  /** Override program ID for multi-program setups. */
205
219
  programId?: string;
220
+ /**
221
+ * Cooperative cancellation for long-running cron loops (point expiry,
222
+ * reservation cleanup, tier evaluation, override sweeps, campaign award).
223
+ * Checked at each loop boundary via `throwIfAborted` — aborting between
224
+ * iterations never rolls back an already-committed transaction, and each
225
+ * per-item write is its own unit of work. Threaded to mongokit round-trips
226
+ * by hosts that pass it; mongokit (3.16+) honours it at every op boundary.
227
+ */
228
+ signal?: AbortSignal | undefined;
206
229
  /** Dynamic tenant key support — e.g. `{ companyId: '...' }` — when a host
207
230
  * configured a custom `contextKey`. */
208
231
  [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-Bo5YQ_fu.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-D20Cn4KB.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-xLLJ7jLy.mjs";
3
+ import { c as ResolvedTenant, n as LoyaltyConfig, r as LoyaltyModelName, s as ResolvedConfig } from "./config-BdSyAdnF.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-CnESLipu.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-CMSNfGos.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-BN96EDac.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 };