@classytic/loyalty 0.3.0 → 0.5.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
@@ -1,169 +1,231 @@
1
- # Changelog
2
-
3
- Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
- adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
-
107
- ## [0.2.0]
108
-
109
- Structural rewrite on top of the 0.1.0 shape. The public API is now a
110
- smaller, sharper surface organized around seven subpath exports
111
- (`/domain/enums`, `/models`, `/repositories`, `/events`, `/types`,
112
- `/notifications`, plus the root engine factory). Legacy barrel + service
113
- layers were removed; hosts wire business flows directly against the
114
- repositories and the engine factory.
115
-
116
- ### Changed
117
-
118
- - **Domain restructure.** Dropped the `src/domain/ports/` + `src/services/`
119
- layer in favor of thin repositories that expose exactly the operations
120
- hosts need — `earn`, `redeem`, `adjust`, `expire`, referral credit,
121
- tier recomputation. No more `earning.service` / `redemption.service` /
122
- `tier.service` / `referral.service` / `ledger.service` / `member.service`
123
- / `member-guard` indirections; the equivalent logic lives on the
124
- repositories where it's naturally atomic.
125
- - **Events subsystem simplified.** Removed `events/event-bus.ts` +
126
- `events/event-types.ts` (both are internal indirection that the engine
127
- didn't need). Event payloads are now colocated with the repository
128
- that emits them.
129
- - **Repositories updated to mongokit 3.11 + repo-core 0.2 conventions.**
130
- Every repo extends `Repository<T>` directly, uses the tenant plugin via
131
- `injectTenant`, and atomic state changes go through CAS guards.
132
- - **Peer deps** unchanged in spirit, tightened in range:
133
- - `@classytic/mongokit >= 3.11`
134
- - `@classytic/primitives >= 0.1`
135
- - `@classytic/repo-core >= 0.2`
136
- - `mongoose >= 9.4`, `zod >= 4`
137
- - **DevDeps:** `@classytic/primitives` devDep moved from `file:` to `>=0.1.0`
138
- (now that primitives ships on npm); all `@classytic/*` dev ranges
139
- standardized on `>=`.
140
-
141
- ### Removed
142
-
143
- - `src/services/*` — logic folded into repositories.
144
- - `src/domain/ports/*` barrels and per-port files — structural types
145
- now live with the repositories they describe.
146
- - Event-bus indirection — repositories emit via the engine's configured
147
- transport directly.
148
-
149
- ### Added
150
-
151
- - Subpath export `/notifications` — notification port + built-in
152
- lifecycle payloads (tier up/down, points expiring, referral credit).
153
- - Test restructure — 257 tests across unit + integration projects; the
154
- deleted legacy suites were collapsed into focused scenario files that
155
- exercise the new public API.
156
-
157
- ### Peer compatibility
158
-
159
- Anyone on 0.1.0 wiring against the old `*.service.ts` modules will need
160
- to migrate to the repository methods. Signatures are similar — earn /
161
- redeem / adjust remain the nouns — but the call sites move from
162
- `services.earning.earnPoints(...)` to `repositories.pointTransaction.earn(...)`
163
- and similar. No behavior changes in the ledger semantics.
164
-
165
- ## [0.1.0]
166
-
167
- Initial release. Loyalty points, tiers, earning rules, referrals, and
168
- redemption engine for MongoDB. Tenant-scoped, event-emitting, atomic
169
- point-ledger with CAS guards.
1
+ # Changelog
2
+
3
+ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.5.0] — unpublished (2026-07-15)
7
+
8
+ ### Changed — mongokit 3.22 adopted (peer floors: mongokit >=3.22.0, repo-core >=0.13.0, primitives >=0.11.0)
9
+
10
+ - **Points ledger fence enforced** — `PointTransaction` rows are the
11
+ balance audit trail; `appendOnlyPlugin` now refuses every mutation
12
+ path (405 `APPEND_ONLY_VIOLATION`). Previously nothing enforced it —
13
+ a stray `update()` could rewrite an earn/burn row and silently
14
+ desync the audit trail from member balances. The expiry sweeper's
15
+ `expiredAt` stamp is the one engine-owned lifecycle write and passes
16
+ the audited `bypassAppendOnly` escape.
17
+ - No FSM boilerplate existed (no assert+claim sites, no
18
+ read-modify-write history) — loyalty was already clean there; this
19
+ release is the fence + kit refresh (clean registry install, no
20
+ legacy flags).
21
+
22
+ ## [0.4.0] — addendum (2026-07-05)
23
+
24
+ ### Changed (behavioral)
25
+
26
+ - **§P8.1 relay-only session guard** — `UnmanagedSessionError` now throws
27
+ only when a host session arrives with NEITHER a `[PENDING_EVENTS]`
28
+ queue NOR an outbox. Session + outbox + no queue is legal
29
+ durable-relay-only mode: the row commits with the host transaction, the
30
+ relay delivers post-commit, the immediate transport publish is skipped.
31
+ (Previously the guard was stricter than spec and threw even with an
32
+ outbox wired.) Aligns loyalty with invoice / catalog / order /
33
+ purchase / transfer / promo / payrun.
34
+ - **Stack floors raised** — peers `@classytic/mongokit >=3.18.0`,
35
+ `@classytic/repo-core >=0.7.0`; full suite verified against those
36
+ versions.
37
+
38
+ ## [0.4.0] — 2026-07-03
39
+
40
+ ### Fixed
41
+
42
+ - **`evaluateAction` is now idempotent per recurrence key.** The order path
43
+ dedupes with `${orderId}:${ruleId}`, but the action path (signup /
44
+ referral / birthday / review) never set an `idempotencyKey` — a retried
45
+ sweep or double-fired event double-awarded points. When `referenceId` is
46
+ supplied, each rule award now carries `${referenceId}:${ruleId}`; callers
47
+ encode the recurrence period in `referenceId` (e.g.
48
+ `birthday:2026:<memberId>`) and the unique partial index on
49
+ `idempotencyKey` becomes the authoritative once-only guard.
50
+ - **Action rules honor `conditions` now.** `evaluateAction` skipped
51
+ `matchesConditions` entirely, silently ignoring admin-authored
52
+ `tiers` / `dateRange` / `dayOfWeek` on action-type rules ("only gold
53
+ members get birthday points" did nothing). Conditions now apply;
54
+ `minOrderAmount` is skipped for actions (no order to measure).
55
+ - **`allowNonTransactional` now actually works at runtime.** The flag
56
+ silenced the boot capability gate but `MongoUnitOfWork.withTransaction`
57
+ still opened a real transaction unconditionally — on standalone Mongo
58
+ every `earnPoints`/`redeem` unit failed with "does not support retryable
59
+ writes" despite the documented escape hatch. The unit of work now
60
+ delegates to mongokit's `withTransaction` with `allowFallback` so the
61
+ transactions-unsupported error re-runs the unit on a plain session
62
+ (partial-write risk accepted, exactly what the flag opts into).
63
+ - Day arithmetic (tier-downgrade grace expiry, referral rate-limit window)
64
+ now uses `addDays` from `@classytic/primitives/calendar` — exact 24h × n
65
+ durations instead of local `setDate`, which shifted with the deploy
66
+ machine's `TZ` env. Primitives peer bumped to `>=0.9.0`.
67
+
68
+ ## [0.3.0] — 2026-06-13
69
+
70
+ Stack upgrade to mongokit 3.16 / repo-core 0.6 / primitives 0.7.2 plus
71
+ verified correctness + PACKAGE_RULES compliance fixes. No new features.
72
+
73
+ ### Fixed
74
+
75
+ - **Stack actually resolves the new floor now (CRITICAL).** The committed
76
+ lockfile silently served `@classytic/mongokit@3.13.0` +
77
+ `@classytic/repo-core@0.4.0` despite the `^3.13.3` / `^0.4.2` pins, so the
78
+ whole test suite ran against the OLD stack. Clean reinstall + peer/dev bumps
79
+ pin the resolved tree to mongokit **3.16.0**, repo-core **0.6.0**, primitives
80
+ **0.7.2** (verified with `npm ls`).
81
+ - **§P8 outbox is now session-bound on the transactional verbs.** `earnPoints`,
82
+ `adjustPoints`, `reserve`, `confirm`, `release`, and `reverseConfirmed`
83
+ previously dispatched the event AFTER `withTransaction` committed, so
84
+ `outbox.save` ran OUTSIDE the unit of work — the business doc could commit
85
+ while the durable event row was lost (or vice-versa). Dispatch now runs
86
+ INSIDE the transaction (outbox row commits atomically with the write) and the
87
+ transport publish is deferred to a post-commit flush (`runLoyaltyUnit` +
88
+ `[PENDING_EVENTS]`), so in-process subscribers never see a state that may roll
89
+ back. A new integration test asserts the save is session-bound and that a
90
+ failing `outbox.save` rolls the `$inc` balance write back.
91
+ - **Redemption slot no longer permanently blocked after release.** The
92
+ `{ ownerType, ownerId }` unique index spanned ALL statuses, so a released /
93
+ reversed reservation E11000-blocked any retry on the same order (payment
94
+ retry, cart re-attempt) forever. It is now a **partial** unique index scoped
95
+ to active statuses (`reserved`, `confirmed`) — terminal states free the slot
96
+ (PACKAGE_RULES rule 32/38/39). Re-reserve-after-release and second-active-
97
+ reserve-blocked tests added.
98
+
99
+ ### Added
100
+
101
+ - **`UnmanagedSessionError` guard (§P8).** When a host passes `ctx.session`
102
+ (it owns the outer transaction) without attaching a `[PENDING_EVENTS]` queue,
103
+ dispatch throws BEFORE any side effect instead of publishing / saving outside
104
+ the host's unit of work (which would leak ghost events on rollback).
105
+ `PENDING_EVENTS` and `flushPendingLoyaltyEvents` are exported for hosts that
106
+ manage their own transaction.
107
+ - **Capability boot gate.** `createLoyaltyEngine()` asserts the backend declares
108
+ the `transactions` capability (repo-core 0.6 / mongokit 3.16+) at engine
109
+ creation — fail loud at boot rather than with a cryptic driver error on the
110
+ first transactional call. `allowNonTransactional: true` opts out for local-dev
111
+ on standalone Mongo. New `LoyaltyCapabilityError`.
112
+ - **Cooperative cancellation in cron loops.** `processExpirations`, `awardBatch`,
113
+ `cleanupExpired` (redemption + referral), `evaluateAll`, and
114
+ `sweepExpiredOverrides` check `ctx.signal` via `throwIfAborted` between items —
115
+ abortable batches; each per-item write is its own committed unit of work, so
116
+ aborting never rolls back already-processed rows. `LoyaltyContext` gained
117
+ `signal?: AbortSignal`.
118
+
119
+ ### Changed
120
+
121
+ - **Domain errors implement `HttpError`** from `@classytic/repo-core/errors`
122
+ (was a bespoke `httpStatus` shape). Clean break: `httpStatus` → `status`,
123
+ and codes are now hierarchical + lowercase (`loyalty.<entity>.<problem>`,
124
+ e.g. `loyalty.member.not_found`, `loyalty.points.insufficient`) so arc
125
+ serializes them to `ErrorContract` correctly. No deprecation alias.
126
+ - **Peer-dep ranges raised** — `@classytic/mongokit >=3.16.0`,
127
+ `@classytic/repo-core >=0.6.0` (`>=` style). devDeps bumped to match and
128
+ primitives devDep pinned to `^0.7.2`.
129
+ - **`exactOptionalPropertyTypes: true`** enabled in tsconfig (mandatory per
130
+ PACKAGE_RULES Build Hygiene / P10); fixed the three pre-existing optional-vs-
131
+ undefined violations it surfaced.
132
+ - **tsconfig no longer emits sourcemaps or declaration maps**
133
+ (`sourceMap: false`, `declarationMap: false`) — both leaked source. tsdown
134
+ was already correct; tsconfig now matches.
135
+
136
+ ## [0.2.3] — 2026-05-08
137
+
138
+ ### Fixed
139
+
140
+ - **§P8 dispatch — `outbox.save` failures now propagate.** [src/events/dispatch.ts](src/events/dispatch.ts)
141
+ previously caught `outbox.save` errors and swallowed them silently. Per
142
+ PACKAGE_RULES §P8 the outbox row is the durability anchor for the
143
+ transactional-outbox pattern: if the save fails, the caller's transaction
144
+ MUST roll back so the business doc and the event row land atomically.
145
+ Swallowing the failure meant a parent doc could commit while the event
146
+ vanished, with no relay row to recover from. Fix re-throws after logging.
147
+ `events.publish` failures continue to be swallowed (correctly, per §P8 —
148
+ the host's outbox relay re-publishes from the durable row), but now log
149
+ before swallowing so failures are observable.
150
+ - **`DispatchDeps.logger` field added.** Optional. Both arms of the
151
+ dispatch helper log through it when set, surfacing failures in the host's
152
+ observability stack without coupling the package to a specific logger.
153
+
154
+ ### Changed
155
+
156
+ - **Peer-dep ranges tightened** — `@classytic/mongokit` `>=3.13.3`
157
+ (was `>=3.13.0`) and `@classytic/repo-core` `>=0.4.2` (was `>=0.4.0`).
158
+ 3.13.3 preserves `errorLabels` on `MongoServerError` so concurrent
159
+ transactional writes (the `claim()`-based redemption + tier-eval paths)
160
+ retry correctly under contention; 0.4.2 adds the typed `FindAllOptions`
161
+ shape with `limit?` for callers that need a bounded non-paginated read.
162
+
163
+ ### Internal
164
+
165
+ - Replaced 4 `arr.find()!` non-null assertions in [src/index.ts](src/index.ts)
166
+ + scenario tests with explicit narrowing — TS now proves what the runtime
167
+ shape already guaranteed.
168
+
169
+ ## [0.2.0]
170
+
171
+ Structural rewrite on top of the 0.1.0 shape. The public API is now a
172
+ smaller, sharper surface organized around seven subpath exports
173
+ (`/domain/enums`, `/models`, `/repositories`, `/events`, `/types`,
174
+ `/notifications`, plus the root engine factory). Legacy barrel + service
175
+ layers were removed; hosts wire business flows directly against the
176
+ repositories and the engine factory.
177
+
178
+ ### Changed
179
+
180
+ - **Domain restructure.** Dropped the `src/domain/ports/` + `src/services/`
181
+ layer in favor of thin repositories that expose exactly the operations
182
+ hosts need — `earn`, `redeem`, `adjust`, `expire`, referral credit,
183
+ tier recomputation. No more `earning.service` / `redemption.service` /
184
+ `tier.service` / `referral.service` / `ledger.service` / `member.service`
185
+ / `member-guard` indirections; the equivalent logic lives on the
186
+ repositories where it's naturally atomic.
187
+ - **Events subsystem simplified.** Removed `events/event-bus.ts` +
188
+ `events/event-types.ts` (both are internal indirection that the engine
189
+ didn't need). Event payloads are now colocated with the repository
190
+ that emits them.
191
+ - **Repositories updated to mongokit 3.11 + repo-core 0.2 conventions.**
192
+ Every repo extends `Repository<T>` directly, uses the tenant plugin via
193
+ `injectTenant`, and atomic state changes go through CAS guards.
194
+ - **Peer deps** unchanged in spirit, tightened in range:
195
+ - `@classytic/mongokit >= 3.11`
196
+ - `@classytic/primitives >= 0.1`
197
+ - `@classytic/repo-core >= 0.2`
198
+ - `mongoose >= 9.4`, `zod >= 4`
199
+ - **DevDeps:** `@classytic/primitives` devDep moved from `file:` to `>=0.1.0`
200
+ (now that primitives ships on npm); all `@classytic/*` dev ranges
201
+ standardized on `>=`.
202
+
203
+ ### Removed
204
+
205
+ - `src/services/*` — logic folded into repositories.
206
+ - `src/domain/ports/*` barrels and per-port files — structural types
207
+ now live with the repositories they describe.
208
+ - Event-bus indirection — repositories emit via the engine's configured
209
+ transport directly.
210
+
211
+ ### Added
212
+
213
+ - Subpath export `/notifications` — notification port + built-in
214
+ lifecycle payloads (tier up/down, points expiring, referral credit).
215
+ - Test restructure — 257 tests across unit + integration projects; the
216
+ deleted legacy suites were collapsed into focused scenario files that
217
+ exercise the new public API.
218
+
219
+ ### Peer compatibility
220
+
221
+ Anyone on 0.1.0 wiring against the old `*.service.ts` modules will need
222
+ to migrate to the repository methods. Signatures are similar — earn /
223
+ redeem / adjust remain the nouns — but the call sites move from
224
+ `services.earning.earnPoints(...)` to `repositories.pointTransaction.earn(...)`
225
+ and similar. No behavior changes in the ledger semantics.
226
+
227
+ ## [0.1.0]
228
+
229
+ Initial release. Loyalty points, tiers, earning rules, referrals, and
230
+ redemption engine for MongoDB. Tenant-scoped, event-emitting, atomic
231
+ point-ledger with CAS guards.
@@ -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;
@@ -140,6 +151,8 @@ interface ResolvedConfig {
140
151
  enabled: boolean;
141
152
  defaultDays?: number;
142
153
  };
154
+ /** Resolved IANA zone for wall-clock rule evaluation; `undefined` = UTC. */
155
+ timezone?: string;
143
156
  tenant: ResolvedTenant;
144
157
  }
145
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-CnESLipu.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,4 +1,4 @@
1
- import { o as LoyaltyContext } from "./index-D20Cn4KB.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-D20Cn4KB.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-BdSyAdnF.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
 
@@ -159,7 +159,19 @@ declare class EarningRuleRepository extends Repository<EarningRule> {
159
159
  action: string;
160
160
  referenceId?: string;
161
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
+ */
162
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;
163
175
  private calculateOrderPoints;
164
176
  private calculateCategoryPoints;
165
177
  private applyRounding;
@@ -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;
package/dist/index.d.mts CHANGED
@@ -1,9 +1,9 @@
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";
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-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";
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-BN96EDac.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-Dngaj21_.mjs";
7
7
  import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
8
8
  import { OutboxStore } from "@classytic/primitives/outbox";
9
9
  import { HttpError } from "@classytic/repo-core/errors";
package/dist/index.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { n as DEFAULT_TENANT_FIELD, r as DEFAULT_TENANT_TYPE } from "./constants-DkQYkOK1.mjs";
2
- import { C as TenantIsolationError, E as ValidationError, S as SelfReferralError, T as UnmanagedSessionError, _ as RedemptionExpiredError, a as PointTransactionRepository, b as ReferralNotFoundError, c as flushPendingLoyaltyEvents, d as DuplicateReferralError, f as InsufficientPointsError, g as MemberNotFoundError, h as MemberInactiveError, i as RedemptionRepository, l as EarningRuleRepository, m as MemberAlreadyEnrolledError, n as TierDefinitionRepository, o as MemberRepository, p as LoyaltyCapabilityError, r as ReferralRepository, s as PENDING_EVENTS, t as createRepositories, u as CircularReferralError, v as RedemptionNotFoundError, w as TierNotFoundError, x as RuleNotFoundError, y as ReferralLimitExceededError } from "./repositories-C_p8asCn.mjs";
2
+ import { C as TenantIsolationError, E as ValidationError, S as SelfReferralError, T as UnmanagedSessionError, _ as RedemptionExpiredError, a as PointTransactionRepository, b as ReferralNotFoundError, c as flushPendingLoyaltyEvents, d as DuplicateReferralError, f as InsufficientPointsError, g as MemberNotFoundError, h as MemberInactiveError, i as RedemptionRepository, l as EarningRuleRepository, m as MemberAlreadyEnrolledError, n as TierDefinitionRepository, o as MemberRepository, p as LoyaltyCapabilityError, r as ReferralRepository, s as PENDING_EVENTS, t as createRepositories, u as CircularReferralError, v as RedemptionNotFoundError, w as TierNotFoundError, x as RuleNotFoundError, y as ReferralLimitExceededError } from "./repositories-CjWfUlrA.mjs";
3
3
  import { E as InProcessLoyaltyBus, T as loyaltyEventDefinitions, _ as ReferralRecorded, a as MemberSuspended, b as TierDowngraded, c as PointsEarned, d as PointsRedeemed, f as RedemptionConfirmed, g as ReferralApproved, i as MemberReactivated, l as PointsExpired, m as RedemptionReserved, n as MemberDeactivated, p as RedemptionReleased, r as MemberEnrolled, s as PointsAdjusted, u as PointsExpiringSoon, v as ReferralRejected, w as TierUpgraded, x as TierGraceStarted, y as ReferralRewarded } from "./events-DN5_p6T0.mjs";
4
4
  import { n as createModels } from "./models-Ddh62lgO.mjs";
5
5
  import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-C38JA3cU.mjs";
6
+ import { withTransaction } from "@classytic/mongokit";
7
+ import { isValidTimeZone } from "@classytic/primitives/timezone";
6
8
  //#region src/index.ts
7
9
  function resolveConfig(config) {
8
10
  let tenant;
@@ -48,36 +50,28 @@ function resolveConfig(config) {
48
50
  enabled: config.expiry?.enabled ?? false,
49
51
  ...config.expiry?.defaultDays !== void 0 ? { defaultDays: config.expiry.defaultDays } : {}
50
52
  },
53
+ ...config.timezone !== void 0 ? { timezone: config.timezone } : {},
51
54
  tenant
52
55
  };
56
+ if (config.timezone !== void 0 && !isValidTimeZone(config.timezone)) throw new ValidationError(`Invalid IANA timezone: '${config.timezone}'`);
53
57
  if (resolved.conversionRate <= 0) throw new ValidationError("conversionRate must be positive");
54
58
  if (resolved.referral.codeLength < 4) throw new ValidationError("referral codeLength must be >= 4");
55
59
  if (resolved.redemption.maxRedeemPercent < 0 || resolved.redemption.maxRedeemPercent > 100) throw new ValidationError("maxRedeemPercent must be between 0 and 100");
56
60
  return resolved;
57
61
  }
58
62
  var MongoUnitOfWork = class {
59
- constructor(connection) {
63
+ constructor(connection, allowFallback) {
60
64
  this.connection = connection;
65
+ this.allowFallback = allowFallback;
61
66
  }
62
67
  async withTransaction(cb) {
63
- const session = await this.connection.startSession();
64
- try {
65
- const slot = { assigned: false };
66
- await session.withTransaction(async () => {
67
- slot.value = await cb(session);
68
- slot.assigned = true;
69
- });
70
- if (!slot.assigned) throw new Error("withTransaction: callback did not assign a result");
71
- return slot.value;
72
- } finally {
73
- await session.endSession();
74
- }
68
+ return withTransaction(this.connection, cb, this.allowFallback ? { allowFallback: true } : {});
75
69
  }
76
70
  };
77
71
  function createLoyaltyEngine(config) {
78
72
  const resolvedConfig = resolveConfig(config);
79
73
  const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex, config.collectionPrefix, config.forceRecreate);
80
- const unitOfWork = new MongoUnitOfWork(config.mongoose);
74
+ const unitOfWork = new MongoUnitOfWork(config.mongoose, config.allowNonTransactional ?? false);
81
75
  const events = config.eventTransport ?? new InProcessLoyaltyBus();
82
76
  const repositories = createRepositories({
83
77
  models,
@@ -1,4 +1,4 @@
1
- import { c as ResolvedTenant, i as ModelName, r as LoyaltyModelName, t as IndexDefinition } from "../config-BdSyAdnF.mjs";
1
+ import { c as ResolvedTenant, i as ModelName, r as LoyaltyModelName, t as IndexDefinition } from "../config-Dtun1duw.mjs";
2
2
  import * as _$mongoose from "mongoose";
3
3
  import { Connection, Model } from "mongoose";
4
4
 
@@ -86,9 +86,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
86
86
  _id: _$mongoose.Types.ObjectId;
87
87
  } & {
88
88
  __v: number;
89
- }, "id"> & {
89
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
90
90
  id: string;
91
- }, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
91
+ }>, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
92
92
  timestamps: true;
93
93
  }, {
94
94
  programId: string;
@@ -167,9 +167,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
167
167
  _id: _$mongoose.Types.ObjectId;
168
168
  } & {
169
169
  __v: number;
170
- }, "id"> & {
170
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
171
171
  id: string;
172
- }, unknown, {
172
+ }>, unknown, {
173
173
  programId: string;
174
174
  externalId: string;
175
175
  externalType: string;
@@ -298,9 +298,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
298
298
  _id: _$mongoose.Types.ObjectId;
299
299
  } & {
300
300
  __v: number;
301
- }, "id"> & {
301
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
302
302
  id: string;
303
- }, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
303
+ }>, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
304
304
  timestamps: {
305
305
  createdAt: true;
306
306
  updatedAt: false;
@@ -373,9 +373,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
373
373
  _id: _$mongoose.Types.ObjectId;
374
374
  } & {
375
375
  __v: number;
376
- }, "id"> & {
376
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
377
377
  id: string;
378
- }, unknown, {
378
+ }>, unknown, {
379
379
  programId: string;
380
380
  type: "earn" | "redeem" | "adjust" | "expire" | "transfer" | "referral_bonus";
381
381
  description: string;
@@ -519,9 +519,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
519
519
  _id: _$mongoose.Types.ObjectId;
520
520
  } & {
521
521
  __v: number;
522
- }, "id"> & {
522
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
523
523
  id: string;
524
- }, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
524
+ }>, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
525
525
  timestamps: true;
526
526
  }, {
527
527
  programId: string;
@@ -618,9 +618,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
618
618
  _id: _$mongoose.Types.ObjectId;
619
619
  } & {
620
620
  __v: number;
621
- }, "id"> & {
621
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
622
622
  id: string;
623
- }, unknown, {
623
+ }>, unknown, {
624
624
  programId: string;
625
625
  type: "order" | "action" | "category" | "tier_bonus";
626
626
  name: string;
@@ -764,9 +764,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
764
764
  _id: _$mongoose.Types.ObjectId;
765
765
  } & {
766
766
  __v: number;
767
- }, "id"> & {
767
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
768
768
  id: string;
769
- }, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
769
+ }>, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
770
770
  timestamps: true;
771
771
  }, {
772
772
  programId: string;
@@ -839,9 +839,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
839
839
  _id: _$mongoose.Types.ObjectId;
840
840
  } & {
841
841
  __v: number;
842
- }, "id"> & {
842
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
843
843
  id: string;
844
- }, unknown, {
844
+ }>, unknown, {
845
845
  programId: string;
846
846
  name: string;
847
847
  rank: number;
@@ -972,9 +972,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
972
972
  _id: _$mongoose.Types.ObjectId;
973
973
  } & {
974
974
  __v: number;
975
- }, "id"> & {
975
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
976
976
  id: string;
977
- }, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
977
+ }>, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
978
978
  timestamps: true;
979
979
  }, {
980
980
  programId: string;
@@ -1050,9 +1050,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
1050
1050
  _id: _$mongoose.Types.ObjectId;
1051
1051
  } & {
1052
1052
  __v: number;
1053
- }, "id"> & {
1053
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
1054
1054
  id: string;
1055
- }, unknown, {
1055
+ }>, unknown, {
1056
1056
  programId: string;
1057
1057
  status: "expired" | "reserved" | "confirmed" | "released" | "reversed";
1058
1058
  memberId: _$mongoose.Types.ObjectId;
@@ -1164,9 +1164,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
1164
1164
  _id: _$mongoose.Types.ObjectId;
1165
1165
  } & {
1166
1166
  __v: number;
1167
- }, "id"> & {
1167
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
1168
1168
  id: string;
1169
- }, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
1169
+ }>, _$mongoose.Schema<any, Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
1170
1170
  timestamps: true;
1171
1171
  }, {
1172
1172
  programId: string;
@@ -1221,9 +1221,9 @@ declare function createModels(connection: Connection, tenant: ResolvedTenant, in
1221
1221
  _id: _$mongoose.Types.ObjectId;
1222
1222
  } & {
1223
1223
  __v: number;
1224
- }, "id"> & {
1224
+ }, "id"> & _$mongoose.HydratedDocumentOverrides<{
1225
1225
  id: string;
1226
- }, unknown, {
1226
+ }>, unknown, {
1227
1227
  programId: string;
1228
1228
  status: "expired" | "pending" | "approved" | "rejected";
1229
1229
  referralCode: string;
@@ -1,2 +1,2 @@
1
- import { a as ReferralRepository, d as MemberRepository, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, r as createRepositories, s as RedemptionRepository, t as CreateRepositoriesInput, u as PointTransactionRepository } from "../index-BN96EDac.mjs";
1
+ import { a as ReferralRepository, d as MemberRepository, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, r as createRepositories, s as RedemptionRepository, t as CreateRepositoriesInput, u as PointTransactionRepository } from "../index-Dngaj21_.mjs";
2
2
  export { CreateRepositoriesInput, EarningRuleRepository, LoyaltyRepositories, MemberRepository, PointTransactionRepository, RedemptionRepository, ReferralRepository, TierDefinitionRepository, createRepositories };
@@ -1,2 +1,2 @@
1
- import { a as PointTransactionRepository, i as RedemptionRepository, l as EarningRuleRepository, n as TierDefinitionRepository, o as MemberRepository, r as ReferralRepository, t as createRepositories } from "../repositories-C_p8asCn.mjs";
1
+ import { a as PointTransactionRepository, i as RedemptionRepository, l as EarningRuleRepository, n as TierDefinitionRepository, o as MemberRepository, r as ReferralRepository, t as createRepositories } from "../repositories-CjWfUlrA.mjs";
2
2
  export { EarningRuleRepository, MemberRepository, PointTransactionRepository, RedemptionRepository, ReferralRepository, TierDefinitionRepository, createRepositories };
@@ -1,5 +1,7 @@
1
1
  import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-C38JA3cU.mjs";
2
- import { Repository, multiTenantPlugin } from "@classytic/mongokit";
2
+ import { Repository, appendOnlyPlugin, multiTenantPlugin } from "@classytic/mongokit";
3
+ import { localTimeParts } from "@classytic/primitives/timezone";
4
+ import { addDays, isoWeekday } from "@classytic/primitives/calendar";
3
5
  import { randomBytes } from "node:crypto";
4
6
  import { throwIfAborted } from "@classytic/repo-core/repository";
5
7
  //#region src/domain/errors/base.ts
@@ -333,6 +335,7 @@ var EarningRuleRepository = class extends Repository {
333
335
  const transactions = [];
334
336
  let totalPoints = 0;
335
337
  for (const rule of actionRules) {
338
+ if (!this.matchesConditions(rule, member)) continue;
336
339
  const points = rule.reward.fixedPoints ?? 0;
337
340
  if (points <= 0) continue;
338
341
  const tx = await this.txRepo.earnPoints({
@@ -341,7 +344,10 @@ var EarningRuleRepository = class extends Repository {
341
344
  description: `Action: ${input.action} (${rule.name})`,
342
345
  referenceType: "action",
343
346
  earningRuleId: rule._id,
344
- ...input.referenceId !== void 0 ? { referenceId: input.referenceId } : {}
347
+ ...input.referenceId !== void 0 ? {
348
+ referenceId: input.referenceId,
349
+ idempotencyKey: `${input.referenceId}:${rule._id}`
350
+ } : {}
345
351
  }, ctx);
346
352
  totalPoints += points;
347
353
  breakdown.push({
@@ -357,10 +363,14 @@ var EarningRuleRepository = class extends Repository {
357
363
  transactions
358
364
  };
359
365
  }
366
+ /**
367
+ * `orderTotal` is optional — action-type evaluations have no order, so
368
+ * `minOrderAmount` only applies when a total is supplied (order paths).
369
+ */
360
370
  matchesConditions(rule, member, orderTotal) {
361
371
  const { conditions } = rule;
362
372
  const now = /* @__PURE__ */ new Date();
363
- if (conditions.minOrderAmount && orderTotal < conditions.minOrderAmount) return false;
373
+ if (conditions.minOrderAmount && orderTotal !== void 0 && orderTotal < conditions.minOrderAmount) return false;
364
374
  if (conditions.tiers && conditions.tiers.length > 0) {
365
375
  const memberTier = member.tierOverride ?? member.tier;
366
376
  if (!memberTier || !conditions.tiers.includes(memberTier)) return false;
@@ -370,10 +380,20 @@ var EarningRuleRepository = class extends Repository {
370
380
  if (conditions.dateRange.end && now > conditions.dateRange.end) return false;
371
381
  }
372
382
  if (conditions.dayOfWeek && conditions.dayOfWeek.length > 0) {
373
- if (!conditions.dayOfWeek.includes(now.getDay())) return false;
383
+ if (!conditions.dayOfWeek.includes(this.weekday(now))) return false;
374
384
  }
375
385
  return true;
376
386
  }
387
+ /**
388
+ * The wall-clock weekday of `instant` in JS `getDay()` convention
389
+ * (0=Sun … 6=Sat — what `EarningConditions.dayOfWeek` is authored in),
390
+ * evaluated in the engine's configured `timezone` (DST-exact) or UTC when
391
+ * unset. NEVER server-local `Date.getDay()`, which shifts with the deploy
392
+ * machine's `TZ` env (PACKAGE_RULES P12).
393
+ */
394
+ weekday(instant) {
395
+ return (this.config.timezone ? localTimeParts(instant, this.config.timezone).isoWeekday : isoWeekday(instant)) % 7;
396
+ }
377
397
  calculateOrderPoints(rule, orderTotal) {
378
398
  const { reward } = rule;
379
399
  let points = 0;
@@ -410,7 +430,6 @@ var EarningRuleRepository = class extends Repository {
410
430
  const PENDING_EVENTS = Symbol.for("@classytic/loyalty:pendingEvents");
411
431
  async function dispatchLoyaltyEvent(deps, event, ctx) {
412
432
  const pending = ctx[PENDING_EVENTS];
413
- if (ctx.session && !pending) throw new UnmanagedSessionError();
414
433
  if (deps.outbox) try {
415
434
  const saveOptions = {};
416
435
  if (ctx.session !== void 0) saveOptions.session = ctx.session;
@@ -419,11 +438,12 @@ async function dispatchLoyaltyEvent(deps, event, ctx) {
419
438
  deps.logger?.error(`[loyalty] outbox.save failed for ${event.type}:`, err);
420
439
  throw err;
421
440
  }
441
+ else if (ctx.session && !pending) throw new UnmanagedSessionError();
422
442
  if (pending) {
423
443
  pending.push(event);
424
444
  return;
425
445
  }
426
- if (deps.events) try {
446
+ if (deps.events && !ctx.session) try {
427
447
  await deps.events.publish(event);
428
448
  } catch (err) {
429
449
  deps.logger?.error(`[loyalty] events.publish failed for ${event.type}:`, err);
@@ -689,7 +709,7 @@ var PointTransactionRepository = class extends Repository {
689
709
  unitOfWork;
690
710
  config;
691
711
  constructor(model, plugins = []) {
692
- super(model, plugins);
712
+ super(model, [...plugins, appendOnlyPlugin()]);
693
713
  }
694
714
  /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
695
715
  async _dispatch(event, ctx) {
@@ -981,7 +1001,8 @@ var PointTransactionRepository = class extends Repository {
981
1001
  });
982
1002
  await this.update(tx._id, { expiredAt: now }, {
983
1003
  session,
984
- ...tc
1004
+ ...tc,
1005
+ bypassAppendOnly: true
985
1006
  });
986
1007
  await this.memberRepo.updateBalance(tx.memberId, {
987
1008
  current: -tx.points,
@@ -1524,8 +1545,7 @@ var ReferralRepository = class extends Repository {
1524
1545
  ...tc
1525
1546
  });
1526
1547
  if (reverseReferral && String(reverseReferral.referrerId) === String(referee._id)) throw new CircularReferralError();
1527
- const periodStart = /* @__PURE__ */ new Date();
1528
- periodStart.setDate(periodStart.getDate() - this.config.referral.periodDays);
1548
+ const periodStart = addDays(/* @__PURE__ */ new Date(), -this.config.referral.periodDays);
1529
1549
  if (await this.count({
1530
1550
  referrerId: referrer._id,
1531
1551
  createdAt: { $gte: periodStart }
@@ -1956,8 +1976,7 @@ var TierDefinitionRepository = class extends Repository {
1956
1976
  action: "unchanged"
1957
1977
  };
1958
1978
  if (downgrade.gracePeriodDays > 0 && !member.tierExpiresAt) {
1959
- const graceExpiresAt = /* @__PURE__ */ new Date();
1960
- graceExpiresAt.setDate(graceExpiresAt.getDate() + downgrade.gracePeriodDays);
1979
+ const graceExpiresAt = addDays(/* @__PURE__ */ new Date(), downgrade.gracePeriodDays);
1961
1980
  await this.memberRepo.update(memberId, { tierExpiresAt: graceExpiresAt }, {
1962
1981
  lean: true,
1963
1982
  ...tc
@@ -1,3 +1,3 @@
1
- import { _ as TierEvaluationBatch, a as EnrollInput, c as UpdateTierInput, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, m as PaginatedResult, n as CreateEarningRuleInput, o as LoyaltyContext, p as ListQuery, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, u as EarningBreakdown } from "../index-D20Cn4KB.mjs";
2
- import { a as PluginType, c as ResolvedTenant, i as ModelName, l as TenantConfig, n as LoyaltyConfig, o as RepositoryPlugins, s as ResolvedConfig, t as IndexDefinition } from "../config-BdSyAdnF.mjs";
1
+ import { _ as TierEvaluationBatch, a as EnrollInput, c as UpdateTierInput, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, m as PaginatedResult, n as CreateEarningRuleInput, o as LoyaltyContext, p as ListQuery, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, u as EarningBreakdown } from "../index-YSsi4e_G.mjs";
2
+ import { a as PluginType, c as ResolvedTenant, i as ModelName, l as TenantConfig, n as LoyaltyConfig, o as RepositoryPlugins, s as ResolvedConfig, t as IndexDefinition } from "../config-Dtun1duw.mjs";
3
3
  export { AdjustPointsInput, BalanceSummary, CreateEarningRuleInput, CreateTierInput, EarnPointsInput, EarningBreakdown, EarningResult, EnrollInput, ExpirationResult, IndexDefinition, ListQuery, LoyaltyConfig, LoyaltyContext, ModelName, PaginatedResult, PluginType, RedemptionValidation, RepositoryPlugins, ResolvedConfig, ResolvedTenant, TenantConfig, TierEvaluation, TierEvaluationBatch, UpdateEarningRuleInput, UpdateTierInput };
package/package.json CHANGED
@@ -1,116 +1,116 @@
1
- {
2
- "name": "@classytic/loyalty",
3
- "version": "0.3.0",
4
- "description": "Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB",
5
- "author": "Classytic",
6
- "homepage": "https://www.npmjs.com/package/@classytic/loyalty",
7
- "bugs": {
8
- "url": "https://github.com/classytic/loyalty/issues"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/classytic/loyalty.git"
13
- },
14
- "publishConfig": {
15
- "access": "public"
16
- },
17
- "type": "module",
18
- "sideEffects": false,
19
- "engines": {
20
- "node": ">=22"
21
- },
22
- "exports": {
23
- ".": {
24
- "types": "./dist/index.d.mts",
25
- "default": "./dist/index.mjs"
26
- },
27
- "./domain/enums": {
28
- "types": "./dist/domain/enums/index.d.mts",
29
- "default": "./dist/domain/enums/index.mjs"
30
- },
31
- "./models": {
32
- "types": "./dist/models/index.d.mts",
33
- "default": "./dist/models/index.mjs"
34
- },
35
- "./repositories": {
36
- "types": "./dist/repositories/index.d.mts",
37
- "default": "./dist/repositories/index.mjs"
38
- },
39
- "./events": {
40
- "types": "./dist/events/index.d.mts",
41
- "default": "./dist/events/index.mjs"
42
- },
43
- "./types": {
44
- "types": "./dist/types/index.d.mts",
45
- "default": "./dist/types/index.mjs"
46
- },
47
- "./notifications": {
48
- "types": "./dist/notifications/index.d.mts",
49
- "default": "./dist/notifications/index.mjs"
50
- }
51
- },
52
- "main": "./dist/index.mjs",
53
- "types": "./dist/index.d.mts",
54
- "files": [
55
- "dist",
56
- "README.md",
57
- "CHANGELOG.md",
58
- "LICENSE"
59
- ],
60
- "scripts": {
61
- "build": "tsdown",
62
- "dev": "tsdown --watch",
63
- "test": "vitest run --project unit --project integration",
64
- "test:unit": "vitest run --project unit",
65
- "test:integration": "vitest run --project integration",
66
- "test:scenarios": "vitest run --project scenarios",
67
- "test:all": "vitest run",
68
- "test:watch": "vitest --project unit --project integration",
69
- "test:coverage": "vitest run --coverage",
70
- "typecheck": "tsc --noEmit",
71
- "lint": "biome check src/ tests/",
72
- "lint:fix": "biome check --write src/ tests/",
73
- "format": "biome format --write src/ tests/",
74
- "check": "biome check --write --unsafe src/ tests/",
75
- "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
76
- "push": "classytic-push",
77
- "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
78
- "release": "npm run push -- main && npm run release:tag && npm publish"
79
- },
80
- "peerDependencies": {
81
- "@classytic/mongokit": ">=3.16.0",
82
- "@classytic/primitives": ">=0.5.0",
83
- "@classytic/repo-core": ">=0.6.0",
84
- "mongoose": ">=9.4.1",
85
- "zod": ">=4.0.0"
86
- },
87
- "devDependencies": {
88
- "@biomejs/biome": "^2.4.9",
89
- "@classytic/dev-tools": "^0.2.0",
90
- "@classytic/mongokit": "^3.16.0",
91
- "@classytic/primitives": "^0.7.2",
92
- "@classytic/repo-core": "^0.6.0",
93
- "@types/node": "^25.5.0",
94
- "@vitest/coverage-v8": "^3.2.4",
95
- "knip": "^6.3.0",
96
- "mongodb-memory-server": "^10.2.3",
97
- "mongoose": "^9.4.1",
98
- "tsdown": "^0.21.5",
99
- "typescript": "^5.7.0",
100
- "vitest": "^3.0.0",
101
- "zod": "^4.3.6"
102
- },
103
- "keywords": [
104
- "loyalty",
105
- "points",
106
- "rewards",
107
- "tiers",
108
- "referral",
109
- "membership",
110
- "earning-rules",
111
- "redemption",
112
- "mongodb",
113
- "mongoose"
114
- ],
115
- "license": "MIT"
116
- }
1
+ {
2
+ "name": "@classytic/loyalty",
3
+ "version": "0.5.0",
4
+ "description": "Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB",
5
+ "author": "Classytic",
6
+ "homepage": "https://www.npmjs.com/package/@classytic/loyalty",
7
+ "bugs": {
8
+ "url": "https://github.com/classytic/loyalty/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/classytic/loyalty.git"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "node": ">=22"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.mts",
25
+ "default": "./dist/index.mjs"
26
+ },
27
+ "./domain/enums": {
28
+ "types": "./dist/domain/enums/index.d.mts",
29
+ "default": "./dist/domain/enums/index.mjs"
30
+ },
31
+ "./models": {
32
+ "types": "./dist/models/index.d.mts",
33
+ "default": "./dist/models/index.mjs"
34
+ },
35
+ "./repositories": {
36
+ "types": "./dist/repositories/index.d.mts",
37
+ "default": "./dist/repositories/index.mjs"
38
+ },
39
+ "./events": {
40
+ "types": "./dist/events/index.d.mts",
41
+ "default": "./dist/events/index.mjs"
42
+ },
43
+ "./types": {
44
+ "types": "./dist/types/index.d.mts",
45
+ "default": "./dist/types/index.mjs"
46
+ },
47
+ "./notifications": {
48
+ "types": "./dist/notifications/index.d.mts",
49
+ "default": "./dist/notifications/index.mjs"
50
+ }
51
+ },
52
+ "main": "./dist/index.mjs",
53
+ "types": "./dist/index.d.mts",
54
+ "files": [
55
+ "dist",
56
+ "README.md",
57
+ "CHANGELOG.md",
58
+ "LICENSE"
59
+ ],
60
+ "scripts": {
61
+ "build": "tsdown",
62
+ "dev": "tsdown --watch",
63
+ "test": "vitest run --project unit --project integration",
64
+ "test:unit": "vitest run --project unit",
65
+ "test:integration": "vitest run --project integration",
66
+ "test:scenarios": "vitest run --project scenarios",
67
+ "test:all": "vitest run",
68
+ "test:watch": "vitest --project unit --project integration",
69
+ "test:coverage": "vitest run --coverage",
70
+ "typecheck": "tsc --noEmit",
71
+ "lint": "biome check src/ tests/",
72
+ "lint:fix": "biome check --write src/ tests/",
73
+ "format": "biome format --write src/ tests/",
74
+ "check": "biome check --write --unsafe src/ tests/",
75
+ "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
76
+ "push": "classytic-push",
77
+ "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
78
+ "release": "npm run push -- main && npm run release:tag && npm publish"
79
+ },
80
+ "peerDependencies": {
81
+ "@classytic/mongokit": ">=3.22.0",
82
+ "@classytic/primitives": ">=0.11.0",
83
+ "@classytic/repo-core": ">=0.13.0",
84
+ "mongoose": ">=9.4.1",
85
+ "zod": ">=4.0.0"
86
+ },
87
+ "devDependencies": {
88
+ "@biomejs/biome": "^2.4.9",
89
+ "@classytic/dev-tools": "^0.2.0",
90
+ "@classytic/mongokit": "^3.22.0",
91
+ "@classytic/primitives": "^0.11.0",
92
+ "@classytic/repo-core": "^0.13.0",
93
+ "@types/node": "^25.5.0",
94
+ "@vitest/coverage-v8": "^3.2.4",
95
+ "knip": "^6.3.0",
96
+ "mongodb-memory-server": "^10.2.3",
97
+ "mongoose": "^9.4.1",
98
+ "tsdown": "^0.21.5",
99
+ "typescript": "^5.7.0",
100
+ "vitest": "^3.0.0",
101
+ "zod": "^4.3.6"
102
+ },
103
+ "keywords": [
104
+ "loyalty",
105
+ "points",
106
+ "rewards",
107
+ "tiers",
108
+ "referral",
109
+ "membership",
110
+ "earning-rules",
111
+ "redemption",
112
+ "mongodb",
113
+ "mongoose"
114
+ ],
115
+ "license": "MIT"
116
+ }