@classytic/loyalty 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,14 @@
1
- import { a as RuleStatus, n as MemberStatus, o as RuleType, s as TransactionType } from "./index-DMK8a98J.mjs";
1
+ import { a as RuleStatus, n as MemberStatus, o as RuleType, s as TransactionType } from "./index-D_Oe3aDk.mjs";
2
+ import "./config-CtkGj3xZ.mjs";
2
3
  import { OperationContext } from "@classytic/primitives/context";
3
4
  import { ApprovalChain } from "@classytic/primitives/approval";
4
-
5
5
  //#region src/domain/entities/earning-rule.d.ts
6
6
  interface EarningConditions {
7
+ /**
8
+ * Minimum order total for the rule to apply — **integer minor units** of the
9
+ * host's ambient currency (paisa for BDT). `10000` = ৳100. Compared against
10
+ * `evaluateOrder`'s `orderTotal` (also minor units).
11
+ */
7
12
  minOrderAmount?: number;
8
13
  categories?: string[];
9
14
  tiers?: string[];
@@ -12,7 +17,7 @@ interface EarningConditions {
12
17
  * Days the rule applies, JS `getDay()` convention: 0=Sun … 6=Sat. Empty /
13
18
  * omitted = every day. Evaluated against the wall-clock weekday in the
14
19
  * engine's configured `timezone` (UTC when unset), never the server's local
15
- * clock. See `LoyaltyConfig.timezone`.
20
+ * clock. See `LoyaltyShape.timezone`.
16
21
  */
17
22
  dayOfWeek?: number[];
18
23
  dateRange?: {
@@ -21,7 +26,24 @@ interface EarningConditions {
21
26
  };
22
27
  }
23
28
  interface EarningReward {
29
+ /**
30
+ * Points awarded per `rateBasis` minor units of spend:
31
+ * `points = pointsPerAmount × (orderTotalMinor / rateBasis)` (rounded once
32
+ * per `roundingMode`). REQUIRES `rateBasis` — there is no implicit
33
+ * "per 1 major unit" basis anymore (that ambiguity was the bug).
34
+ * "5 points per ৳100" = `{ pointsPerAmount: 5, rateBasis: 10000 }`.
35
+ */
24
36
  pointsPerAmount?: number;
37
+ /**
38
+ * RATE: the explicit integer minor-unit basis `pointsPerAmount` is quoted
39
+ * against (≥ 1). Required whenever `pointsPerAmount` is set.
40
+ */
41
+ rateBasis?: number;
42
+ /**
43
+ * RATE: integer minor units of spend per single point (≥ 1).
44
+ * `points = orderTotalMinor / amountPerPoint` (rounded per `roundingMode`).
45
+ * "1 point per ৳100" = `amountPerPoint: 10000`.
46
+ */
25
47
  amountPerPoint?: number;
26
48
  fixedPoints?: number;
27
49
  multiplier?: number;
@@ -48,6 +70,11 @@ interface EarningRule {
48
70
  //#region src/domain/entities/tier-definition.d.ts
49
71
  interface TierQualification {
50
72
  minLifetimePoints?: number;
73
+ /**
74
+ * Minimum lifetime spend — **integer minor units** (paisa for BDT).
75
+ * Declared for host-side qualification logic only: the in-package tier
76
+ * evaluation checks lifetime POINTS exclusively and never reads this field.
77
+ */
51
78
  minLifetimeSpend?: number;
52
79
  evaluationPeriodDays?: number;
53
80
  }
@@ -178,6 +205,10 @@ interface RedemptionValidation {
178
205
  valid: boolean;
179
206
  error?: string;
180
207
  pointsToRedeem: number;
208
+ /**
209
+ * Discount in **integer minor units** (paisa for BDT) — always exactly
210
+ * `pointsToRedeem × pointValueMinor`.
211
+ */
181
212
  discountAmount: number;
182
213
  maxAllowedPoints: number;
183
214
  }
@@ -278,7 +309,7 @@ interface CreateEarningRuleInput {
278
309
  * Loyalty program this rule belongs to.
279
310
  *
280
311
  * Optional for repository-created rules: `EarningRuleRepository.create(...)`
281
- * and `createMany(...)` fill this from `LoyaltyConfig.program.id`, matching
312
+ * and `createMany(...)` fill this from `LoyaltyShape.program.id`, matching
282
313
  * the default used by `MemberRepository.enroll(...)`. Pass explicitly only
283
314
  * when creating a rule for a different program.
284
315
  */
@@ -1,25 +1,14 @@
1
- import { o as LoyaltyContext } from "./index-YSsi4e_G.mjs";
2
- import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventHandler as EventHandler$1, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
1
+ import { o as LoyaltyContext } from "./index-Cp_sXNpG.mjs";
2
+ import { InProcessEventBus, InProcessEventBusOptions } from "@classytic/primitives/event-infra";
3
+ import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
3
4
  import { z } from "zod";
4
-
5
5
  //#region src/events/helpers.d.ts
6
6
  declare function createEvent$1<T>(type: string, payload: T, ctx?: LoyaltyContext, meta?: Partial<DomainEvent['meta']>): DomainEvent<T>;
7
7
  //#endregion
8
8
  //#region src/events/in-process-bus.d.ts
9
- interface InProcessLoyaltyBusOptions {
10
- /** Logger for handler errors (default: console). */
11
- logger?: {
12
- error: (msg: string, ...args: unknown[]) => void;
13
- };
14
- }
15
- declare class InProcessLoyaltyBus implements EventTransport {
16
- readonly name = "in-process-loyalty";
17
- private handlers;
18
- private logger;
9
+ type InProcessLoyaltyBusOptions = InProcessEventBusOptions;
10
+ declare class InProcessLoyaltyBus extends InProcessEventBus {
19
11
  constructor(options?: InProcessLoyaltyBusOptions);
20
- publish(event: DomainEvent): Promise<void>;
21
- subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
22
- close(): Promise<void>;
23
12
  }
24
13
  //#endregion
25
14
  //#region src/events/loyalty-event-catalog.d.ts
@@ -318,6 +307,7 @@ interface RedemptionPayload {
318
307
  externalId: string;
319
308
  redemptionId: string;
320
309
  points: number;
310
+ /** Discount in integer minor units (paisa for BDT). */
321
311
  discountAmount: number;
322
312
  ownerType: string;
323
313
  ownerId: string;
@@ -397,4 +387,4 @@ interface LoyaltyEventMap {
397
387
  }
398
388
  type LoyaltyEventPayload<E extends LoyaltyEventName> = LoyaltyEventMap[E];
399
389
  //#endregion
400
- export { PointsExpired as A, ReferralRewarded as B, MemberDeactivated as C, MultiTierReferralAwarded as D, MemberSuspended as E, RedemptionReserved as F, TierUpgraded as G, TierGraceStarted as H, RedemptionReversed as I, InProcessLoyaltyBusOptions as J, loyaltyEventDefinitions as K, ReferralApproved as L, PointsRedeemed as M, RedemptionConfirmed as N, PointsAdjusted as O, RedemptionReleased as P, ReferralRecorded as R, LoyaltyEventSchema as S, MemberReactivated as T, TierOverrideCleared as U, TierDowngraded as V, TierOverrideExpired as W, createEvent$1 as Y, TierChangedPayload as _, LoyaltyEventMap as a, LoyaltyEventDefinition as b, LoyaltyEvents as c, PointsAdjustedPayload as d, PointsEarnedPayload as f, ReferralPayload as g, RedemptionReversedPayload as h, CampaignAwardedPayload as i, PointsExpiringSoon as j, PointsEarned as k, MemberLifecyclePayload as l, RedemptionPayload as m, EventHandler$1 as n, LoyaltyEventName as o, PointsExpiredPayload as p, InProcessLoyaltyBus as q, EventTransport$1 as r, LoyaltyEventPayload as s, DomainEvent$1 as t, MultiTierReferralAwardedPayload as u, TierOverrideLifecyclePayload as v, MemberEnrolled as w, LoyaltyEventPayloadOf as x, CampaignAwarded as y, ReferralRejected as z };
390
+ export { PointsExpired as A, ReferralRewarded as B, MemberDeactivated as C, MultiTierReferralAwarded as D, MemberSuspended as E, RedemptionReserved as F, TierUpgraded as G, TierGraceStarted as H, RedemptionReversed as I, InProcessLoyaltyBusOptions as J, loyaltyEventDefinitions as K, ReferralApproved as L, PointsRedeemed as M, RedemptionConfirmed as N, PointsAdjusted as O, RedemptionReleased as P, ReferralRecorded as R, LoyaltyEventSchema as S, MemberReactivated as T, TierOverrideCleared as U, TierDowngraded as V, TierOverrideExpired as W, createEvent$1 as Y, TierChangedPayload as _, LoyaltyEventMap as a, LoyaltyEventDefinition as b, LoyaltyEvents as c, PointsAdjustedPayload as d, PointsEarnedPayload as f, ReferralPayload as g, RedemptionReversedPayload as h, CampaignAwardedPayload as i, PointsExpiringSoon as j, PointsEarned as k, MemberLifecyclePayload as l, RedemptionPayload as m, EventHandler as n, LoyaltyEventName as o, PointsExpiredPayload as p, InProcessLoyaltyBus as q, EventTransport$1 as r, LoyaltyEventPayload as s, DomainEvent$1 as t, MultiTierReferralAwardedPayload as u, TierOverrideLifecyclePayload as v, MemberEnrolled as w, LoyaltyEventPayloadOf as x, CampaignAwarded as y, ReferralRejected as z };
package/dist/index.d.mts CHANGED
@@ -1,13 +1,13 @@
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
- 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-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
- 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-Dngaj21_.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-Cp_sXNpG.mjs";
2
+ import { t as NotificationAdapter } from "./notification.port-wY4IKimz.mjs";
3
+ import { c as ResolvedConfig, i as LoyaltyShape, l as ResolvedTenant, n as LoyaltyModelName, r as LoyaltyRuntime } from "./config-CtkGj3xZ.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-YUFamhDk.mjs";
5
+ import { n as LoyaltyModels, r as defineLoyaltyModels, t as DefineLoyaltyModelsConfig } from "./index-BIhVuwIZ.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-BIPHUsfR.mjs";
7
+ import { Connection } from "mongoose";
7
8
  import { DomainEvent, DomainEvent as DomainEvent$1, EventHandler, EventTransport, EventTransport as EventTransport$1 } from "@classytic/primitives/events";
8
9
  import { OutboxStore } from "@classytic/primitives/outbox";
9
10
  import { HttpError } from "@classytic/repo-core/errors";
10
-
11
11
  //#region src/domain/errors/base.d.ts
12
12
  /**
13
13
  * Base for every loyalty throwable. Implements `HttpError` from
@@ -122,6 +122,25 @@ declare class UnmanagedSessionError extends LoyaltyError {
122
122
  readonly status = 400;
123
123
  constructor();
124
124
  }
125
+ /**
126
+ * A host passed `ctx.session`, but that session has no ACTIVE transaction.
127
+ *
128
+ * A bare `startSession()` provides NO atomicity — every write inside it
129
+ * commits independently. Accepting it as proof of a unit of work is the
130
+ * silent-permissiveness failure in its purest form: loyalty then saves the
131
+ * outbox row "in-session" and applies the balance `$inc` "in-session", and
132
+ * if the outbox save fails the balance mutation still stands with no ledger
133
+ * row behind it. Nothing throws; the points are simply wrong.
134
+ *
135
+ * `session.inTransaction()` is the driver's own answer, so this check cannot
136
+ * drift from what MongoDB actually thinks. `@classytic/access` refuses
137
+ * exactly this at `entitlement.repository.ts` — same rule, same reason.
138
+ */
139
+ declare class SessionNotInTransactionError extends LoyaltyError {
140
+ readonly code = "loyalty.transaction.session_not_in_transaction";
141
+ readonly status = 409;
142
+ constructor();
143
+ }
125
144
  /**
126
145
  * The backend doesn't declare the runtime capabilities loyalty's
127
146
  * transactional verbs require (earn / adjust / reserve / confirm / release /
@@ -132,7 +151,16 @@ declare class UnmanagedSessionError extends LoyaltyError {
132
151
  declare class LoyaltyCapabilityError extends LoyaltyError {
133
152
  readonly code = "loyalty.engine.missing_capabilities";
134
153
  readonly status = 500;
135
- constructor(missing: readonly string[]);
154
+ /**
155
+ * `true` when the backend never ANSWERED (bound before the connection
156
+ * opened, a driver exposing no topology) rather than answering "no". Both
157
+ * refuse — an unobserved outcome is never a positive one (AGENTS.md FAIL
158
+ * LOUD rule 3) — but the operator's fix differs, so the message must.
159
+ */
160
+ readonly indeterminate: boolean;
161
+ constructor(missing: readonly string[], opts?: {
162
+ indeterminate?: boolean;
163
+ });
136
164
  }
137
165
  //#endregion
138
166
  //#region src/events/dispatch.d.ts
@@ -155,31 +183,34 @@ declare function flushPendingLoyaltyEvents(events: EventTransport$1 | undefined,
155
183
  } | undefined): Promise<void>;
156
184
  //#endregion
157
185
  //#region src/index.d.ts
158
- declare function resolveConfig(config: LoyaltyConfig): ResolvedConfig;
159
- /**
160
- * LoyaltyEngine -- repositories + arc-compatible event transport.
161
- *
162
- * Domain verbs live on the repositories themselves (`engine.repositories.member.enroll(...)`,
163
- * `engine.repositories.pointTransaction.earnPoints(...)`, etc.). There is no separate
164
- * service layer. See PACKAGE_RULES S1-S3.
165
- *
166
- * `events` is structurally identical to `@classytic/arc`'s `EventTransport`.
167
- * Hosts can pass any arc transport (Memory/Redis/Kafka) via `eventTransport`
168
- * and subscribe glob-style:
169
- *
170
- * const loyalty = createLoyaltyEngine({
171
- * mongoose,
172
- * eventTransport: new RedisEventTransport({ url }),
173
- * });
174
- *
175
- * await loyalty.events.subscribe('loyalty.points.*', handler);
176
- */
186
+ declare function resolveConfig(config: LoyaltyShape): ResolvedConfig;
177
187
  interface LoyaltyEngine {
178
188
  models: LoyaltyModels;
179
189
  repositories: LoyaltyRepositories;
180
190
  events: EventTransport$1;
191
+ /** Build/align indexes. Call once per deploy when booted `autoIndex: false`. */
181
192
  syncIndexes(): Promise<void>;
193
+ /**
194
+ * Release resources owned by this engine. Closes the event bus ONLY when the
195
+ * engine created it itself (`InProcessLoyaltyBus`); a host-injected
196
+ * `eventTransport` is the host's to close. Idempotent.
197
+ */
198
+ close(): Promise<void>;
199
+ }
200
+ interface LoyaltyBlueprint {
201
+ readonly id: 'loyalty';
202
+ /** The model names this blueprint registers — known without a connection. */
203
+ readonly modelNames: readonly string[];
204
+ /**
205
+ * Compile + register the models on `connection`, wire the engine, verify
206
+ * capabilities. Synchronous: loyalty's construction performs no async work.
207
+ */
208
+ bind(connection: Connection, runtime?: LoyaltyRuntime): LoyaltyEngine;
182
209
  }
183
- declare function createLoyaltyEngine(config: LoyaltyConfig): LoyaltyEngine;
210
+ /**
211
+ * Describe the loyalty engine. Pure — registers no model, builds no schema,
212
+ * performs no I/O. Config is resolved and validated here.
213
+ */
214
+ declare function defineLoyalty(shape?: LoyaltyShape): LoyaltyBlueprint;
184
215
  //#endregion
185
- export { type AdjustPointsInput, type BalanceSummary, CircularReferralError, type CreateEarningRuleInput, type CreateTierInput, type DomainEvent, DuplicateReferralError, type EarnPointsInput, type EarningResult, type EarningRule, EarningRuleRepository, type EnrollInput, type EventHandler, type EventTransport, InProcessLoyaltyBus, type InProcessLoyaltyBusOptions, InsufficientPointsError, LoyaltyCapabilityError, type LoyaltyConfig, type LoyaltyContext, LoyaltyEngine, type LoyaltyEventDefinition, type LoyaltyEventMap, type LoyaltyEventName, type LoyaltyEventPayload, type LoyaltyEventPayloadOf, type LoyaltyEventSchema, LoyaltyEvents, type LoyaltyModelName, type LoyaltyModels, type LoyaltyRepositories, type Member, MemberAlreadyEnrolledError, type MemberBalance, MemberDeactivated, MemberEnrolled, MemberInactiveError, type MemberLifecyclePayload, MemberNotFoundError, MemberReactivated, MemberRepository, MemberSuspended, type NotificationAdapter, PENDING_EVENTS, type PointTransaction, PointTransactionRepository, PointsAdjusted, type PointsAdjustedPayload, PointsEarned, type PointsEarnedPayload, PointsExpired, type PointsExpiredPayload, PointsExpiringSoon, PointsRedeemed, type Redemption, RedemptionConfirmed, RedemptionExpiredError, RedemptionNotFoundError, type RedemptionPayload, RedemptionReleased, RedemptionRepository, RedemptionReserved, type RedemptionValidation, type Referral, ReferralApproved, ReferralLimitExceededError, ReferralNotFoundError, type ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRepository, ReferralRewarded, type ResolvedConfig, type ResolvedTenant, RuleNotFoundError, SelfReferralError, TenantIsolationError, type TierChangedPayload, type TierDefinition, TierDefinitionRepository, TierDowngraded, type TierEvaluation, TierGraceStarted, TierNotFoundError, TierUpgraded, type UnitOfWork, UnmanagedSessionError, type UpdateEarningRuleInput, type UpdateTierInput, ValidationError, createEvent, createLoyaltyEngine, flushPendingLoyaltyEvents, loyaltyEventDefinitions, resolveConfig };
216
+ export { type AdjustPointsInput, type BalanceSummary, CircularReferralError, type CreateEarningRuleInput, type CreateTierInput, type DefineLoyaltyModelsConfig, type DomainEvent, DuplicateReferralError, type EarnPointsInput, type EarningResult, type EarningRule, EarningRuleRepository, type EnrollInput, type EventHandler, type EventTransport, InProcessLoyaltyBus, type InProcessLoyaltyBusOptions, InsufficientPointsError, LoyaltyBlueprint, LoyaltyCapabilityError, type LoyaltyContext, LoyaltyEngine, type LoyaltyEventDefinition, type LoyaltyEventMap, type LoyaltyEventName, type LoyaltyEventPayload, type LoyaltyEventPayloadOf, type LoyaltyEventSchema, LoyaltyEvents, type LoyaltyModelName, type LoyaltyModels, type LoyaltyRepositories, type LoyaltyRuntime, type LoyaltyShape, type Member, MemberAlreadyEnrolledError, type MemberBalance, MemberDeactivated, MemberEnrolled, MemberInactiveError, type MemberLifecyclePayload, MemberNotFoundError, MemberReactivated, MemberRepository, MemberSuspended, type NotificationAdapter, PENDING_EVENTS, type PointTransaction, PointTransactionRepository, PointsAdjusted, type PointsAdjustedPayload, PointsEarned, type PointsEarnedPayload, PointsExpired, type PointsExpiredPayload, PointsExpiringSoon, PointsRedeemed, type Redemption, RedemptionConfirmed, RedemptionExpiredError, RedemptionNotFoundError, type RedemptionPayload, RedemptionReleased, RedemptionRepository, RedemptionReserved, type RedemptionValidation, type Referral, ReferralApproved, ReferralLimitExceededError, ReferralNotFoundError, type ReferralPayload, ReferralRecorded, ReferralRejected, ReferralRepository, ReferralRewarded, type ResolvedConfig, type ResolvedTenant, RuleNotFoundError, SelfReferralError, SessionNotInTransactionError, TenantIsolationError, type TierChangedPayload, type TierDefinition, TierDefinitionRepository, TierDowngraded, type TierEvaluation, TierGraceStarted, TierNotFoundError, TierUpgraded, type UnitOfWork, UnmanagedSessionError, type UpdateEarningRuleInput, type UpdateTierInput, ValidationError, createEvent, defineLoyalty, defineLoyaltyModels, flushPendingLoyaltyEvents, loyaltyEventDefinitions, resolveConfig };
package/dist/index.mjs CHANGED
@@ -1,35 +1,18 @@
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-CjWfUlrA.mjs";
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
- import { n as createModels } from "./models-Ddh62lgO.mjs";
5
- import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-C38JA3cU.mjs";
6
- import { withTransaction } from "@classytic/mongokit";
1
+ import "./constants-DOPBh6kt.mjs";
2
+ import { C as SessionNotInTransactionError, D as ValidationError, E as UnmanagedSessionError, S as SelfReferralError, T as TierNotFoundError, _ 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 TenantIsolationError, x as RuleNotFoundError, y as ReferralLimitExceededError } from "./repositories-DErtHQou.mjs";
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-BLjg3uLA.mjs";
4
+ import { t as defineLoyaltyModels } from "./models-weWqS5Bc.mjs";
5
+ import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-DXirolM_.mjs";
6
+ import { transactionResolutionOf, withTransaction } from "@classytic/mongokit";
7
7
  import { isValidTimeZone } from "@classytic/primitives/timezone";
8
+ import { assertNoLegacyTenantKeys, resolveTenantConfig } from "@classytic/repo-core/tenant";
8
9
  //#region src/index.ts
9
10
  function resolveConfig(config) {
10
- let tenant;
11
- if (config.tenant === false) tenant = {
12
- enabled: false,
13
- tenantField: DEFAULT_TENANT_FIELD,
14
- fieldType: DEFAULT_TENANT_TYPE,
15
- contextKey: DEFAULT_TENANT_FIELD
16
- };
17
- else if (config.tenant === void 0) tenant = {
18
- enabled: true,
19
- tenantField: DEFAULT_TENANT_FIELD,
20
- fieldType: DEFAULT_TENANT_TYPE,
21
- contextKey: DEFAULT_TENANT_FIELD
22
- };
23
- else tenant = {
24
- enabled: true,
25
- tenantField: config.tenant.tenantField ?? "organizationId",
26
- fieldType: config.tenant.fieldType ?? "objectId",
27
- contextKey: config.tenant.contextKey ?? config.tenant.tenantField ?? "organizationId",
28
- ...config.tenant.ref ? { ref: config.tenant.ref } : {}
29
- };
11
+ const tenant = resolveTenantConfig(config.tenant);
12
+ if (tenant.strategy === "custom") throw new Error("[loyalty] tenant.strategy 'custom' is unsupported: loyalty repositories require one concrete tenant field");
30
13
  const resolved = {
31
14
  programId: config.program?.id ?? "default",
32
- conversionRate: config.program?.conversionRate ?? 10,
15
+ pointValueMinor: config.program?.pointValueMinor ?? 10,
33
16
  redemption: {
34
17
  minPoints: config.redemption?.minPoints ?? 0,
35
18
  minOrderAmount: config.redemption?.minOrderAmount ?? 0,
@@ -54,48 +37,116 @@ function resolveConfig(config) {
54
37
  tenant
55
38
  };
56
39
  if (config.timezone !== void 0 && !isValidTimeZone(config.timezone)) throw new ValidationError(`Invalid IANA timezone: '${config.timezone}'`);
57
- if (resolved.conversionRate <= 0) throw new ValidationError("conversionRate must be positive");
40
+ if (!Number.isSafeInteger(resolved.pointValueMinor) || resolved.pointValueMinor <= 0) throw new ValidationError("pointValueMinor must be a positive integer (minor units per point)");
58
41
  if (resolved.referral.codeLength < 4) throw new ValidationError("referral codeLength must be >= 4");
59
42
  if (resolved.redemption.maxRedeemPercent < 0 || resolved.redemption.maxRedeemPercent > 100) throw new ValidationError("maxRedeemPercent must be between 0 and 100");
60
43
  return resolved;
61
44
  }
62
45
  var MongoUnitOfWork = class {
46
+ connection;
47
+ allowFallback;
63
48
  constructor(connection, allowFallback) {
64
49
  this.connection = connection;
65
50
  this.allowFallback = allowFallback;
66
51
  }
52
+ /** The fallback IS the transaction-less path — say so, don't make callers infer it. */
53
+ get allowsNonTransactional() {
54
+ return this.allowFallback;
55
+ }
67
56
  async withTransaction(cb) {
68
57
  return withTransaction(this.connection, cb, this.allowFallback ? { allowFallback: true } : {});
69
58
  }
70
59
  };
71
- function createLoyaltyEngine(config) {
72
- const resolvedConfig = resolveConfig(config);
73
- const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex, config.collectionPrefix, config.forceRecreate);
74
- const unitOfWork = new MongoUnitOfWork(config.mongoose, config.allowNonTransactional ?? false);
75
- const events = config.eventTransport ?? new InProcessLoyaltyBus();
76
- const repositories = createRepositories({
77
- models,
78
- plugins: config.plugins,
60
+ /**
61
+ * Describe the loyalty engine. Pure — registers no model, builds no schema,
62
+ * performs no I/O. Config is resolved and validated here.
63
+ */
64
+ function defineLoyalty(shape = {}) {
65
+ assertNoLegacyTenantKeys(shape, "defineLoyalty", [["tenantFieldType", "tenant.fieldType"]]);
66
+ const resolvedConfig = resolveConfig(shape);
67
+ const modelBlueprint = defineLoyaltyModels({
79
68
  tenant: resolvedConfig.tenant,
80
- config: resolvedConfig,
81
- events,
82
- outbox: config.outbox,
83
- notifications: config.notifications ?? null,
84
- unitOfWork
69
+ indexes: shape.indexes,
70
+ autoIndex: shape.autoIndex,
71
+ collectionPrefix: shape.collectionPrefix,
72
+ forceRecreate: shape.forceRecreate
85
73
  });
86
- if (!config.allowNonTransactional) {
87
- const caps = repositories.pointTransaction.capabilities;
88
- if (!caps) throw new LoyaltyCapabilityError(["capabilities descriptor (repo-core 0.6 / mongokit 3.16+) — backend declares none"]);
89
- if (caps.transactions !== true) throw new LoyaltyCapabilityError(["transactions"]);
90
- }
91
74
  return {
92
- models,
93
- repositories,
94
- events,
95
- async syncIndexes() {
96
- await Promise.all(Object.values(models).map((m) => m.createIndexes()));
75
+ id: "loyalty",
76
+ modelNames: modelBlueprint.modelNames,
77
+ bind(connection, runtime = {}) {
78
+ /**
79
+ * VERIFY (boot gate) — an injected outbox MUST enlist `ctx.session`.
80
+ *
81
+ * Checked FIRST, before the model blueprint binds: a cheaper error should report
82
+ * before a costlier one, and a bind that throws here must not have already mutated
83
+ * the connection's model registry.
84
+ *
85
+ * `OutboxWriteOptions.session` is best-effort BY CONTRACT (see
86
+ * `@classytic/primitives/outbox`) — "stores may ignore fields they don't support".
87
+ * A hand-rolled host store therefore satisfies `OutboxStore` structurally, compiles,
88
+ * runs, and persists the event row OUTSIDE the caller's transaction.
89
+ *
90
+ * Points are STORED VALUE, so that lands on the worst path this kernel has.
91
+ * `earn` / `redeem` / `reverse` `$inc` the member balance and insert the ledger row
92
+ * in ONE transaction while `dispatchLoyaltyEvent` saves the event in-session
93
+ * (`events/dispatch.ts`), so the row must vanish with them on rollback. A store that
94
+ * drops the session emits `loyalty.points.earned` for a balance that rolled back —
95
+ * a tier recalculation or a notification then credits a customer with points the
96
+ * ledger never recorded. Nothing throws.
97
+ *
98
+ * `dispatch.ts` treats an outbox as licence for "durable-relay-only mode" (a bare
99
+ * host session with no PENDING_EVENTS queue is legal *because* the row is assumed to
100
+ * commit with the host transaction) — that assumption is exactly what this verifies.
101
+ * `UnmanagedSessionError` guards the OPPOSITE hole (session, NO outbox) and cannot
102
+ * see this one: the store is present and `save()` resolves.
103
+ *
104
+ * NOT waived by `allowNonTransactional` — that opts into standalone-Mongo partial
105
+ * writes, a different requirement entirely.
106
+ *
107
+ * `!== true`, not `=== false`: a hand-rolled store has the flag ABSENT.
108
+ */
109
+ if (runtime.outbox && runtime.outbox.transactionalSave !== true) throw new LoyaltyCapabilityError(["outbox.transactionalSave (the configured OutboxStore does not declare that save() enlists ctx.session — see @classytic/primitives/outbox)"]);
110
+ const models = modelBlueprint.bind(connection);
111
+ const allowNonTransactional = runtime.allowNonTransactional ?? false;
112
+ const unitOfWork = new MongoUnitOfWork(connection, allowNonTransactional);
113
+ const ownsBus = runtime.eventTransport === void 0;
114
+ const events = runtime.eventTransport ?? new InProcessLoyaltyBus();
115
+ const repositories = createRepositories({
116
+ models,
117
+ plugins: runtime.plugins,
118
+ tenant: resolvedConfig.tenant,
119
+ config: resolvedConfig,
120
+ events,
121
+ outbox: runtime.outbox,
122
+ notifications: runtime.notifications ?? null,
123
+ unitOfWork
124
+ });
125
+ {
126
+ const caps = repositories.pointTransaction.capabilities;
127
+ if (!caps) throw new LoyaltyCapabilityError(["capabilities descriptor (repo-core 0.6 / mongokit 3.16+) — backend declares none"]);
128
+ if (caps.transactions !== true) {
129
+ const indeterminate = transactionResolutionOf(caps) === "unknown";
130
+ if (allowNonTransactional) (runtime.logger ?? console).warn?.(`[loyalty] the backend ${indeterminate ? "could NOT be confirmed to support" : "does NOT support"} transactions. allowNonTransactional=true: point earn / redemption / reversal run WITHOUT atomicity — a crash mid-verb can leave a balance no transaction explains. Development only.`);
131
+ else throw new LoyaltyCapabilityError(["transactions"], { indeterminate });
132
+ }
133
+ }
134
+ let closed = false;
135
+ return {
136
+ models,
137
+ repositories,
138
+ events,
139
+ async syncIndexes() {
140
+ await Promise.all(Object.values(models).map((m) => m.createIndexes()));
141
+ },
142
+ async close() {
143
+ if (closed) return;
144
+ closed = true;
145
+ if (ownsBus) await events.close?.();
146
+ }
147
+ };
97
148
  }
98
149
  };
99
150
  }
100
151
  //#endregion
101
- export { CircularReferralError, DuplicateReferralError, EarningRuleRepository, InProcessLoyaltyBus, InsufficientPointsError, LoyaltyCapabilityError, LoyaltyEvents, MemberAlreadyEnrolledError, MemberDeactivated, MemberEnrolled, MemberInactiveError, MemberNotFoundError, MemberReactivated, MemberRepository, MemberSuspended, PENDING_EVENTS, PointTransactionRepository, PointsAdjusted, PointsEarned, PointsExpired, PointsExpiringSoon, PointsRedeemed, RedemptionConfirmed, RedemptionExpiredError, RedemptionNotFoundError, RedemptionReleased, RedemptionRepository, RedemptionReserved, ReferralApproved, ReferralLimitExceededError, ReferralNotFoundError, ReferralRecorded, ReferralRejected, ReferralRepository, ReferralRewarded, RuleNotFoundError, SelfReferralError, TenantIsolationError, TierDefinitionRepository, TierDowngraded, TierGraceStarted, TierNotFoundError, TierUpgraded, UnmanagedSessionError, ValidationError, createEvent, createLoyaltyEngine, flushPendingLoyaltyEvents, loyaltyEventDefinitions, resolveConfig };
152
+ export { CircularReferralError, DuplicateReferralError, EarningRuleRepository, InProcessLoyaltyBus, InsufficientPointsError, LoyaltyCapabilityError, LoyaltyEvents, MemberAlreadyEnrolledError, MemberDeactivated, MemberEnrolled, MemberInactiveError, MemberNotFoundError, MemberReactivated, MemberRepository, MemberSuspended, PENDING_EVENTS, PointTransactionRepository, PointsAdjusted, PointsEarned, PointsExpired, PointsExpiringSoon, PointsRedeemed, RedemptionConfirmed, RedemptionExpiredError, RedemptionNotFoundError, RedemptionReleased, RedemptionRepository, RedemptionReserved, ReferralApproved, ReferralLimitExceededError, ReferralNotFoundError, ReferralRecorded, ReferralRejected, ReferralRepository, ReferralRewarded, RuleNotFoundError, SelfReferralError, SessionNotInTransactionError, TenantIsolationError, TierDefinitionRepository, TierDowngraded, TierGraceStarted, TierNotFoundError, TierUpgraded, UnmanagedSessionError, ValidationError, createEvent, defineLoyalty, defineLoyaltyModels, flushPendingLoyaltyEvents, loyaltyEventDefinitions, resolveConfig };