@classytic/loyalty 0.6.0 → 0.10.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/dist/constants-DfrENxFG.mjs +75 -0
  3. package/dist/domain/earn-base.d.mts +35 -0
  4. package/dist/domain/earn-base.mjs +37 -0
  5. package/dist/domain/enums/index.d.mts +2 -2
  6. package/dist/domain/enums/index.mjs +1 -2
  7. package/dist/domain/rule-schedule.d.mts +24 -0
  8. package/dist/domain/rule-schedule.mjs +12 -0
  9. package/dist/events/index.d.mts +2 -2
  10. package/dist/events/index.mjs +3 -3
  11. package/dist/{events-BLjg3uLA.mjs → events-DhZolW61.mjs} +104 -4
  12. package/dist/{index-CR2k7jQ8.d.mts → index-BvyzKU7R.d.mts} +294 -34
  13. package/dist/{index-D6GvuvmF.d.mts → index-CorB0JL6.d.mts} +259 -113
  14. package/dist/index-DRrEhE0r.d.mts +604 -0
  15. package/dist/{index-YUFamhDk.d.mts → index-Db5Gb7x_.d.mts} +187 -39
  16. package/dist/index-tjMMmPzU.d.mts +36 -0
  17. package/dist/index.d.mts +121 -30
  18. package/dist/index.mjs +63 -10
  19. package/dist/{loyalty-events-DXirolM_.mjs → loyalty-events-j29B66YW.mjs} +20 -13
  20. package/dist/models/index.d.mts +2 -2
  21. package/dist/models/index.mjs +1 -2
  22. package/dist/{models-weWqS5Bc.mjs → models-BEGLduz1.mjs} +276 -7
  23. package/dist/notifications/index.d.mts +3 -4
  24. package/dist/repositories/index.d.mts +2 -2
  25. package/dist/repositories/index.mjs +1 -2
  26. package/dist/{repositories-B676z4bE.mjs → repositories-ER68JmUo.mjs} +1178 -102
  27. package/dist/types/index.d.mts +2 -3
  28. package/package.json +39 -22
  29. package/dist/config-CtkGj3xZ.d.mts +0 -178
  30. package/dist/constants-DOPBh6kt.mjs +0 -48
  31. package/dist/index-Cp_sXNpG.d.mts +0 -349
  32. package/dist/index-D_Oe3aDk.d.mts +0 -19
package/CHANGELOG.md CHANGED
@@ -3,6 +3,186 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.10.0 — 2026-10-18
7
+
8
+ > 0.8.0 and 0.9.0 were never published: their changes (below) ship in this release.
9
+
10
+ ### Changed — peer floors
11
+
12
+ `@classytic/mongokit` `>=3.41.0`, `@classytic/primitives` `>=0.32.0`, `@classytic/repo-core` `>=0.31.0`, `@classytic/validation` `>=0.6.0`, `mongoose` `>=9.11.0`.
13
+
14
+ ### Added — stamp cards ("buy 9, the 10th is free")
15
+ - Three models (`LoyaltyStampProgram`, `LoyaltyStampEntry`, `LoyaltyStampAward`) and three
16
+ repositories: `stampProgram` (CRUD), `stamps` (`recordOrder`, `settle`, `reverseOrder`,
17
+ `progress`), `stampAward` (reads).
18
+ - A program counts `order`, `visit` (one per business day in `timezone`), `item` (quantity) or
19
+ `spend` (`perMinor`), optionally narrowed by category slugs, products, branches and a window;
20
+ stamps may expire (`stampValidityDays`); awards repeat until `maxRewardsPerMember`.
21
+ - Every guard is an index or a CAS: one entry per (program, member, order), one visit per day,
22
+ one award per sequence, each entry's `consumed` count claimed in the award's unit of work.
23
+ - Rewards: points (earned with source `campaign` — a grant with no single sale behind it, so
24
+ `loyalty.points.earned` keeps its enum) or a voucher through the new REQUIRED-when-used
25
+ `LoyaltyRuntime.rewardIssuer` port, idempotent on `awardKey`. A voucher award is claimed
26
+ `pending` and issued after commit; `settle` resumes a pending one.
27
+ - `reverseOrder` removes an undone order's unconsumed stamps; stamps already in an award are kept
28
+ and reported (`retainedInAwards`).
29
+ - Refused at write: an empty allow-list, `spend` without `perMinor`, a window that ends before it
30
+ starts, and a voucher reward on an engine bound without `rewardIssuer`.
31
+ `stampProgramInputSchema` / `stampProgramPatchSchema` are the strict wire bodies.
32
+ - `StampProgram`, `StampEntry` and `StampAward` type their stored ids as `Types.ObjectId` (what a
33
+ read returns); the progress, record and reversal results and event payloads stay strings.
34
+ - Events: `loyalty.stamp.earned`, `loyalty.stamp.reversed`, `loyalty.stamp.award_issued`.
35
+
36
+ ### Added — `item` stamps on lines sold by measure
37
+ `StampOrderLine.quantityStep` (optional; absent = 1). A line whose step is not a whole number (2.35 kg
38
+ of rice on a 0.001 kg step) earns ONE `item` stamp, never `floor(kg)` or the gram count; whole-unit
39
+ lines still count units. `recordOrder` refuses a fractional quantity on a line that declares no
40
+ step (it used to be floored) and a non-positive step. Pinned by
41
+ `tests/unit/stamp-fractional-quantity.test.ts`.
42
+
43
+ ## 0.8.0
44
+
45
+ ### Fixed — one expiry sweep expires every due lot
46
+
47
+ `processExpirations` read ONE page of 100 due lots per call, and the hourly schedule calls it once,
48
+ so a burst of due lots (a year-end lifetime) expired at 2,400 a day — members kept spending points
49
+ already past their date. It now pages until no lot due by the call's `now` remains, in
50
+ `(expiresAt, _id)` order, over a new partial index `expiry_sweep` that skips expired history and
51
+ non-expiring rows (203 → 3 documents examined). Pinned by `tests/scenarios/point-expiry.scenario.test.ts`.
52
+
53
+ ### ⚠ Changed — string-key partial indexes are `{ $gte: '' }`, so lookups by key use them
54
+
55
+ `{ $type: 'string' }` is never implied by an equality query, so lookups by point-transaction
56
+ `idempotencyKey`, member `cardId` and `referralCode` were collection scans. `$gte: ''` admits the
57
+ same rows. Renamed `idempotencyKey_str`, `cardId_str`, `referralCode_str`.
58
+ **Deploy:** build, then `db:retire-type-string-indexes -- --apply`.
59
+
60
+ ### Changed (breaking) — every points fact carries what a ledger needs to book it
61
+ - `earnPoints` REQUIRES `source: 'order' | 'action' | 'referral' | 'campaign'` (`EARN_SOURCES`),
62
+ refused at runtime when unknown. Only `order` has a sale behind it; a consumer may defer part of
63
+ that sale's price, and treat every other source as a grant. Stamped on the row and on
64
+ `loyalty.points.earned`. The kernel's own callers set it (rules → `order` / `action`,
65
+ referrals → `referral`, `awardBatch` → `campaign`).
66
+ - `pointValueMinor` (what ONE point was worth at that moment) is stamped on earn, adjust,
67
+ reverse and expire rows and on their events; `loyalty.redemption.reversed` carries the
68
+ redemption's own rate. The RATE, not a product — a reversal has claimed/collected/shortfall
69
+ quantities and a consumer multiplies whichever it books.
70
+ - New `loyalty.points.lot_expired`, one per expired lot, dispatched INSIDE the lot's unit of
71
+ work — a crash can no longer leave a lot stamped expired with no event. The aggregate
72
+ `loyalty.points.expired` summary is unchanged.
73
+ - `loyalty.redemption.reversed.transactionId` is omitted when nothing was credited (was `''`,
74
+ which collides as a key).
75
+
76
+ ### Added — `redemption.outstandingLiability(ctx)`
77
+ What the program owes: spendable balances plus points held by open reservations, at the current
78
+ point value (`LoyaltyLiabilitySnapshot`). The figure a ledger's liability reconciles against.
79
+
80
+ ### Fixed — events carry string ids, as their catalog declares
81
+ `createEvent` converts ObjectIds in the payload to strings. An in-process subscriber received
82
+ ObjectIds while the outbox delivered strings, so a consumer validating against the published
83
+ schema refused every live delivery. Pinned by a test that parses every emitted event with its
84
+ own definition.
85
+
86
+ ### Changed — members are placed in a tier from the start
87
+ `enroll` assigns the entry tier (the highest one 0 lifetime points qualifies for) unless one is
88
+ given — was `null` until a sweep. `evaluateMember(id, ctx, { upgradeOnly })` promotes but never
89
+ demotes (the earn path); `qualifiedTierName(programId, lifetime)` answers placement.
90
+
91
+ ### Added — `earnBase`, the one definition of what an order earns on
92
+ `@classytic/loyalty/domain/earn-base` (zero imports): earning lines at the price charged,
93
+ minus their pro-rata share of every order discount (promo, manual, points redeemed).
94
+ Shipping, fee lines and tax added on top never earn; tax inside a shelf price is part of that
95
+ price. Returns `{ orderTotal, items }` that sum exactly (largest remainder), so an `order` rule
96
+ and a `category` rule read one base. Matches Smile.io / LoyaltyLion / Square / Odoo.
97
+
98
+ ### Added — undo loyalty for a refunded or canceled purchase
99
+
100
+ - `pointTransaction.reverseEarned({ referenceType, referenceId, fraction, reason }, ctx)`
101
+ claws back `floor(earned × fraction)` of what a reference earned, minus what
102
+ earlier reversals claimed. Converges by state: replays and concurrent callers
103
+ reverse nothing extra (the idempotency key is derived from the target).
104
+ Points already spent are returned as `shortfall`, never driven below zero;
105
+ `lifetime` drops by the full claim.
106
+ - `redemption.undoForOwner(ownerIds, ctx, { reason })` reverses confirmed and
107
+ releases reserved redemptions held against any of the owner's identities.
108
+ - `redemption.confirmForOwner(ownerIds, ctx)` confirms holds still `reserved`
109
+ against an order that exists — the net for a checkout whose inline confirm
110
+ failed. `confirm(id, ctx, { ownerExists: true })` skips the TTL refusal for it.
111
+ - **Behaviour change — tier `benefits.pointsMultiplier` is applied.** It was stored
112
+ and never read, so a Gold ×2 member earned like ×1. `evaluateOrder` now adds
113
+ `floor(rulePoints × (m − 1))` for m > 1 as its own `earn` row
114
+ (`Tier bonus: <tier> ×m`, idempotency `<order>:tier-multiplier`), using the
115
+ override tier when set. Programs with multipliers configured will award more
116
+ from this release on. `reverseEarned` reverses it with the rule points.
117
+ - **Behaviour change — points expire when the program says so.** `expiry` was
118
+ resolved and read by nothing, so every earn had no `expiresAt` and the sweep
119
+ never expired anything. `earnPoints` now stamps `now + expiry.defaultDays`
120
+ when the caller sets none. `expiry.enabled` without a whole `defaultDays >= 1`
121
+ is refused at build.
122
+ - **Live program settings.** `LoyaltyRuntime.programSettings()` supplies the
123
+ operator-owned settings (point value, redemption limits, expiry) PER OPERATION
124
+ over the shape's values; one rule set (`assertProgram`) validates boot and live
125
+ values alike, and an invalid live value fails the operation. A hold snapshots
126
+ the rate it was priced at.
127
+ - **Behaviour change — redemption refuses what it used to wave through:** a 0%
128
+ cap ("switched off") and a cap that leaves fewer points than the minimum now
129
+ return `valid: false` with a reason; before they were a zero- or sub-minimum
130
+ "success" that `reserve` recorded.
131
+ - Earning rules honour their schedule: `startsAt`/`endsAt` (what the admin form
132
+ writes) AND `conditions.dateRange`. Before, only `dateRange` was read, so an
133
+ ended rule kept earning. `isRuleInSchedule` — also at the light subpath
134
+ `@classytic/loyalty/domain/rule-schedule` — is the one predicate for earn and preview.
135
+ - `LoyaltyRedemptionPort` / `LoyaltyRedemption` / `LoyaltyReserveInput` — the
136
+ checkout contract, owned here so till and web pipelines `import type` one shape.
137
+ - Ledger type `reverse` (`TRANSACTION_TYPES` now has 7 values) and event
138
+ `loyalty.points.reversed` (`PointsReversed` in the catalog).
139
+
140
+ ## 0.7.0
141
+
142
+ ### Fixed — a `UnitOfWork` yielding a non-transactional session ran the body anyway
143
+
144
+ `runLoyaltyUnit` asserted `inTransaction()` on the HOST-session branch and not on
145
+ the `unitOfWork.withTransaction` branch, so only one of the two ways a body gets
146
+ a session was guarded. The comment above that check already stated the rule —
147
+ "guard before the body runs, not at the first emit: a verb can mutate before it
148
+ dispatches" — and it was applied to one branch.
149
+
150
+ It matters because the balance verbs compensate by THROWING to roll an `$inc`
151
+ back. Without a real transaction that buys nothing: `redeem` decrements a
152
+ member's points, sees the balance go negative, throws — and the points stay
153
+ gone while the caller is told the redemption failed. A lying `withTransaction`
154
+ was previously caught at the first event DISPATCH, by which time the write had
155
+ landed.
156
+
157
+ 3 cases in `tests/unit/unit-of-work-transaction-guard.test.ts`, asserting the
158
+ body did NOT run — the rejection alone would pass even if it had mutated first.
159
+ Falsified: removing the guard fails 2 of 3.
160
+
161
+ ### Fixed — the transaction guard must not override `allowNonTransactional`
162
+
163
+ The fix above asserted `inTransaction()` unconditionally, which also made it
164
+ wrong: `allowNonTransactional` is an explicit host opt-in (loyalty already
165
+ warns about it at boot) for a standalone-Mongo deployment where
166
+ `withTransaction({ allowFallback })` runs the callback with no transaction ON
167
+ PURPOSE. The guard turned that documented degradation into a hard failure, so
168
+ a standalone-Mongo host lost point earn / redemption / reversal entirely — a
169
+ general rule silently defeating a specific decision, the inverse of what the
170
+ guard exists to catch.
171
+
172
+ `UnitOfWork` now states whether it may degrade, and the consent travels on the
173
+ internal context because the same guard also runs from `dispatchLoyaltyEvent`,
174
+ which never sees the unit of work. Absent still means no: an implementation
175
+ that yields a bare session without saying so is still refused, and a
176
+ host-supplied `ctx.session` has no opt-in and always asserts. Falsified both
177
+ ways in `unit-of-work-transaction-guard.test.ts`.
178
+
179
+ ### Changed — peer floors raised to current
180
+
181
+ - `@classytic/mongokit` `>=3.33.0` → **`>=3.38.1`**
182
+ - `@classytic/primitives` `>=0.23.0` → **`>=0.27.0`**
183
+ - `@classytic/repo-core` `>=0.24.0` → **`>=0.27.1`**
184
+ - `@classytic/validation` `>=0.2.0` → **`>=0.5.0`**
185
+
6
186
  ## [0.6.0] — 2026-08-17
7
187
 
8
188
  ### Added
@@ -0,0 +1,75 @@
1
+ //#region src/constants.ts
2
+ const DEFAULT_PROGRAM_ID = "default";
3
+ const TRANSACTION_TYPES = Object.freeze([
4
+ "earn",
5
+ "redeem",
6
+ "adjust",
7
+ "expire",
8
+ "transfer",
9
+ "referral_bonus",
10
+ "reverse"
11
+ ]);
12
+ /**
13
+ * Why points were EARNED — a closed set, required on every earn. `order` is points
14
+ * granted with a sale (a consumer defers part of that sale's price); every other source
15
+ * is a grant with no sale behind it.
16
+ */
17
+ const EARN_SOURCES = Object.freeze([
18
+ "order",
19
+ "action",
20
+ "referral",
21
+ "campaign"
22
+ ]);
23
+ const MEMBER_STATUSES = Object.freeze([
24
+ "active",
25
+ "inactive",
26
+ "suspended"
27
+ ]);
28
+ const RULE_TYPES = Object.freeze([
29
+ "order",
30
+ "action",
31
+ "category",
32
+ "tier_bonus"
33
+ ]);
34
+ const RULE_STATUSES = Object.freeze([
35
+ "active",
36
+ "paused",
37
+ "expired"
38
+ ]);
39
+ const REFERRAL_STATUSES = Object.freeze([
40
+ "pending",
41
+ "approved",
42
+ "rejected",
43
+ "expired"
44
+ ]);
45
+ const REDEMPTION_STATUSES = Object.freeze([
46
+ "reserved",
47
+ "confirmed",
48
+ "released",
49
+ "expired",
50
+ "reversed"
51
+ ]);
52
+ const EARNING_ACTIONS = Object.freeze([
53
+ "signup",
54
+ "referral",
55
+ "birthday",
56
+ "review",
57
+ "profile_complete"
58
+ ]);
59
+ const STAMP_PROGRAM_STATUSES = Object.freeze([
60
+ "active",
61
+ "paused",
62
+ "ended"
63
+ ]);
64
+ /** What one stamp counts: a qualifying order, a business day with one, an item, or `perMinor` of spend. */
65
+ const STAMP_EARN_UNITS = Object.freeze([
66
+ "order",
67
+ "visit",
68
+ "item",
69
+ "spend"
70
+ ]);
71
+ const STAMP_REWARD_KINDS = Object.freeze(["points", "voucher"]);
72
+ /** `pending` = claimed (stamps consumed) but the external reward not yet issued; settle resumes it. */
73
+ const STAMP_AWARD_STATUSES = Object.freeze(["pending", "issued"]);
74
+ //#endregion
75
+ export { REDEMPTION_STATUSES as a, RULE_TYPES as c, STAMP_PROGRAM_STATUSES as d, STAMP_REWARD_KINDS as f, MEMBER_STATUSES as i, STAMP_AWARD_STATUSES as l, EARNING_ACTIONS as n, REFERRAL_STATUSES as o, TRANSACTION_TYPES as p, EARN_SOURCES as r, RULE_STATUSES as s, DEFAULT_PROGRAM_ID as t, STAMP_EARN_UNITS as u };
@@ -0,0 +1,35 @@
1
+ //#region src/domain/earn-base.d.ts
2
+ /**
3
+ * The amount an order earns on — ONE definition for the award, the preview and
4
+ * every host: earning lines at the price charged, minus their pro-rata share of
5
+ * every order discount (promo, manual, points redeemed). Shipping, fee lines and
6
+ * tax added on top never earn; tax inside a shelf price is part of that price.
7
+ *
8
+ * Integer minor units in and out. Zero imports, so a client can share it.
9
+ */
10
+ export interface EarnBaseLine {
11
+ /** What the line was charged before order-level discounts (`lineTotal`), minor units. */
12
+ amount: number;
13
+ /** False for a line that is not merchandise — a service fee, a surcharge. */
14
+ earns: boolean;
15
+ quantity?: number;
16
+ categoryId?: string;
17
+ }
18
+ export interface EarnBaseInput {
19
+ lines: readonly EarnBaseLine[];
20
+ /** Every order-level discount, minor units — spread over ALL lines, fees included. */
21
+ discount: number;
22
+ }
23
+ export interface EarnBaseItem {
24
+ amount: number;
25
+ quantity: number;
26
+ categoryId?: string;
27
+ }
28
+ export interface EarnBase {
29
+ /** Σ `items[].amount` — what an `order` rule reads. */
30
+ orderTotal: number;
31
+ /** Per earning line, net of its discount share — what a `category` rule reads. */
32
+ items: EarnBaseItem[];
33
+ }
34
+ export declare function earnBase({ lines, discount }: EarnBaseInput): EarnBase;
35
+ //#endregion
@@ -0,0 +1,37 @@
1
+ //#region src/domain/earn-base.ts
2
+ const whole = (n) => Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
3
+ function earnBase({ lines, discount }) {
4
+ const priced = lines.map((l) => ({
5
+ ...l,
6
+ amount: whole(l.amount)
7
+ }));
8
+ const gross = priced.reduce((s, l) => s + l.amount, 0);
9
+ const off = Math.min(whole(discount), gross);
10
+ const earning = priced.filter((l) => l.earns && l.amount > 0);
11
+ if (gross === 0 || earning.length === 0) return {
12
+ orderTotal: 0,
13
+ items: []
14
+ };
15
+ const earningGross = earning.reduce((s, l) => s + l.amount, 0);
16
+ const target = earningGross - Math.round(off * earningGross / gross);
17
+ const shares = earning.map((l) => l.amount * target / earningGross);
18
+ const nets = shares.map(Math.floor);
19
+ let left = target - nets.reduce((s, n) => s + n, 0);
20
+ const byRemainder = shares.map((s, i) => [s - Math.floor(s), i]).sort((a, b) => b[0] - a[0]);
21
+ for (const [, i] of byRemainder) {
22
+ if (left <= 0) break;
23
+ nets[i] = (nets[i] ?? 0) + 1;
24
+ left -= 1;
25
+ }
26
+ const items = earning.map((l, i) => ({
27
+ amount: nets[i] ?? 0,
28
+ quantity: l.quantity ?? 1,
29
+ ...l.categoryId ? { categoryId: l.categoryId } : {}
30
+ })).filter((it) => it.amount > 0);
31
+ return {
32
+ orderTotal: items.reduce((s, it) => s + it.amount, 0),
33
+ items
34
+ };
35
+ }
36
+ //#endregion
37
+ export { earnBase };
@@ -1,2 +1,2 @@
1
- import { a as RuleStatus, c as EARNING_ACTIONS, d as REFERRAL_STATUSES, f as RULE_STATUSES, i as ReferralStatus, l as MEMBER_STATUSES, m as TRANSACTION_TYPES, n as MemberStatus, o as RuleType, p as RULE_TYPES, r as RedemptionStatus, s as TransactionType, t as EarningAction, u as REDEMPTION_STATUSES } from "../../index-D_Oe3aDk.mjs";
2
- export { EARNING_ACTIONS, EarningAction, MEMBER_STATUSES, MemberStatus, REDEMPTION_STATUSES, REFERRAL_STATUSES, RULE_STATUSES, RULE_TYPES, RedemptionStatus, ReferralStatus, RuleStatus, RuleType, TRANSACTION_TYPES, TransactionType };
1
+ import { C as STAMP_REWARD_KINDS, S as STAMP_PROGRAM_STATUSES, _ as REFERRAL_STATUSES, a as ReferralStatus, b as STAMP_AWARD_STATUSES, c as StampAwardStatus, d as StampRewardKind, f as TransactionType, g as REDEMPTION_STATUSES, h as MEMBER_STATUSES, i as RedemptionStatus, l as StampEarnUnit, m as EARN_SOURCES, n as EarningAction, o as RuleStatus, p as EARNING_ACTIONS, r as MemberStatus, s as RuleType, t as EarnSource, u as StampProgramStatus, v as RULE_STATUSES, w as TRANSACTION_TYPES, x as STAMP_EARN_UNITS, y as RULE_TYPES } from "../../index-tjMMmPzU.mjs";
2
+ export { EARNING_ACTIONS, EARN_SOURCES, EarnSource, EarningAction, MEMBER_STATUSES, MemberStatus, REDEMPTION_STATUSES, REFERRAL_STATUSES, RULE_STATUSES, RULE_TYPES, RedemptionStatus, ReferralStatus, RuleStatus, RuleType, STAMP_AWARD_STATUSES, STAMP_EARN_UNITS, STAMP_PROGRAM_STATUSES, STAMP_REWARD_KINDS, StampAwardStatus, StampEarnUnit, StampProgramStatus, StampRewardKind, TRANSACTION_TYPES, TransactionType };
@@ -1,2 +1 @@
1
- import { a as REFERRAL_STATUSES, c as TRANSACTION_TYPES, i as REDEMPTION_STATUSES, n as EARNING_ACTIONS, o as RULE_STATUSES, r as MEMBER_STATUSES, s as RULE_TYPES } from "../../constants-DOPBh6kt.mjs";
2
- export { EARNING_ACTIONS, MEMBER_STATUSES, REDEMPTION_STATUSES, REFERRAL_STATUSES, RULE_STATUSES, RULE_TYPES, TRANSACTION_TYPES };
1
+ export { n as EARNING_ACTIONS, r as EARN_SOURCES, i as MEMBER_STATUSES, a as REDEMPTION_STATUSES, o as REFERRAL_STATUSES, s as RULE_STATUSES, c as RULE_TYPES, l as STAMP_AWARD_STATUSES, u as STAMP_EARN_UNITS, d as STAMP_PROGRAM_STATUSES, f as STAMP_REWARD_KINDS, p as TRANSACTION_TYPES } from "../../constants-DfrENxFG.mjs";
@@ -0,0 +1,24 @@
1
+ //#region src/domain/rule-schedule.d.ts
2
+ /**
3
+ * Is an earning rule live at `now`? The ONE schedule predicate — the earn path
4
+ * and any preview call this, so the two cannot disagree.
5
+ *
6
+ * A rule carries its window twice: top-level `startsAt`/`endsAt` (what the admin
7
+ * form writes) and `conditions.dateRange` (older rules, the API). It is live only
8
+ * inside BOTH. Ignoring the top-level pair is how an "Ends At" date never stopped
9
+ * a rule.
10
+ */
11
+ type Instant = Date | string | null | undefined;
12
+ export interface ScheduledRuleLike {
13
+ startsAt?: Instant;
14
+ endsAt?: Instant;
15
+ conditions?: {
16
+ dateRange?: {
17
+ start?: Instant;
18
+ end?: Instant;
19
+ } | undefined;
20
+ } | undefined;
21
+ }
22
+ export declare function isRuleInSchedule(rule: ScheduledRuleLike, now: Date): boolean;
23
+ //#endregion
24
+ export {};
@@ -0,0 +1,12 @@
1
+ //#region src/domain/rule-schedule.ts
2
+ const at = (v) => v ? new Date(v).getTime() : null;
3
+ function isRuleInSchedule(rule, now) {
4
+ const t = now.getTime();
5
+ const starts = [at(rule.startsAt), at(rule.conditions?.dateRange?.start)];
6
+ const ends = [at(rule.endsAt), at(rule.conditions?.dateRange?.end)];
7
+ if (starts.some((s) => s !== null && t < s)) return false;
8
+ if (ends.some((e) => e !== null && t > e)) return false;
9
+ return true;
10
+ }
11
+ //#endregion
12
+ export { isRuleInSchedule };
@@ -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-YUFamhDk.mjs";
2
- export { CampaignAwarded, type CampaignAwardedPayload, type DomainEvent, type EventHandler, type EventTransport, InProcessLoyaltyBus, type InProcessLoyaltyBusOptions, type LoyaltyEventDefinition, type LoyaltyEventMap, type LoyaltyEventName, type LoyaltyEventPayload, type LoyaltyEventPayloadOf, type LoyaltyEventSchema, LoyaltyEvents, MemberDeactivated, MemberEnrolled, type MemberLifecyclePayload, MemberReactivated, MemberSuspended, MultiTierReferralAwarded, type MultiTierReferralAwardedPayload, PointsAdjusted, type PointsAdjustedPayload, PointsEarned, type PointsEarnedPayload, PointsExpired, type PointsExpiredPayload, PointsExpiringSoon, PointsRedeemed, RedemptionConfirmed, type RedemptionPayload, RedemptionReleased, RedemptionReserved, RedemptionReversed, type RedemptionReversedPayload, ReferralApproved, type ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRewarded, type TierChangedPayload, TierDowngraded, TierGraceStarted, TierOverrideCleared, TierOverrideExpired, type TierOverrideLifecyclePayload, TierUpgraded, createEvent, loyaltyEventDefinitions };
1
+ import { $ as TierOverrideCleared, A as MemberReactivated, B as RedemptionConfirmed, C as TierOverrideLifecyclePayload, D as LoyaltyEventSchema, E as LoyaltyEventPayloadOf, F as PointsExpired, G as ReferralRecorded, H as RedemptionReserved, I as PointsExpiringSoon, J as StampAwardIssued, K as ReferralRejected, L as PointsLotExpired, M as MultiTierReferralAwarded, N as PointsAdjusted, O as MemberDeactivated, P as PointsEarned, Q as TierGraceStarted, R as PointsRedeemed, S as TierChangedPayload, T as LoyaltyEventDefinition, U as RedemptionReversed, V as RedemptionReleased, W as ReferralApproved, X as StampReversed, Y as StampEarned, Z as TierDowngraded, _ as RedemptionReversedPayload, a as LoyaltyEventMap, at as createEvent, b as StampEarnedPayload, c as LoyaltyEvents, d as PointsAdjustedPayload, et as TierOverrideExpired, f as PointsEarnedPayload, g as RedemptionPayload, h as PointsReversedPayload, i as CampaignAwardedPayload, it as InProcessLoyaltyBusOptions, j as MemberSuspended, k as MemberEnrolled, l as MemberLifecyclePayload, m as PointsLotExpiredPayload, n as EventHandler, nt as loyaltyEventDefinitions, o as LoyaltyEventName, p as PointsExpiredPayload, q as ReferralRewarded, r as EventTransport, rt as InProcessLoyaltyBus, s as LoyaltyEventPayload, t as DomainEvent, tt as TierUpgraded, u as MultiTierReferralAwardedPayload, v as ReferralPayload, w as CampaignAwarded, x as StampReversedPayload, y as StampAwardIssuedPayload, z as PointsReversed } from "../index-Db5Gb7x_.mjs";
2
+ export { CampaignAwarded, type CampaignAwardedPayload, type DomainEvent, type EventHandler, type EventTransport, InProcessLoyaltyBus, type InProcessLoyaltyBusOptions, type LoyaltyEventDefinition, type LoyaltyEventMap, type LoyaltyEventName, type LoyaltyEventPayload, type LoyaltyEventPayloadOf, type LoyaltyEventSchema, LoyaltyEvents, MemberDeactivated, MemberEnrolled, type MemberLifecyclePayload, MemberReactivated, MemberSuspended, MultiTierReferralAwarded, type MultiTierReferralAwardedPayload, PointsAdjusted, type PointsAdjustedPayload, PointsEarned, type PointsEarnedPayload, PointsExpired, type PointsExpiredPayload, PointsExpiringSoon, PointsLotExpired, type PointsLotExpiredPayload, PointsRedeemed, PointsReversed, type PointsReversedPayload, RedemptionConfirmed, type RedemptionPayload, RedemptionReleased, RedemptionReserved, RedemptionReversed, type RedemptionReversedPayload, ReferralApproved, type ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRewarded, StampAwardIssued, type StampAwardIssuedPayload, StampEarned, type StampEarnedPayload, StampReversed, type StampReversedPayload, type TierChangedPayload, TierDowngraded, TierGraceStarted, TierOverrideCleared, TierOverrideExpired, type TierOverrideLifecyclePayload, TierUpgraded, createEvent, loyaltyEventDefinitions };
@@ -1,3 +1,3 @@
1
- import { C as TierOverrideExpired, E as InProcessLoyaltyBus, S as TierOverrideCleared, T as loyaltyEventDefinitions, _ as ReferralRecorded, a as MemberSuspended, b as TierDowngraded, c as PointsEarned, d as PointsRedeemed, f as RedemptionConfirmed, g as ReferralApproved, h as RedemptionReversed, i as MemberReactivated, l as PointsExpired, m as RedemptionReserved, n as MemberDeactivated, o as MultiTierReferralAwarded, p as RedemptionReleased, r as MemberEnrolled, s as PointsAdjusted, t as CampaignAwarded, u as PointsExpiringSoon, v as ReferralRejected, w as TierUpgraded, x as TierGraceStarted, y as ReferralRewarded } from "../events-BLjg3uLA.mjs";
2
- import { n as createEvent, t as LoyaltyEvents } from "../loyalty-events-DXirolM_.mjs";
3
- export { CampaignAwarded, InProcessLoyaltyBus, LoyaltyEvents, MemberDeactivated, MemberEnrolled, MemberReactivated, MemberSuspended, MultiTierReferralAwarded, PointsAdjusted, PointsEarned, PointsExpired, PointsExpiringSoon, PointsRedeemed, RedemptionConfirmed, RedemptionReleased, RedemptionReserved, RedemptionReversed, ReferralApproved, ReferralRecorded, ReferralRejected, ReferralRewarded, TierDowngraded, TierGraceStarted, TierOverrideCleared, TierOverrideExpired, TierUpgraded, createEvent, loyaltyEventDefinitions };
1
+ import { A as loyaltyEventDefinitions, C as StampEarned, D as TierOverrideCleared, E as TierGraceStarted, O as TierOverrideExpired, S as StampAwardIssued, T as TierDowngraded, _ as RedemptionReversed, a as MemberSuspended, b as ReferralRejected, c as PointsEarned, d as PointsLotExpired, f as PointsRedeemed, g as RedemptionReserved, h as RedemptionReleased, i as MemberReactivated, j as InProcessLoyaltyBus, k as TierUpgraded, l as PointsExpired, m as RedemptionConfirmed, n as MemberDeactivated, o as MultiTierReferralAwarded, p as PointsReversed, r as MemberEnrolled, s as PointsAdjusted, t as CampaignAwarded, u as PointsExpiringSoon, v as ReferralApproved, w as StampReversed, x as ReferralRewarded, y as ReferralRecorded } from "../events-DhZolW61.mjs";
2
+ import { n as createEvent, t as LoyaltyEvents } from "../loyalty-events-j29B66YW.mjs";
3
+ export { CampaignAwarded, InProcessLoyaltyBus, LoyaltyEvents, MemberDeactivated, MemberEnrolled, MemberReactivated, MemberSuspended, MultiTierReferralAwarded, PointsAdjusted, PointsEarned, PointsExpired, PointsExpiringSoon, PointsLotExpired, PointsRedeemed, PointsReversed, RedemptionConfirmed, RedemptionReleased, RedemptionReserved, RedemptionReversed, ReferralApproved, ReferralRecorded, ReferralRejected, ReferralRewarded, StampAwardIssued, StampEarned, StampReversed, TierDowngraded, TierGraceStarted, TierOverrideCleared, TierOverrideExpired, TierUpgraded, createEvent, loyaltyEventDefinitions };
@@ -1,4 +1,5 @@
1
- import { t as LoyaltyEvents } from "./loyalty-events-DXirolM_.mjs";
1
+ import { r as EARN_SOURCES } from "./constants-DfrENxFG.mjs";
2
+ import { t as LoyaltyEvents } from "./loyalty-events-j29B66YW.mjs";
2
3
  import { InProcessEventBus } from "@classytic/primitives/event-infra";
3
4
  import { createEvent } from "@classytic/primitives/events";
4
5
  import { z } from "zod";
@@ -50,11 +51,15 @@ const memberLifecycleSchema = z.object({
50
51
  status: z.string()
51
52
  });
52
53
  /** Mirrors `PointsEarnedPayload`. */
54
+ /** Minor units one point was worth at the fact — integer, at least 1. */
55
+ const pointValue = z.number().int().min(1);
53
56
  const pointsEarnedSchema = z.object({
54
57
  memberId: z.string(),
55
58
  externalId: z.string(),
56
59
  externalType: z.string(),
57
60
  points: z.number(),
61
+ source: z.enum(EARN_SOURCES),
62
+ pointValueMinor: pointValue,
58
63
  balanceAfter: z.number(),
59
64
  transactionId: z.string(),
60
65
  earningRuleName: z.string().optional(),
@@ -66,16 +71,41 @@ const pointsAdjustedSchema = z.object({
66
71
  memberId: z.string(),
67
72
  externalId: z.string(),
68
73
  points: z.number(),
74
+ pointValueMinor: pointValue,
69
75
  balanceAfter: z.number(),
70
76
  transactionId: z.string(),
71
77
  description: z.string()
72
78
  });
79
+ /** Mirrors `PointsReversedPayload`. */
80
+ const pointsReversedSchema = z.object({
81
+ memberId: z.string(),
82
+ externalId: z.string(),
83
+ referenceType: z.string(),
84
+ referenceId: z.string(),
85
+ claimed: z.number().int().nonnegative(),
86
+ collected: z.number().int().nonnegative(),
87
+ shortfall: z.number().int().nonnegative(),
88
+ pointValueMinor: pointValue,
89
+ balanceAfter: z.number(),
90
+ transactionId: z.string(),
91
+ reason: z.string()
92
+ });
73
93
  /** Mirrors `PointsExpiredPayload` — reused by EXPIRED + EXPIRING_SOON. */
74
94
  const pointsExpiredSchema = z.object({
75
95
  membersAffected: z.number().int().nonnegative(),
76
96
  pointsExpired: z.number().int().nonnegative(),
77
97
  transactionCount: z.number().int().nonnegative()
78
98
  });
99
+ /** Mirrors `PointsLotExpiredPayload`. */
100
+ const pointsLotExpiredSchema = z.object({
101
+ memberId: z.string(),
102
+ externalId: z.string(),
103
+ transactionId: z.string(),
104
+ lotTransactionId: z.string(),
105
+ points: z.number().int().positive(),
106
+ pointValueMinor: pointValue,
107
+ balanceAfter: z.number()
108
+ });
79
109
  /** Mirrors `TierChangedPayload` — reused by UPGRADED + DOWNGRADED + GRACE_STARTED. */
80
110
  const tierChangedSchema = z.object({
81
111
  memberId: z.string(),
@@ -125,7 +155,8 @@ const redemptionReversedSchema = z.object({
125
155
  originalPoints: z.number(),
126
156
  proportional: z.boolean(),
127
157
  reason: z.string().optional(),
128
- transactionId: z.string()
158
+ pointValueMinor: pointValue,
159
+ transactionId: z.string().optional()
129
160
  });
130
161
  /** Mirrors `CampaignAwardedPayload`. */
131
162
  const campaignAwardedSchema = z.object({
@@ -146,6 +177,45 @@ const multiTierReferralAwardedSchema = z.object({
146
177
  transactionId: z.string()
147
178
  }))
148
179
  });
180
+ /** Mirrors `StampEarnedPayload`. */
181
+ const stampEarnedSchema = z.object({
182
+ stampProgramId: z.string(),
183
+ memberId: z.string(),
184
+ externalId: z.string(),
185
+ entryId: z.string(),
186
+ orderRef: z.string(),
187
+ stamps: z.number().int().positive(),
188
+ businessDay: z.string(),
189
+ expiresAt: z.string().optional()
190
+ });
191
+ /** Mirrors `StampReversedPayload`. */
192
+ const stampReversedSchema = z.object({
193
+ stampProgramId: z.string(),
194
+ memberId: z.string(),
195
+ entryId: z.string(),
196
+ orderRef: z.string(),
197
+ reversed: z.number().int().nonnegative(),
198
+ retainedInAwards: z.number().int().nonnegative()
199
+ });
200
+ /** Mirrors `StampAwardIssuedPayload`. */
201
+ const stampAwardIssuedSchema = z.object({
202
+ stampProgramId: z.string(),
203
+ memberId: z.string(),
204
+ externalId: z.string(),
205
+ awardId: z.string(),
206
+ sequence: z.number().int().positive(),
207
+ stampsConsumed: z.number().int().positive(),
208
+ reward: z.discriminatedUnion("kind", [z.object({
209
+ kind: z.literal("points"),
210
+ points: z.number().int().positive(),
211
+ pointTransactionId: z.string()
212
+ }), z.object({
213
+ kind: z.literal("voucher"),
214
+ voucherProgramId: z.string(),
215
+ voucherId: z.string(),
216
+ code: z.string()
217
+ })])
218
+ });
149
219
  const MemberEnrolled = defineLoyaltyEvent({
150
220
  name: LoyaltyEvents.MEMBER_ENROLLED,
151
221
  description: "A new loyalty member was enrolled in a program.",
@@ -181,11 +251,21 @@ const PointsAdjusted = defineLoyaltyEvent({
181
251
  description: "Points were manually adjusted (admin correction / override).",
182
252
  zodSchema: pointsAdjustedSchema
183
253
  });
254
+ const PointsReversed = defineLoyaltyEvent({
255
+ name: LoyaltyEvents.POINTS_REVERSED,
256
+ description: "Earned points were clawed back because the purchase that earned them was refunded or canceled.",
257
+ zodSchema: pointsReversedSchema
258
+ });
184
259
  const PointsExpired = defineLoyaltyEvent({
185
260
  name: LoyaltyEvents.POINTS_EXPIRED,
186
261
  description: "A batch of points expired across one or more members.",
187
262
  zodSchema: pointsExpiredSchema
188
263
  });
264
+ const PointsLotExpired = defineLoyaltyEvent({
265
+ name: LoyaltyEvents.POINTS_LOT_EXPIRED,
266
+ description: "One lot of earned points expired — dispatched atomically with the expiry.",
267
+ zodSchema: pointsLotExpiredSchema
268
+ });
189
269
  const PointsExpiringSoon = defineLoyaltyEvent({
190
270
  name: LoyaltyEvents.POINTS_EXPIRING_SOON,
191
271
  description: "Points are approaching their expiry window — host can notify members.",
@@ -266,6 +346,21 @@ const MultiTierReferralAwarded = defineLoyaltyEvent({
266
346
  description: "Ancestor referrers were awarded points via a multi-tier referral chain.",
267
347
  zodSchema: multiTierReferralAwardedSchema
268
348
  });
349
+ const StampEarned = defineLoyaltyEvent({
350
+ name: LoyaltyEvents.STAMP_EARNED,
351
+ description: "An order earned stamps on a stamp-card program.",
352
+ zodSchema: stampEarnedSchema
353
+ });
354
+ const StampReversed = defineLoyaltyEvent({
355
+ name: LoyaltyEvents.STAMP_REVERSED,
356
+ description: "An undone order's unconsumed stamps were reversed; stamps already awarded are kept.",
357
+ zodSchema: stampReversedSchema
358
+ });
359
+ const StampAwardIssued = defineLoyaltyEvent({
360
+ name: LoyaltyEvents.STAMP_AWARD_ISSUED,
361
+ description: "A full stamp card was exchanged for its reward (points or a voucher).",
362
+ zodSchema: stampAwardIssuedSchema
363
+ });
269
364
  /**
270
365
  * Every loyalty event defined in the package — pass to Arc's
271
366
  * `EventRegistry`. Hosts wire ONE array; the whole `loyalty.*` namespace
@@ -280,7 +375,9 @@ const loyaltyEventDefinitions = [
280
375
  PointsEarned,
281
376
  PointsRedeemed,
282
377
  PointsAdjusted,
378
+ PointsReversed,
283
379
  PointsExpired,
380
+ PointsLotExpired,
284
381
  PointsExpiringSoon,
285
382
  TierUpgraded,
286
383
  TierDowngraded,
@@ -296,7 +393,10 @@ const loyaltyEventDefinitions = [
296
393
  ReferralApproved,
297
394
  ReferralRejected,
298
395
  ReferralRewarded,
299
- MultiTierReferralAwarded
396
+ MultiTierReferralAwarded,
397
+ StampEarned,
398
+ StampReversed,
399
+ StampAwardIssued
300
400
  ];
301
401
  //#endregion
302
- export { TierOverrideExpired as C, InProcessLoyaltyBus as E, TierOverrideCleared as S, loyaltyEventDefinitions as T, ReferralRecorded as _, MemberSuspended as a, TierDowngraded as b, PointsEarned as c, PointsRedeemed as d, RedemptionConfirmed as f, ReferralApproved as g, RedemptionReversed as h, MemberReactivated as i, PointsExpired as l, RedemptionReserved as m, MemberDeactivated as n, MultiTierReferralAwarded as o, RedemptionReleased as p, MemberEnrolled as r, PointsAdjusted as s, CampaignAwarded as t, PointsExpiringSoon as u, ReferralRejected as v, TierUpgraded as w, TierGraceStarted as x, ReferralRewarded as y };
402
+ export { loyaltyEventDefinitions as A, StampEarned as C, TierOverrideCleared as D, TierGraceStarted as E, TierOverrideExpired as O, StampAwardIssued as S, TierDowngraded as T, RedemptionReversed as _, MemberSuspended as a, ReferralRejected as b, PointsEarned as c, PointsLotExpired as d, PointsRedeemed as f, RedemptionReserved as g, RedemptionReleased as h, MemberReactivated as i, InProcessLoyaltyBus as j, TierUpgraded as k, PointsExpired as l, RedemptionConfirmed as m, MemberDeactivated as n, MultiTierReferralAwarded as o, PointsReversed as p, MemberEnrolled as r, PointsAdjusted as s, CampaignAwarded as t, PointsExpiringSoon as u, ReferralApproved as v, StampReversed as w, ReferralRewarded as x, ReferralRecorded as y };