@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 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
@@ -1,2 +1,2 @@
1
- import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, D as MultiTierReferralAwarded, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, I as RedemptionReversed, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, U as TierOverrideCleared, V as TierDowngraded, W as TierOverrideExpired, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, h as RedemptionReversedPayload, i as CampaignAwardedPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, n as EventHandler, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, r as EventTransport, s as LoyaltyEventPayload, t as DomainEvent, u as MultiTierReferralAwardedPayload, v as TierOverrideLifecyclePayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, y as CampaignAwarded, z as ReferralRejected } from "../index-CnESLipu.mjs";
1
+ import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, D as MultiTierReferralAwarded, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, I as RedemptionReversed, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, U as TierOverrideCleared, V as TierDowngraded, W as TierOverrideExpired, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, h as RedemptionReversedPayload, i as CampaignAwardedPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, n as EventHandler, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, r as EventTransport, s as LoyaltyEventPayload, t as DomainEvent, u as MultiTierReferralAwardedPayload, v as TierOverrideLifecyclePayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, y as CampaignAwarded, z as ReferralRejected } from "../index-D_5Ojc2c.mjs";
2
2
  export { CampaignAwarded, CampaignAwardedPayload, DomainEvent, EventHandler, EventTransport, InProcessLoyaltyBus, InProcessLoyaltyBusOptions, LoyaltyEventDefinition, LoyaltyEventMap, LoyaltyEventName, LoyaltyEventPayload, LoyaltyEventPayloadOf, LoyaltyEventSchema, LoyaltyEvents, MemberDeactivated, MemberEnrolled, MemberLifecyclePayload, MemberReactivated, MemberSuspended, MultiTierReferralAwarded, MultiTierReferralAwardedPayload, PointsAdjusted, PointsAdjustedPayload, PointsEarned, PointsEarnedPayload, PointsExpired, PointsExpiredPayload, PointsExpiringSoon, PointsRedeemed, RedemptionConfirmed, RedemptionPayload, RedemptionReleased, RedemptionReserved, RedemptionReversed, RedemptionReversedPayload, ReferralApproved, ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRewarded, TierChangedPayload, TierDowngraded, TierGraceStarted, TierOverrideCleared, TierOverrideExpired, TierOverrideLifecyclePayload, TierUpgraded, createEvent, loyaltyEventDefinitions };
@@ -1,4 +1,4 @@
1
- import { o as LoyaltyContext } from "./index-D20Cn4KB.mjs";
1
+ import { o as LoyaltyContext } from "./index-YSsi4e_G.mjs";
2
2
  import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventHandler as EventHandler$1, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
3
3
  import { z } from "zod";
4
4
 
@@ -1,11 +1,11 @@
1
1
  import { i as ReferralStatus, r as RedemptionStatus } from "./index-DMK8a98J.mjs";
2
- import { S as EarningRule, _ as TierEvaluationBatch, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, o as LoyaltyContext, v as PointTransaction, x as TierDefinition, y as Member } from "./index-D20Cn4KB.mjs";
2
+ import { S as EarningRule, _ as TierEvaluationBatch, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, o as LoyaltyContext, v as PointTransaction, x as TierDefinition, y as Member } from "./index-YSsi4e_G.mjs";
3
3
  import { t as NotificationAdapter } from "./notification.port-CJ9nXj0k.mjs";
4
- import { c as ResolvedTenant, o as RepositoryPlugins, s as ResolvedConfig } from "./config-BdSyAdnF.mjs";
4
+ import { c as ResolvedTenant, o as RepositoryPlugins, s as ResolvedConfig } from "./config-Dtun1duw.mjs";
5
5
  import { LoyaltyModels } from "./models/index.mjs";
6
+ import { PluginType, Repository } from "@classytic/mongokit";
6
7
  import { EventTransport } from "@classytic/primitives/events";
7
8
  import { ClientSession, Model } from "mongoose";
8
- import { PluginType, Repository } from "@classytic/mongokit";
9
9
  import { OutboxStore } from "@classytic/primitives/outbox";
10
10
  import { ApprovalChain } from "@classytic/primitives/approval";
11
11
 
@@ -159,7 +159,19 @@ declare class EarningRuleRepository extends Repository<EarningRule> {
159
159
  action: string;
160
160
  referenceId?: string;
161
161
  }, ctx: LoyaltyContext): Promise<EarningResult>;
162
+ /**
163
+ * `orderTotal` is optional — action-type evaluations have no order, so
164
+ * `minOrderAmount` only applies when a total is supplied (order paths).
165
+ */
162
166
  private matchesConditions;
167
+ /**
168
+ * The wall-clock weekday of `instant` in JS `getDay()` convention
169
+ * (0=Sun … 6=Sat — what `EarningConditions.dayOfWeek` is authored in),
170
+ * evaluated in the engine's configured `timezone` (DST-exact) or UTC when
171
+ * unset. NEVER server-local `Date.getDay()`, which shifts with the deploy
172
+ * machine's `TZ` env (PACKAGE_RULES P12).
173
+ */
174
+ private weekday;
163
175
  private calculateOrderPoints;
164
176
  private calculateCategoryPoints;
165
177
  private applyRounding;
@@ -8,6 +8,12 @@ interface EarningConditions {
8
8
  categories?: string[];
9
9
  tiers?: string[];
10
10
  actions?: string[];
11
+ /**
12
+ * Days the rule applies, JS `getDay()` convention: 0=Sun … 6=Sat. Empty /
13
+ * omitted = every day. Evaluated against the wall-clock weekday in the
14
+ * engine's configured `timezone` (UTC when unset), never the server's local
15
+ * clock. See `LoyaltyConfig.timezone`.
16
+ */
11
17
  dayOfWeek?: number[];
12
18
  dateRange?: {
13
19
  start?: Date;
package/dist/index.d.mts CHANGED
@@ -1,9 +1,9 @@
1
- import { S as EarningRule, a as EnrollInput, b as MemberBalance, c as UpdateTierInput, d as EarningResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, n as CreateEarningRuleInput, o as LoyaltyContext, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, v as PointTransaction, x as TierDefinition, y as Member } from "./index-D20Cn4KB.mjs";
1
+ import { S as EarningRule, a as EnrollInput, b as MemberBalance, c as UpdateTierInput, d as EarningResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, n as CreateEarningRuleInput, o as LoyaltyContext, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, v as PointTransaction, x as TierDefinition, y as Member } from "./index-YSsi4e_G.mjs";
2
2
  import { t as NotificationAdapter } from "./notification.port-CJ9nXj0k.mjs";
3
- import { c as ResolvedTenant, n as LoyaltyConfig, r as LoyaltyModelName, s as ResolvedConfig } from "./config-BdSyAdnF.mjs";
4
- import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, V as TierDowngraded, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, s as LoyaltyEventPayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, z as ReferralRejected } from "./index-CnESLipu.mjs";
3
+ import { c as ResolvedTenant, n as LoyaltyConfig, r as LoyaltyModelName, s as ResolvedConfig } from "./config-Dtun1duw.mjs";
4
+ import { A as PointsExpired, B as ReferralRewarded, C as MemberDeactivated, E as MemberSuspended, F as RedemptionReserved, G as TierUpgraded, H as TierGraceStarted, J as InProcessLoyaltyBusOptions, K as loyaltyEventDefinitions, L as ReferralApproved, M as PointsRedeemed, N as RedemptionConfirmed, O as PointsAdjusted, P as RedemptionReleased, R as ReferralRecorded, S as LoyaltyEventSchema, T as MemberReactivated, V as TierDowngraded, Y as createEvent, _ as TierChangedPayload, a as LoyaltyEventMap, b as LoyaltyEventDefinition, c as LoyaltyEvents, d as PointsAdjustedPayload, f as PointsEarnedPayload, g as ReferralPayload, j as PointsExpiringSoon, k as PointsEarned, l as MemberLifecyclePayload, m as RedemptionPayload, o as LoyaltyEventName, p as PointsExpiredPayload, q as InProcessLoyaltyBus, s as LoyaltyEventPayload, w as MemberEnrolled, x as LoyaltyEventPayloadOf, z as ReferralRejected } from "./index-D_5Ojc2c.mjs";
5
5
  import { LoyaltyModels } from "./models/index.mjs";
6
- import { a as ReferralRepository, c as Redemption, d as MemberRepository, f as UnitOfWork, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, o as Referral, s as RedemptionRepository, u as PointTransactionRepository } from "./index-BN96EDac.mjs";
6
+ import { a as ReferralRepository, c as Redemption, d as MemberRepository, f as UnitOfWork, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, o as Referral, s as RedemptionRepository, u as PointTransactionRepository } from "./index-Dngaj21_.mjs";
7
7
  import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
8
8
  import { OutboxStore } from "@classytic/primitives/outbox";
9
9
  import { HttpError } from "@classytic/repo-core/errors";
package/dist/index.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { n as DEFAULT_TENANT_FIELD, r as DEFAULT_TENANT_TYPE } from "./constants-DkQYkOK1.mjs";
2
- import { C as TenantIsolationError, E as ValidationError, S as SelfReferralError, T as UnmanagedSessionError, _ as RedemptionExpiredError, a as PointTransactionRepository, b as ReferralNotFoundError, c as flushPendingLoyaltyEvents, d as DuplicateReferralError, f as InsufficientPointsError, g as MemberNotFoundError, h as MemberInactiveError, i as RedemptionRepository, l as EarningRuleRepository, m as MemberAlreadyEnrolledError, n as TierDefinitionRepository, o as MemberRepository, p as LoyaltyCapabilityError, r as ReferralRepository, s as PENDING_EVENTS, t as createRepositories, u as CircularReferralError, v as RedemptionNotFoundError, w as TierNotFoundError, x as RuleNotFoundError, y as ReferralLimitExceededError } from "./repositories-C_p8asCn.mjs";
2
+ import { C as TenantIsolationError, E as ValidationError, S as SelfReferralError, T as UnmanagedSessionError, _ as RedemptionExpiredError, a as PointTransactionRepository, b as ReferralNotFoundError, c as flushPendingLoyaltyEvents, d as DuplicateReferralError, f as InsufficientPointsError, g as MemberNotFoundError, h as MemberInactiveError, i as RedemptionRepository, l as EarningRuleRepository, m as MemberAlreadyEnrolledError, n as TierDefinitionRepository, o as MemberRepository, p as LoyaltyCapabilityError, r as ReferralRepository, s as PENDING_EVENTS, t as createRepositories, u as CircularReferralError, v as RedemptionNotFoundError, w as TierNotFoundError, x as RuleNotFoundError, y as ReferralLimitExceededError } from "./repositories-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
- const session = await this.connection.startSession();
64
- try {
65
- const slot = { assigned: false };
66
- await session.withTransaction(async () => {
67
- slot.value = await cb(session);
68
- slot.assigned = true;
69
- });
70
- if (!slot.assigned) throw new Error("withTransaction: callback did not assign a result");
71
- return slot.value;
72
- } finally {
73
- await session.endSession();
74
- }
68
+ return withTransaction(this.connection, cb, this.allowFallback ? { allowFallback: true } : {});
75
69
  }
76
70
  };
77
71
  function createLoyaltyEngine(config) {
78
72
  const resolvedConfig = resolveConfig(config);
79
73
  const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex, config.collectionPrefix, config.forceRecreate);
80
- const unitOfWork = new MongoUnitOfWork(config.mongoose);
74
+ const unitOfWork = new MongoUnitOfWork(config.mongoose, config.allowNonTransactional ?? false);
81
75
  const events = config.eventTransport ?? new InProcessLoyaltyBus();
82
76
  const repositories = createRepositories({
83
77
  models,
@@ -1,4 +1,4 @@
1
- import { c as ResolvedTenant, i as ModelName, r as LoyaltyModelName, t as IndexDefinition } from "../config-BdSyAdnF.mjs";
1
+ import { c as ResolvedTenant, i as ModelName, r as LoyaltyModelName, t as IndexDefinition } from "../config-Dtun1duw.mjs";
2
2
  import * as _$mongoose from "mongoose";
3
3
  import { Connection, Model } from "mongoose";
4
4
 
@@ -1,2 +1,2 @@
1
- import { a as ReferralRepository, d as MemberRepository, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, r as createRepositories, s as RedemptionRepository, t as CreateRepositoriesInput, u as PointTransactionRepository } from "../index-BN96EDac.mjs";
1
+ import { a as ReferralRepository, d as MemberRepository, i as TierDefinitionRepository, l as EarningRuleRepository, n as LoyaltyRepositories, r as createRepositories, s as RedemptionRepository, t as CreateRepositoriesInput, u as PointTransactionRepository } from "../index-Dngaj21_.mjs";
2
2
  export { CreateRepositoriesInput, EarningRuleRepository, LoyaltyRepositories, MemberRepository, PointTransactionRepository, RedemptionRepository, ReferralRepository, TierDefinitionRepository, createRepositories };
@@ -1,2 +1,2 @@
1
- import { a as PointTransactionRepository, i as RedemptionRepository, l as EarningRuleRepository, n as TierDefinitionRepository, o as MemberRepository, r as ReferralRepository, t as createRepositories } from "../repositories-C_p8asCn.mjs";
1
+ import { a as PointTransactionRepository, i as RedemptionRepository, l as EarningRuleRepository, n as TierDefinitionRepository, o as MemberRepository, r as ReferralRepository, t as createRepositories } from "../repositories-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 ? { referenceId: input.referenceId } : {}
347
+ ...input.referenceId !== void 0 ? {
348
+ referenceId: input.referenceId,
349
+ idempotencyKey: `${input.referenceId}:${rule._id}`
350
+ } : {}
345
351
  }, ctx);
346
352
  totalPoints += points;
347
353
  breakdown.push({
@@ -357,10 +363,14 @@ var EarningRuleRepository = class extends Repository {
357
363
  transactions
358
364
  };
359
365
  }
366
+ /**
367
+ * `orderTotal` is optional — action-type evaluations have no order, so
368
+ * `minOrderAmount` only applies when a total is supplied (order paths).
369
+ */
360
370
  matchesConditions(rule, member, orderTotal) {
361
371
  const { conditions } = rule;
362
372
  const now = /* @__PURE__ */ new Date();
363
- if (conditions.minOrderAmount && orderTotal < conditions.minOrderAmount) return false;
373
+ if (conditions.minOrderAmount && orderTotal !== void 0 && orderTotal < conditions.minOrderAmount) return false;
364
374
  if (conditions.tiers && conditions.tiers.length > 0) {
365
375
  const memberTier = member.tierOverride ?? member.tier;
366
376
  if (!memberTier || !conditions.tiers.includes(memberTier)) return false;
@@ -370,10 +380,20 @@ var EarningRuleRepository = class extends Repository {
370
380
  if (conditions.dateRange.end && now > conditions.dateRange.end) return false;
371
381
  }
372
382
  if (conditions.dayOfWeek && conditions.dayOfWeek.length > 0) {
373
- if (!conditions.dayOfWeek.includes(now.getDay())) return false;
383
+ if (!conditions.dayOfWeek.includes(this.weekday(now))) return false;
374
384
  }
375
385
  return true;
376
386
  }
387
+ /**
388
+ * The wall-clock weekday of `instant` in JS `getDay()` convention
389
+ * (0=Sun … 6=Sat — what `EarningConditions.dayOfWeek` is authored in),
390
+ * evaluated in the engine's configured `timezone` (DST-exact) or UTC when
391
+ * unset. NEVER server-local `Date.getDay()`, which shifts with the deploy
392
+ * machine's `TZ` env (PACKAGE_RULES P12).
393
+ */
394
+ weekday(instant) {
395
+ return (this.config.timezone ? localTimeParts(instant, this.config.timezone).isoWeekday : isoWeekday(instant)) % 7;
396
+ }
377
397
  calculateOrderPoints(rule, orderTotal) {
378
398
  const { reward } = rule;
379
399
  let points = 0;
@@ -410,7 +430,6 @@ var EarningRuleRepository = class extends Repository {
410
430
  const PENDING_EVENTS = Symbol.for("@classytic/loyalty:pendingEvents");
411
431
  async function dispatchLoyaltyEvent(deps, event, ctx) {
412
432
  const pending = ctx[PENDING_EVENTS];
413
- if (ctx.session && !pending) throw new UnmanagedSessionError();
414
433
  if (deps.outbox) try {
415
434
  const saveOptions = {};
416
435
  if (ctx.session !== void 0) saveOptions.session = ctx.session;
@@ -419,11 +438,12 @@ async function dispatchLoyaltyEvent(deps, event, ctx) {
419
438
  deps.logger?.error(`[loyalty] outbox.save failed for ${event.type}:`, err);
420
439
  throw err;
421
440
  }
441
+ else if (ctx.session && !pending) throw new UnmanagedSessionError();
422
442
  if (pending) {
423
443
  pending.push(event);
424
444
  return;
425
445
  }
426
- if (deps.events) try {
446
+ if (deps.events && !ctx.session) try {
427
447
  await deps.events.publish(event);
428
448
  } catch (err) {
429
449
  deps.logger?.error(`[loyalty] events.publish failed for ${event.type}:`, err);
@@ -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
@@ -1,3 +1,3 @@
1
- import { _ as TierEvaluationBatch, a as EnrollInput, c as UpdateTierInput, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, m as PaginatedResult, n as CreateEarningRuleInput, o as LoyaltyContext, p as ListQuery, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, u as EarningBreakdown } from "../index-D20Cn4KB.mjs";
2
- import { a as PluginType, c as ResolvedTenant, i as ModelName, l as TenantConfig, n as LoyaltyConfig, o as RepositoryPlugins, s as ResolvedConfig, t as IndexDefinition } from "../config-BdSyAdnF.mjs";
1
+ import { _ as TierEvaluationBatch, a as EnrollInput, c as UpdateTierInput, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, m as PaginatedResult, n as CreateEarningRuleInput, o as LoyaltyContext, p as ListQuery, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, u as EarningBreakdown } from "../index-YSsi4e_G.mjs";
2
+ import { a as PluginType, c as ResolvedTenant, i as ModelName, l as TenantConfig, n as LoyaltyConfig, o as RepositoryPlugins, s as ResolvedConfig, t as IndexDefinition } from "../config-Dtun1duw.mjs";
3
3
  export { AdjustPointsInput, BalanceSummary, CreateEarningRuleInput, CreateTierInput, EarnPointsInput, EarningBreakdown, EarningResult, EnrollInput, ExpirationResult, IndexDefinition, ListQuery, LoyaltyConfig, LoyaltyContext, ModelName, PaginatedResult, PluginType, RedemptionValidation, RepositoryPlugins, ResolvedConfig, ResolvedTenant, TenantConfig, TierEvaluation, TierEvaluationBatch, UpdateEarningRuleInput, UpdateTierInput };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/loyalty",
3
- "version": "0.3.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.16.0",
82
- "@classytic/primitives": ">=0.5.0",
83
- "@classytic/repo-core": ">=0.6.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.16.0",
91
- "@classytic/primitives": "^0.7.2",
92
- "@classytic/repo-core": "^0.6.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",