@classytic/loyalty 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/{config-BdSyAdnF.d.mts → config-Dtun1duw.d.mts} +13 -0
- package/dist/events/index.d.mts +1 -1
- package/dist/{index-CnESLipu.d.mts → index-D_5Ojc2c.d.mts} +1 -1
- package/dist/{index-BN96EDac.d.mts → index-Dngaj21_.d.mts} +15 -3
- package/dist/{index-D20Cn4KB.d.mts → index-YSsi4e_G.d.mts} +6 -0
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +9 -15
- package/dist/models/index.d.mts +1 -1
- package/dist/repositories/index.d.mts +1 -1
- package/dist/repositories/index.mjs +1 -1
- package/dist/{repositories-C_p8asCn.mjs → repositories-DEwCbLCh.mjs} +27 -9
- package/dist/types/index.d.mts +2 -2
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,52 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.4.0] — addendum (2026-07-05)
|
|
7
|
+
|
|
8
|
+
### Changed (behavioral)
|
|
9
|
+
|
|
10
|
+
- **§P8.1 relay-only session guard** — `UnmanagedSessionError` now throws
|
|
11
|
+
only when a host session arrives with NEITHER a `[PENDING_EVENTS]`
|
|
12
|
+
queue NOR an outbox. Session + outbox + no queue is legal
|
|
13
|
+
durable-relay-only mode: the row commits with the host transaction, the
|
|
14
|
+
relay delivers post-commit, the immediate transport publish is skipped.
|
|
15
|
+
(Previously the guard was stricter than spec and threw even with an
|
|
16
|
+
outbox wired.) Aligns loyalty with invoice / catalog / order /
|
|
17
|
+
purchase / transfer / promo / payrun.
|
|
18
|
+
- **Stack floors raised** — peers `@classytic/mongokit >=3.18.0`,
|
|
19
|
+
`@classytic/repo-core >=0.7.0`; full suite verified against those
|
|
20
|
+
versions.
|
|
21
|
+
|
|
22
|
+
## [0.4.0] — 2026-07-03
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **`evaluateAction` is now idempotent per recurrence key.** The order path
|
|
27
|
+
dedupes with `${orderId}:${ruleId}`, but the action path (signup /
|
|
28
|
+
referral / birthday / review) never set an `idempotencyKey` — a retried
|
|
29
|
+
sweep or double-fired event double-awarded points. When `referenceId` is
|
|
30
|
+
supplied, each rule award now carries `${referenceId}:${ruleId}`; callers
|
|
31
|
+
encode the recurrence period in `referenceId` (e.g.
|
|
32
|
+
`birthday:2026:<memberId>`) and the unique partial index on
|
|
33
|
+
`idempotencyKey` becomes the authoritative once-only guard.
|
|
34
|
+
- **Action rules honor `conditions` now.** `evaluateAction` skipped
|
|
35
|
+
`matchesConditions` entirely, silently ignoring admin-authored
|
|
36
|
+
`tiers` / `dateRange` / `dayOfWeek` on action-type rules ("only gold
|
|
37
|
+
members get birthday points" did nothing). Conditions now apply;
|
|
38
|
+
`minOrderAmount` is skipped for actions (no order to measure).
|
|
39
|
+
- **`allowNonTransactional` now actually works at runtime.** The flag
|
|
40
|
+
silenced the boot capability gate but `MongoUnitOfWork.withTransaction`
|
|
41
|
+
still opened a real transaction unconditionally — on standalone Mongo
|
|
42
|
+
every `earnPoints`/`redeem` unit failed with "does not support retryable
|
|
43
|
+
writes" despite the documented escape hatch. The unit of work now
|
|
44
|
+
delegates to mongokit's `withTransaction` with `allowFallback` so the
|
|
45
|
+
transactions-unsupported error re-runs the unit on a plain session
|
|
46
|
+
(partial-write risk accepted, exactly what the flag opts into).
|
|
47
|
+
- Day arithmetic (tier-downgrade grace expiry, referral rate-limit window)
|
|
48
|
+
now uses `addDays` from `@classytic/primitives/calendar` — exact 24h × n
|
|
49
|
+
durations instead of local `setDate`, which shifted with the deploy
|
|
50
|
+
machine's `TZ` env. Primitives peer bumped to `>=0.9.0`.
|
|
51
|
+
|
|
6
52
|
## [0.3.0] — 2026-06-13
|
|
7
53
|
|
|
8
54
|
Stack upgrade to mongokit 3.16 / repo-core 0.6 / primitives 0.7.2 plus
|
|
@@ -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
|
package/dist/events/index.d.mts
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-DEwCbLCh.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
|
-
|
|
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,
|
package/dist/models/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as ResolvedTenant, i as ModelName, r as LoyaltyModelName, t as IndexDefinition } from "../config-
|
|
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
|
|
|
@@ -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-
|
|
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-
|
|
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-DEwCbLCh.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
2
|
import { Repository, 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 ? {
|
|
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(
|
|
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);
|
|
@@ -1524,8 +1544,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1524
1544
|
...tc
|
|
1525
1545
|
});
|
|
1526
1546
|
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);
|
|
1547
|
+
const periodStart = addDays(/* @__PURE__ */ new Date(), -this.config.referral.periodDays);
|
|
1529
1548
|
if (await this.count({
|
|
1530
1549
|
referrerId: referrer._id,
|
|
1531
1550
|
createdAt: { $gte: periodStart }
|
|
@@ -1956,8 +1975,7 @@ var TierDefinitionRepository = class extends Repository {
|
|
|
1956
1975
|
action: "unchanged"
|
|
1957
1976
|
};
|
|
1958
1977
|
if (downgrade.gracePeriodDays > 0 && !member.tierExpiresAt) {
|
|
1959
|
-
const graceExpiresAt = /* @__PURE__ */ new Date();
|
|
1960
|
-
graceExpiresAt.setDate(graceExpiresAt.getDate() + downgrade.gracePeriodDays);
|
|
1978
|
+
const graceExpiresAt = addDays(/* @__PURE__ */ new Date(), downgrade.gracePeriodDays);
|
|
1961
1979
|
await this.memberRepo.update(memberId, { tierExpiresAt: graceExpiresAt }, {
|
|
1962
1980
|
lean: true,
|
|
1963
1981
|
...tc
|
package/dist/types/index.d.mts
CHANGED
|
@@ -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-
|
|
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-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/loyalty",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"homepage": "https://www.npmjs.com/package/@classytic/loyalty",
|
|
@@ -78,18 +78,18 @@
|
|
|
78
78
|
"release": "npm run push -- main && npm run release:tag && npm publish"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@classytic/mongokit": ">=3.
|
|
82
|
-
"@classytic/primitives": ">=0.
|
|
83
|
-
"@classytic/repo-core": ">=0.
|
|
81
|
+
"@classytic/mongokit": ">=3.18.0",
|
|
82
|
+
"@classytic/primitives": ">=0.9.0",
|
|
83
|
+
"@classytic/repo-core": ">=0.7.0",
|
|
84
84
|
"mongoose": ">=9.4.1",
|
|
85
85
|
"zod": ">=4.0.0"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@biomejs/biome": "^2.4.9",
|
|
89
89
|
"@classytic/dev-tools": "^0.2.0",
|
|
90
|
-
"@classytic/mongokit": "^3.
|
|
91
|
-
"@classytic/primitives": "^0.
|
|
92
|
-
"@classytic/repo-core": "^0.
|
|
90
|
+
"@classytic/mongokit": "^3.18.0",
|
|
91
|
+
"@classytic/primitives": "^0.9.0",
|
|
92
|
+
"@classytic/repo-core": "^0.7.0",
|
|
93
93
|
"@types/node": "^25.5.0",
|
|
94
94
|
"@vitest/coverage-v8": "^3.2.4",
|
|
95
95
|
"knip": "^6.3.0",
|