@classytic/loyalty 0.5.0 → 0.7.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,231 +1,367 @@
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.
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.7.0
7
+
8
+ ### Fixed — a `UnitOfWork` yielding a non-transactional session ran the body anyway
9
+
10
+ `runLoyaltyUnit` asserted `inTransaction()` on the HOST-session branch and not on
11
+ the `unitOfWork.withTransaction` branch, so only one of the two ways a body gets
12
+ a session was guarded. The comment above that check already stated the rule —
13
+ "guard before the body runs, not at the first emit: a verb can mutate before it
14
+ dispatches" — and it was applied to one branch.
15
+
16
+ It matters because the balance verbs compensate by THROWING to roll an `$inc`
17
+ back. Without a real transaction that buys nothing: `redeem` decrements a
18
+ member's points, sees the balance go negative, throws — and the points stay
19
+ gone while the caller is told the redemption failed. A lying `withTransaction`
20
+ was previously caught at the first event DISPATCH, by which time the write had
21
+ landed.
22
+
23
+ 3 cases in `tests/unit/unit-of-work-transaction-guard.test.ts`, asserting the
24
+ body did NOT run — the rejection alone would pass even if it had mutated first.
25
+ Falsified: removing the guard fails 2 of 3.
26
+
27
+ ### Fixed — the transaction guard must not override `allowNonTransactional`
28
+
29
+ The fix above asserted `inTransaction()` unconditionally, which also made it
30
+ wrong: `allowNonTransactional` is an explicit host opt-in (loyalty already
31
+ warns about it at boot) for a standalone-Mongo deployment where
32
+ `withTransaction({ allowFallback })` runs the callback with no transaction ON
33
+ PURPOSE. The guard turned that documented degradation into a hard failure, so
34
+ a standalone-Mongo host lost point earn / redemption / reversal entirely — a
35
+ general rule silently defeating a specific decision, the inverse of what the
36
+ guard exists to catch.
37
+
38
+ `UnitOfWork` now states whether it may degrade, and the consent travels on the
39
+ internal context because the same guard also runs from `dispatchLoyaltyEvent`,
40
+ which never sees the unit of work. Absent still means no: an implementation
41
+ that yields a bare session without saying so is still refused, and a
42
+ host-supplied `ctx.session` has no opt-in and always asserts. Falsified both
43
+ ways in `unit-of-work-transaction-guard.test.ts`.
44
+
45
+ ### Changed — peer floors raised to current
46
+
47
+ - `@classytic/mongokit` `>=3.33.0` → **`>=3.38.1`**
48
+ - `@classytic/primitives` `>=0.23.0` → **`>=0.27.0`**
49
+ - `@classytic/repo-core` `>=0.24.0` → **`>=0.27.1`**
50
+ - `@classytic/validation` `>=0.2.0` → **`>=0.5.0`**
51
+
52
+ ## [0.6.0] — 2026-08-17
53
+
54
+ ### Added
55
+ - `SessionNotInTransactionError` — thrown when a host passes `ctx.session` that has no active transaction; a bare session provides no atomicity and would leave the points mutation standing if the outbox save fails
56
+ - `ResolvedTenant` type alias (deprecated shim for `ResolvedTenantConfig` from `@classytic/repo-core/tenant`)
57
+
58
+ ### Changed
59
+ - `TenantConfig` widened to `RepoTenantConfig | boolean` (was `false | PrimitivesTenantConfig`); hosts rename `field` → `tenantField` and `type` → `fieldType`
60
+ - `in-process-bus` consolidated onto primitives `InProcessEventBus`; event catalog, dispatch, and repository types aligned
61
+ - Schemas and repositories updated: earning-rule, redemption, tier-definition, point-transaction, referral
62
+ - `src/index.ts` exports reorganised for tree-shaking
63
+
64
+ ## 1.0.0 - 2026-07-29
65
+
66
+ ### Changed
67
+ - **License:** relicensed from MIT to the **Classytic Source-Available License** (Community & Commercial). Evaluation/development use remains free; production use now requires a commercial license from Classytic LLC. See `LICENSE`.
68
+ - Major bump marks the license change; versions published before 1.0.0 remain under their original MIT terms.
69
+
70
+ ## 0.7.1 - 2026-07-22 (unpublished)
71
+
72
+ ### Changed
73
+ - Internal: `InProcess*Bus` is now a thin subclass of `@classytic/primitives/event-infra`'s shared `InProcessEventBus` (dedup / error-isolation / close semantics unchanged); `MemoryOutboxStore` re-exported from the shared runtime where applicable. No public API, event-name, or wire-envelope change. Requires `@classytic/primitives >=0.13.0`.
74
+
75
+ ## [0.7.0] — unpublished (2026-07-22)
76
+
77
+ ### Added — engine lifecycle: `destroy()`
78
+
79
+ - **`LoyaltyEngine.destroy()`** — closes the self-created
80
+ `InProcessLoyaltyBus` (skipped when the host injected `eventTransport`).
81
+ - `destroy()` follows the fleet ownsBus convention (manufacturing/purchase
82
+ pattern): only what the engine itself created is closed; a host-injected
83
+ transport is the host's to close. Idempotent — a second call is a no-op.
84
+ Mongoose models/connection are untouched (host-owned).
85
+ - Lifecycle coverage in `tests/integration/engine-lifecycle.test.ts`.
86
+
87
+ ## [0.6.0] — 2026-07-20
88
+
89
+ ### Changed — money contract: integer MINOR units + explicit rates, no major-unit ambiguity (BREAKING)
90
+
91
+ Every monetary number in loyalty was an ambiguous major-currency float
92
+ (unenforced), and both earning rates carried an implicit "per 1 MAJOR unit"
93
+ basis. Aligned with `@classytic/flow@0.6.0`'s TOTAL-vs-RATE standardization
94
+ (see flow `wiki/conventions.md` §15). Loyalty stays ambient-single-currency —
95
+ NO currency field on any doc or API; the host owns currency.
96
+
97
+ - **All money AMOUNTS are now integers in the host's minor unit** (paisa for
98
+ BDT), enforced by `mongooseMinorUnits()` from the new
99
+ **`@classytic/validation` dependency (`>=0.2.0`)**:
100
+ - `EarningConditions.minOrderAmount`
101
+ - `LoyaltyConfig.redemption.minOrderAmount` (`DEFAULT_MIN_ORDER_AMOUNT`
102
+ stays `0` — zero is unit-invariant)
103
+ - `Redemption.discountAmount`, `Redemption.initialBalance`,
104
+ `Redemption.remainingBalance`
105
+ - `TierQualification.minLifetimeSpend` (declared-only field — tier checks
106
+ still use lifetime POINTS; re-documented + validated, no math change)
107
+ - API inputs: `evaluateOrder({ orderTotal, items[].amount })` and
108
+ `validate`/`reserve({ orderTotal })`. `validateOrderTotal` now requires a
109
+ non-negative **safe integer** ("orderTotal must be a non-negative safe
110
+ integer (minor units)") — floats and majors fail loud.
111
+ - **`EarningReward.amountPerPoint`** is now MINOR UNITS PER POINT (integer
112
+ ≥ 1, bespoke rate validator). "1 point per ৳100" = `amountPerPoint: 10000`.
113
+ - **`EarningReward.pointsPerAmount` requires a new sibling `rateBasis`**
114
+ (integer minor units ≥ 1): `points = pointsPerAmount × (orderTotal /
115
+ rateBasis)`, rounded ONCE per the rule's `roundingMode`. The old implicit
116
+ "points per 1 MAJOR unit" basis was the bug being killed. Pairing enforced
117
+ at the schema level (pre-validate) AND by a runtime `ValidationError` guard
118
+ in the calculators (legacy/hand-written docs fail loud).
119
+ - **`conversionRate` (points per 1 MAJOR unit) is REPLACED by
120
+ `pointValueMinor`** (integer minor units ONE point redeems for, ≥ 1) on
121
+ `LoyaltyConfig.program`, `ResolvedConfig`, and the `Redemption` doc snapshot.
122
+ `conversionRate: 10` (10 pts per ৳1) ≡ `pointValueMinor: 10` (1 pt = 10
123
+ paisa). `DEFAULT_CONVERSION_RATE` → `DEFAULT_POINT_VALUE_MINOR` (still `10`
124
+ — same economic default). Boot validation: "pointValueMinor must be a
125
+ positive integer (minor units per point)".
126
+ - **Redemption math is now EXACT integer arithmetic** (no more floor-division
127
+ remainder): `requestedDiscountMinor = pointsToRedeem × pointValueMinor`;
128
+ percent cap `maxDiscountMinor = floor(orderTotal × maxRedeemPercent / 100)`
129
+ unchanged in shape; when capped, `actualPoints = floor(cappedDiscount /
130
+ pointValueMinor)` and the discount is **recomputed as `actualPoints ×
131
+ pointValueMinor`** — the granted discount ALWAYS equals exactly the points
132
+ charged (no orphaned remainder paisa; documented invariant).
133
+ - **Event contract**: `RedemptionPayload.discountAmount` is integer minor
134
+ units; the Zod catalog schema now asserts `.int().nonnegative()` (Zod event
135
+ schemas are not tsc-checked against the TS interfaces — flow's lesson).
136
+
137
+ **Migration**: clean cutover, no dual-read shim. Re-express persisted majors
138
+ as minors (× minor-unit factor), rename `conversionRate` → `pointValueMinor`
139
+ (config + redemption docs), and add `rateBasis` to every rule that sets
140
+ `pointsPerAmount` (old implicit basis "per ৳1" = `rateBasis: 100`).
141
+
142
+ ## [0.5.0] — unpublished (2026-07-15)
143
+
144
+ ### Changed — mongokit 3.22 adopted (peer floors: mongokit >=3.22.0, repo-core >=0.13.0, primitives >=0.11.0)
145
+
146
+ - **Points ledger fence enforced** — `PointTransaction` rows are the
147
+ balance audit trail; `appendOnlyPlugin` now refuses every mutation
148
+ path (405 `APPEND_ONLY_VIOLATION`). Previously nothing enforced it —
149
+ a stray `update()` could rewrite an earn/burn row and silently
150
+ desync the audit trail from member balances. The expiry sweeper's
151
+ `expiredAt` stamp is the one engine-owned lifecycle write and passes
152
+ the audited `bypassAppendOnly` escape.
153
+ - No FSM boilerplate existed (no assert+claim sites, no
154
+ read-modify-write history) — loyalty was already clean there; this
155
+ release is the fence + kit refresh (clean registry install, no
156
+ legacy flags).
157
+
158
+ ## [0.4.0] — addendum (2026-07-05)
159
+
160
+ ### Changed (behavioral)
161
+
162
+ - **§P8.1 relay-only session guard** — `UnmanagedSessionError` now throws
163
+ only when a host session arrives with NEITHER a `[PENDING_EVENTS]`
164
+ queue NOR an outbox. Session + outbox + no queue is legal
165
+ durable-relay-only mode: the row commits with the host transaction, the
166
+ relay delivers post-commit, the immediate transport publish is skipped.
167
+ (Previously the guard was stricter than spec and threw even with an
168
+ outbox wired.) Aligns loyalty with invoice / catalog / order /
169
+ purchase / transfer / promo / payrun.
170
+ - **Stack floors raised** — peers `@classytic/mongokit >=3.18.0`,
171
+ `@classytic/repo-core >=0.7.0`; full suite verified against those
172
+ versions.
173
+
174
+ ## [0.4.0] — 2026-07-03
175
+
176
+ ### Fixed
177
+
178
+ - **`evaluateAction` is now idempotent per recurrence key.** The order path
179
+ dedupes with `${orderId}:${ruleId}`, but the action path (signup /
180
+ referral / birthday / review) never set an `idempotencyKey` — a retried
181
+ sweep or double-fired event double-awarded points. When `referenceId` is
182
+ supplied, each rule award now carries `${referenceId}:${ruleId}`; callers
183
+ encode the recurrence period in `referenceId` (e.g.
184
+ `birthday:2026:<memberId>`) and the unique partial index on
185
+ `idempotencyKey` becomes the authoritative once-only guard.
186
+ - **Action rules honor `conditions` now.** `evaluateAction` skipped
187
+ `matchesConditions` entirely, silently ignoring admin-authored
188
+ `tiers` / `dateRange` / `dayOfWeek` on action-type rules ("only gold
189
+ members get birthday points" did nothing). Conditions now apply;
190
+ `minOrderAmount` is skipped for actions (no order to measure).
191
+ - **`allowNonTransactional` now actually works at runtime.** The flag
192
+ silenced the boot capability gate but `MongoUnitOfWork.withTransaction`
193
+ still opened a real transaction unconditionally — on standalone Mongo
194
+ every `earnPoints`/`redeem` unit failed with "does not support retryable
195
+ writes" despite the documented escape hatch. The unit of work now
196
+ delegates to mongokit's `withTransaction` with `allowFallback` so the
197
+ transactions-unsupported error re-runs the unit on a plain session
198
+ (partial-write risk accepted, exactly what the flag opts into).
199
+ - Day arithmetic (tier-downgrade grace expiry, referral rate-limit window)
200
+ now uses `addDays` from `@classytic/primitives/calendar` — exact 24h × n
201
+ durations instead of local `setDate`, which shifted with the deploy
202
+ machine's `TZ` env. Primitives peer bumped to `>=0.9.0`.
203
+
204
+ ## [0.3.0] — 2026-06-13
205
+
206
+ Stack upgrade to mongokit 3.16 / repo-core 0.6 / primitives 0.7.2 plus
207
+ verified correctness + PACKAGE_RULES compliance fixes. No new features.
208
+
209
+ ### Fixed
210
+
211
+ - **Stack actually resolves the new floor now (CRITICAL).** The committed
212
+ lockfile silently served `@classytic/mongokit@3.13.0` +
213
+ `@classytic/repo-core@0.4.0` despite the `^3.13.3` / `^0.4.2` pins, so the
214
+ whole test suite ran against the OLD stack. Clean reinstall + peer/dev bumps
215
+ pin the resolved tree to mongokit **3.16.0**, repo-core **0.6.0**, primitives
216
+ **0.7.2** (verified with `npm ls`).
217
+ - **§P8 outbox is now session-bound on the transactional verbs.** `earnPoints`,
218
+ `adjustPoints`, `reserve`, `confirm`, `release`, and `reverseConfirmed`
219
+ previously dispatched the event AFTER `withTransaction` committed, so
220
+ `outbox.save` ran OUTSIDE the unit of work — the business doc could commit
221
+ while the durable event row was lost (or vice-versa). Dispatch now runs
222
+ INSIDE the transaction (outbox row commits atomically with the write) and the
223
+ transport publish is deferred to a post-commit flush (`runLoyaltyUnit` +
224
+ `[PENDING_EVENTS]`), so in-process subscribers never see a state that may roll
225
+ back. A new integration test asserts the save is session-bound and that a
226
+ failing `outbox.save` rolls the `$inc` balance write back.
227
+ - **Redemption slot no longer permanently blocked after release.** The
228
+ `{ ownerType, ownerId }` unique index spanned ALL statuses, so a released /
229
+ reversed reservation E11000-blocked any retry on the same order (payment
230
+ retry, cart re-attempt) forever. It is now a **partial** unique index scoped
231
+ to active statuses (`reserved`, `confirmed`) — terminal states free the slot
232
+ (PACKAGE_RULES rule 32/38/39). Re-reserve-after-release and second-active-
233
+ reserve-blocked tests added.
234
+
235
+ ### Added
236
+
237
+ - **`UnmanagedSessionError` guard (§P8).** When a host passes `ctx.session`
238
+ (it owns the outer transaction) without attaching a `[PENDING_EVENTS]` queue,
239
+ dispatch throws BEFORE any side effect instead of publishing / saving outside
240
+ the host's unit of work (which would leak ghost events on rollback).
241
+ `PENDING_EVENTS` and `flushPendingLoyaltyEvents` are exported for hosts that
242
+ manage their own transaction.
243
+ - **Capability boot gate.** `createLoyaltyEngine()` asserts the backend declares
244
+ the `transactions` capability (repo-core 0.6 / mongokit 3.16+) at engine
245
+ creation — fail loud at boot rather than with a cryptic driver error on the
246
+ first transactional call. `allowNonTransactional: true` opts out for local-dev
247
+ on standalone Mongo. New `LoyaltyCapabilityError`.
248
+ - **Cooperative cancellation in cron loops.** `processExpirations`, `awardBatch`,
249
+ `cleanupExpired` (redemption + referral), `evaluateAll`, and
250
+ `sweepExpiredOverrides` check `ctx.signal` via `throwIfAborted` between items —
251
+ abortable batches; each per-item write is its own committed unit of work, so
252
+ aborting never rolls back already-processed rows. `LoyaltyContext` gained
253
+ `signal?: AbortSignal`.
254
+
255
+ ### Changed
256
+
257
+ - **Domain errors implement `HttpError`** from `@classytic/repo-core/errors`
258
+ (was a bespoke `httpStatus` shape). Clean break: `httpStatus` → `status`,
259
+ and codes are now hierarchical + lowercase (`loyalty.<entity>.<problem>`,
260
+ e.g. `loyalty.member.not_found`, `loyalty.points.insufficient`) so arc
261
+ serializes them to `ErrorContract` correctly. No deprecation alias.
262
+ - **Peer-dep ranges raised** — `@classytic/mongokit >=3.16.0`,
263
+ `@classytic/repo-core >=0.6.0` (`>=` style). devDeps bumped to match and
264
+ primitives devDep pinned to `^0.7.2`.
265
+ - **`exactOptionalPropertyTypes: true`** enabled in tsconfig (mandatory per
266
+ PACKAGE_RULES Build Hygiene / P10); fixed the three pre-existing optional-vs-
267
+ undefined violations it surfaced.
268
+ - **tsconfig no longer emits sourcemaps or declaration maps**
269
+ (`sourceMap: false`, `declarationMap: false`) — both leaked source. tsdown
270
+ was already correct; tsconfig now matches.
271
+
272
+ ## [0.2.3] — 2026-05-08
273
+
274
+ ### Fixed
275
+
276
+ - **§P8 dispatch — `outbox.save` failures now propagate.** [src/events/dispatch.ts](src/events/dispatch.ts)
277
+ previously caught `outbox.save` errors and swallowed them silently. Per
278
+ PACKAGE_RULES §P8 the outbox row is the durability anchor for the
279
+ transactional-outbox pattern: if the save fails, the caller's transaction
280
+ MUST roll back so the business doc and the event row land atomically.
281
+ Swallowing the failure meant a parent doc could commit while the event
282
+ vanished, with no relay row to recover from. Fix re-throws after logging.
283
+ `events.publish` failures continue to be swallowed (correctly, per §P8 —
284
+ the host's outbox relay re-publishes from the durable row), but now log
285
+ before swallowing so failures are observable.
286
+ - **`DispatchDeps.logger` field added.** Optional. Both arms of the
287
+ dispatch helper log through it when set, surfacing failures in the host's
288
+ observability stack without coupling the package to a specific logger.
289
+
290
+ ### Changed
291
+
292
+ - **Peer-dep ranges tightened** — `@classytic/mongokit` `>=3.13.3`
293
+ (was `>=3.13.0`) and `@classytic/repo-core` `>=0.4.2` (was `>=0.4.0`).
294
+ 3.13.3 preserves `errorLabels` on `MongoServerError` so concurrent
295
+ transactional writes (the `claim()`-based redemption + tier-eval paths)
296
+ retry correctly under contention; 0.4.2 adds the typed `FindAllOptions`
297
+ shape with `limit?` for callers that need a bounded non-paginated read.
298
+
299
+ ### Internal
300
+
301
+ - Replaced 4 `arr.find()!` non-null assertions in [src/index.ts](src/index.ts)
302
+ + scenario tests with explicit narrowing — TS now proves what the runtime
303
+ shape already guaranteed.
304
+
305
+ ## [0.2.0]
306
+
307
+ Structural rewrite on top of the 0.1.0 shape. The public API is now a
308
+ smaller, sharper surface organized around seven subpath exports
309
+ (`/domain/enums`, `/models`, `/repositories`, `/events`, `/types`,
310
+ `/notifications`, plus the root engine factory). Legacy barrel + service
311
+ layers were removed; hosts wire business flows directly against the
312
+ repositories and the engine factory.
313
+
314
+ ### Changed
315
+
316
+ - **Domain restructure.** Dropped the `src/domain/ports/` + `src/services/`
317
+ layer in favor of thin repositories that expose exactly the operations
318
+ hosts need — `earn`, `redeem`, `adjust`, `expire`, referral credit,
319
+ tier recomputation. No more `earning.service` / `redemption.service` /
320
+ `tier.service` / `referral.service` / `ledger.service` / `member.service`
321
+ / `member-guard` indirections; the equivalent logic lives on the
322
+ repositories where it's naturally atomic.
323
+ - **Events subsystem simplified.** Removed `events/event-bus.ts` +
324
+ `events/event-types.ts` (both are internal indirection that the engine
325
+ didn't need). Event payloads are now colocated with the repository
326
+ that emits them.
327
+ - **Repositories updated to mongokit 3.11 + repo-core 0.2 conventions.**
328
+ Every repo extends `Repository<T>` directly, uses the tenant plugin via
329
+ `injectTenant`, and atomic state changes go through CAS guards.
330
+ - **Peer deps** unchanged in spirit, tightened in range:
331
+ - `@classytic/mongokit >= 3.11`
332
+ - `@classytic/primitives >= 0.1`
333
+ - `@classytic/repo-core >= 0.2`
334
+ - `mongoose >= 9.4`, `zod >= 4`
335
+ - **DevDeps:** `@classytic/primitives` devDep moved from `file:` to `>=0.1.0`
336
+ (now that primitives ships on npm); all `@classytic/*` dev ranges
337
+ standardized on `>=`.
338
+
339
+ ### Removed
340
+
341
+ - `src/services/*` — logic folded into repositories.
342
+ - `src/domain/ports/*` barrels and per-port files — structural types
343
+ now live with the repositories they describe.
344
+ - Event-bus indirection — repositories emit via the engine's configured
345
+ transport directly.
346
+
347
+ ### Added
348
+
349
+ - Subpath export `/notifications` — notification port + built-in
350
+ lifecycle payloads (tier up/down, points expiring, referral credit).
351
+ - Test restructure — 257 tests across unit + integration projects; the
352
+ deleted legacy suites were collapsed into focused scenario files that
353
+ exercise the new public API.
354
+
355
+ ### Peer compatibility
356
+
357
+ Anyone on 0.1.0 wiring against the old `*.service.ts` modules will need
358
+ to migrate to the repository methods. Signatures are similar — earn /
359
+ redeem / adjust remain the nouns — but the call sites move from
360
+ `services.earning.earnPoints(...)` to `repositories.pointTransaction.earn(...)`
361
+ and similar. No behavior changes in the ledger semantics.
362
+
363
+ ## [0.1.0]
364
+
365
+ Initial release. Loyalty points, tiers, earning rules, referrals, and
366
+ redemption engine for MongoDB. Tenant-scoped, event-emitting, atomic
367
+ point-ledger with CAS guards.