@classytic/loyalty 0.4.0 → 0.6.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,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-D6GvuvmF.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-CR2k7jQ8.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-DEwCbLCh.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-B676z4bE.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,12 +37,14 @@ 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;
@@ -68,34 +53,96 @@ var MongoUnitOfWork = class {
68
53
  return withTransaction(this.connection, cb, this.allowFallback ? { allowFallback: true } : {});
69
54
  }
70
55
  };
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,
56
+ /**
57
+ * Describe the loyalty engine. Pure — registers no model, builds no schema,
58
+ * performs no I/O. Config is resolved and validated here.
59
+ */
60
+ function defineLoyalty(shape = {}) {
61
+ assertNoLegacyTenantKeys(shape, "defineLoyalty", [["tenantFieldType", "tenant.fieldType"]]);
62
+ const resolvedConfig = resolveConfig(shape);
63
+ const modelBlueprint = defineLoyaltyModels({
79
64
  tenant: resolvedConfig.tenant,
80
- config: resolvedConfig,
81
- events,
82
- outbox: config.outbox,
83
- notifications: config.notifications ?? null,
84
- unitOfWork
65
+ indexes: shape.indexes,
66
+ autoIndex: shape.autoIndex,
67
+ collectionPrefix: shape.collectionPrefix,
68
+ forceRecreate: shape.forceRecreate
85
69
  });
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
70
  return {
92
- models,
93
- repositories,
94
- events,
95
- async syncIndexes() {
96
- await Promise.all(Object.values(models).map((m) => m.createIndexes()));
71
+ id: "loyalty",
72
+ modelNames: modelBlueprint.modelNames,
73
+ bind(connection, runtime = {}) {
74
+ /**
75
+ * VERIFY (boot gate) — an injected outbox MUST enlist `ctx.session`.
76
+ *
77
+ * Checked FIRST, before the model blueprint binds: a cheaper error should report
78
+ * before a costlier one, and a bind that throws here must not have already mutated
79
+ * the connection's model registry.
80
+ *
81
+ * `OutboxWriteOptions.session` is best-effort BY CONTRACT (see
82
+ * `@classytic/primitives/outbox`) — "stores may ignore fields they don't support".
83
+ * A hand-rolled host store therefore satisfies `OutboxStore` structurally, compiles,
84
+ * runs, and persists the event row OUTSIDE the caller's transaction.
85
+ *
86
+ * Points are STORED VALUE, so that lands on the worst path this kernel has.
87
+ * `earn` / `redeem` / `reverse` `$inc` the member balance and insert the ledger row
88
+ * in ONE transaction while `dispatchLoyaltyEvent` saves the event in-session
89
+ * (`events/dispatch.ts`), so the row must vanish with them on rollback. A store that
90
+ * drops the session emits `loyalty.points.earned` for a balance that rolled back —
91
+ * a tier recalculation or a notification then credits a customer with points the
92
+ * ledger never recorded. Nothing throws.
93
+ *
94
+ * `dispatch.ts` treats an outbox as licence for "durable-relay-only mode" (a bare
95
+ * host session with no PENDING_EVENTS queue is legal *because* the row is assumed to
96
+ * commit with the host transaction) — that assumption is exactly what this verifies.
97
+ * `UnmanagedSessionError` guards the OPPOSITE hole (session, NO outbox) and cannot
98
+ * see this one: the store is present and `save()` resolves.
99
+ *
100
+ * NOT waived by `allowNonTransactional` — that opts into standalone-Mongo partial
101
+ * writes, a different requirement entirely.
102
+ *
103
+ * `!== true`, not `=== false`: a hand-rolled store has the flag ABSENT.
104
+ */
105
+ 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)"]);
106
+ const models = modelBlueprint.bind(connection);
107
+ const allowNonTransactional = runtime.allowNonTransactional ?? false;
108
+ const unitOfWork = new MongoUnitOfWork(connection, allowNonTransactional);
109
+ const ownsBus = runtime.eventTransport === void 0;
110
+ const events = runtime.eventTransport ?? new InProcessLoyaltyBus();
111
+ const repositories = createRepositories({
112
+ models,
113
+ plugins: runtime.plugins,
114
+ tenant: resolvedConfig.tenant,
115
+ config: resolvedConfig,
116
+ events,
117
+ outbox: runtime.outbox,
118
+ notifications: runtime.notifications ?? null,
119
+ unitOfWork
120
+ });
121
+ {
122
+ const caps = repositories.pointTransaction.capabilities;
123
+ if (!caps) throw new LoyaltyCapabilityError(["capabilities descriptor (repo-core 0.6 / mongokit 3.16+) — backend declares none"]);
124
+ if (caps.transactions !== true) {
125
+ const indeterminate = transactionResolutionOf(caps) === "unknown";
126
+ 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.`);
127
+ else throw new LoyaltyCapabilityError(["transactions"], { indeterminate });
128
+ }
129
+ }
130
+ let closed = false;
131
+ return {
132
+ models,
133
+ repositories,
134
+ events,
135
+ async syncIndexes() {
136
+ await Promise.all(Object.values(models).map((m) => m.createIndexes()));
137
+ },
138
+ async close() {
139
+ if (closed) return;
140
+ closed = true;
141
+ if (ownsBus) await events.close?.();
142
+ }
143
+ };
97
144
  }
98
145
  };
99
146
  }
100
147
  //#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 };
148
+ 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 };