@happyvertical/smrt-sales 0.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/AGENTS.md +87 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/__smrt-register__.d.ts +2 -0
  6. package/dist/__smrt-register__.d.ts.map +1 -0
  7. package/dist/chunks/__smrt-register__-kWdT7LOt.js +6 -0
  8. package/dist/chunks/__smrt-register__-kWdT7LOt.js.map +1 -0
  9. package/dist/chunks/commissions-BCxqOKVM.js +2181 -0
  10. package/dist/chunks/commissions-BCxqOKVM.js.map +1 -0
  11. package/dist/chunks/crm-DwEz7E2r.js +1428 -0
  12. package/dist/chunks/crm-DwEz7E2r.js.map +1 -0
  13. package/dist/chunks/referrals-BY47p70T.js +2880 -0
  14. package/dist/chunks/referrals-BY47p70T.js.map +1 -0
  15. package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts +26 -0
  16. package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts.map +1 -0
  17. package/dist/commissions/collections/CommissionCollection.d.ts +43 -0
  18. package/dist/commissions/collections/CommissionCollection.d.ts.map +1 -0
  19. package/dist/commissions/collections/CommissionPayoutCollection.d.ts +19 -0
  20. package/dist/commissions/collections/CommissionPayoutCollection.d.ts.map +1 -0
  21. package/dist/commissions/collections/CommissionPlanCollection.d.ts +46 -0
  22. package/dist/commissions/collections/CommissionPlanCollection.d.ts.map +1 -0
  23. package/dist/commissions/collections/EarnerCollection.d.ts +14 -0
  24. package/dist/commissions/collections/EarnerCollection.d.ts.map +1 -0
  25. package/dist/commissions/collections/EarningEventCollection.d.ts +27 -0
  26. package/dist/commissions/collections/EarningEventCollection.d.ts.map +1 -0
  27. package/dist/commissions/collections/index.d.ts +11 -0
  28. package/dist/commissions/collections/index.d.ts.map +1 -0
  29. package/dist/commissions/index.d.ts +20 -0
  30. package/dist/commissions/index.d.ts.map +1 -0
  31. package/dist/commissions/models/Commission.d.ts +150 -0
  32. package/dist/commissions/models/Commission.d.ts.map +1 -0
  33. package/dist/commissions/models/CommissionAdjustment.d.ts +62 -0
  34. package/dist/commissions/models/CommissionAdjustment.d.ts.map +1 -0
  35. package/dist/commissions/models/CommissionPayout.d.ts +119 -0
  36. package/dist/commissions/models/CommissionPayout.d.ts.map +1 -0
  37. package/dist/commissions/models/CommissionPlan.d.ts +117 -0
  38. package/dist/commissions/models/CommissionPlan.d.ts.map +1 -0
  39. package/dist/commissions/models/Earner.d.ts +49 -0
  40. package/dist/commissions/models/Earner.d.ts.map +1 -0
  41. package/dist/commissions/models/EarningEvent.d.ts +89 -0
  42. package/dist/commissions/models/EarningEvent.d.ts.map +1 -0
  43. package/dist/commissions/models/index.d.ts +11 -0
  44. package/dist/commissions/models/index.d.ts.map +1 -0
  45. package/dist/commissions/money.d.ts +42 -0
  46. package/dist/commissions/money.d.ts.map +1 -0
  47. package/dist/commissions/services/CommissionBalanceService.d.ts +14 -0
  48. package/dist/commissions/services/CommissionBalanceService.d.ts.map +1 -0
  49. package/dist/commissions/services/CommissionCalculationService.d.ts +142 -0
  50. package/dist/commissions/services/CommissionCalculationService.d.ts.map +1 -0
  51. package/dist/commissions/services/CommissionPayoutService.d.ts +134 -0
  52. package/dist/commissions/services/CommissionPayoutService.d.ts.map +1 -0
  53. package/dist/commissions/services/CommissionSettlementService.d.ts +42 -0
  54. package/dist/commissions/services/CommissionSettlementService.d.ts.map +1 -0
  55. package/dist/commissions/services/index.d.ts +9 -0
  56. package/dist/commissions/services/index.d.ts.map +1 -0
  57. package/dist/commissions/types.d.ts +243 -0
  58. package/dist/commissions/types.d.ts.map +1 -0
  59. package/dist/commissions.d.ts +2 -0
  60. package/dist/commissions.d.ts.map +1 -0
  61. package/dist/commissions.js +3 -0
  62. package/dist/crm/collections/LeadCollection.d.ts +79 -0
  63. package/dist/crm/collections/LeadCollection.d.ts.map +1 -0
  64. package/dist/crm/collections/OpportunityCollection.d.ts +42 -0
  65. package/dist/crm/collections/OpportunityCollection.d.ts.map +1 -0
  66. package/dist/crm/collections/OpportunityConversionCollection.d.ts +26 -0
  67. package/dist/crm/collections/OpportunityConversionCollection.d.ts.map +1 -0
  68. package/dist/crm/collections/PipelineDefinitionCollection.d.ts +46 -0
  69. package/dist/crm/collections/PipelineDefinitionCollection.d.ts.map +1 -0
  70. package/dist/crm/collections/PipelineStageCollection.d.ts +22 -0
  71. package/dist/crm/collections/PipelineStageCollection.d.ts.map +1 -0
  72. package/dist/crm/collections/SalesActivityCollection.d.ts +24 -0
  73. package/dist/crm/collections/SalesActivityCollection.d.ts.map +1 -0
  74. package/dist/crm/collections/SalesRepresentativeCollection.d.ts +19 -0
  75. package/dist/crm/collections/SalesRepresentativeCollection.d.ts.map +1 -0
  76. package/dist/crm/index.d.ts +27 -0
  77. package/dist/crm/index.d.ts.map +1 -0
  78. package/dist/crm/models/Lead.d.ts +135 -0
  79. package/dist/crm/models/Lead.d.ts.map +1 -0
  80. package/dist/crm/models/Opportunity.d.ts +111 -0
  81. package/dist/crm/models/Opportunity.d.ts.map +1 -0
  82. package/dist/crm/models/OpportunityConversion.d.ts +66 -0
  83. package/dist/crm/models/OpportunityConversion.d.ts.map +1 -0
  84. package/dist/crm/models/PipelineDefinition.d.ts +58 -0
  85. package/dist/crm/models/PipelineDefinition.d.ts.map +1 -0
  86. package/dist/crm/models/PipelineStage.d.ts +66 -0
  87. package/dist/crm/models/PipelineStage.d.ts.map +1 -0
  88. package/dist/crm/models/SalesActivity.d.ts +76 -0
  89. package/dist/crm/models/SalesActivity.d.ts.map +1 -0
  90. package/dist/crm/models/SalesRepresentative.d.ts +60 -0
  91. package/dist/crm/models/SalesRepresentative.d.ts.map +1 -0
  92. package/dist/crm/types.d.ts +228 -0
  93. package/dist/crm/types.d.ts.map +1 -0
  94. package/dist/crm.d.ts +2 -0
  95. package/dist/crm.d.ts.map +1 -0
  96. package/dist/crm.js +3 -0
  97. package/dist/index.d.ts +4 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +5 -0
  100. package/dist/manifest.json +9340 -0
  101. package/dist/referrals/collections/AttributionExceptionCollection.d.ts +17 -0
  102. package/dist/referrals/collections/AttributionExceptionCollection.d.ts.map +1 -0
  103. package/dist/referrals/collections/AttributionPolicyCollection.d.ts +50 -0
  104. package/dist/referrals/collections/AttributionPolicyCollection.d.ts.map +1 -0
  105. package/dist/referrals/collections/ReferralAgreementCollection.d.ts +48 -0
  106. package/dist/referrals/collections/ReferralAgreementCollection.d.ts.map +1 -0
  107. package/dist/referrals/collections/ReferralCollection.d.ts +25 -0
  108. package/dist/referrals/collections/ReferralCollection.d.ts.map +1 -0
  109. package/dist/referrals/collections/ReferralLinkCollection.d.ts +96 -0
  110. package/dist/referrals/collections/ReferralLinkCollection.d.ts.map +1 -0
  111. package/dist/referrals/collections/ReferralProgramCollection.d.ts +18 -0
  112. package/dist/referrals/collections/ReferralProgramCollection.d.ts.map +1 -0
  113. package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts +13 -0
  114. package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts.map +1 -0
  115. package/dist/referrals/collections/ReferralTouchCollection.d.ts +30 -0
  116. package/dist/referrals/collections/ReferralTouchCollection.d.ts.map +1 -0
  117. package/dist/referrals/collections/ReferrerCollection.d.ts +17 -0
  118. package/dist/referrals/collections/ReferrerCollection.d.ts.map +1 -0
  119. package/dist/referrals/index.d.ts +38 -0
  120. package/dist/referrals/index.d.ts.map +1 -0
  121. package/dist/referrals/models/AttributionException.d.ts +85 -0
  122. package/dist/referrals/models/AttributionException.d.ts.map +1 -0
  123. package/dist/referrals/models/AttributionPolicy.d.ts +128 -0
  124. package/dist/referrals/models/AttributionPolicy.d.ts.map +1 -0
  125. package/dist/referrals/models/Referral.d.ts +112 -0
  126. package/dist/referrals/models/Referral.d.ts.map +1 -0
  127. package/dist/referrals/models/ReferralAgreement.d.ts +125 -0
  128. package/dist/referrals/models/ReferralAgreement.d.ts.map +1 -0
  129. package/dist/referrals/models/ReferralLink.d.ts +71 -0
  130. package/dist/referrals/models/ReferralLink.d.ts.map +1 -0
  131. package/dist/referrals/models/ReferralProgram.d.ts +57 -0
  132. package/dist/referrals/models/ReferralProgram.d.ts.map +1 -0
  133. package/dist/referrals/models/ReferralTermSnapshot.d.ts +61 -0
  134. package/dist/referrals/models/ReferralTermSnapshot.d.ts.map +1 -0
  135. package/dist/referrals/models/ReferralTouch.d.ts +55 -0
  136. package/dist/referrals/models/ReferralTouch.d.ts.map +1 -0
  137. package/dist/referrals/models/Referrer.d.ts +69 -0
  138. package/dist/referrals/models/Referrer.d.ts.map +1 -0
  139. package/dist/referrals/services/AttributionService.d.ts +248 -0
  140. package/dist/referrals/services/AttributionService.d.ts.map +1 -0
  141. package/dist/referrals/services/ReferralCommissionService.d.ts +91 -0
  142. package/dist/referrals/services/ReferralCommissionService.d.ts.map +1 -0
  143. package/dist/referrals/services/ReferralQualificationService.d.ts +135 -0
  144. package/dist/referrals/services/ReferralQualificationService.d.ts.map +1 -0
  145. package/dist/referrals/types.d.ts +226 -0
  146. package/dist/referrals/types.d.ts.map +1 -0
  147. package/dist/referrals.d.ts +2 -0
  148. package/dist/referrals.d.ts.map +1 -0
  149. package/dist/referrals.js +3 -0
  150. package/dist/smrt-knowledge.json +5501 -0
  151. package/dist/svelte/__tests__/format.test.js +68 -0
  152. package/dist/svelte/__tests__/types.test.js +349 -0
  153. package/dist/svelte/components/AttributionConflictQueue.svelte +361 -0
  154. package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts +15 -0
  155. package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts.map +1 -0
  156. package/dist/svelte/components/CommissionBreakdown.svelte +251 -0
  157. package/dist/svelte/components/CommissionBreakdown.svelte.d.ts +11 -0
  158. package/dist/svelte/components/CommissionBreakdown.svelte.d.ts.map +1 -0
  159. package/dist/svelte/components/CommissionExpenseSummary.svelte +186 -0
  160. package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts +13 -0
  161. package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts.map +1 -0
  162. package/dist/svelte/components/ExecutedAgreementsList.svelte +136 -0
  163. package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts +11 -0
  164. package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts.map +1 -0
  165. package/dist/svelte/components/LeadList.svelte +224 -0
  166. package/dist/svelte/components/LeadList.svelte.d.ts +19 -0
  167. package/dist/svelte/components/LeadList.svelte.d.ts.map +1 -0
  168. package/dist/svelte/components/OpportunityBoard.svelte +230 -0
  169. package/dist/svelte/components/OpportunityBoard.svelte.d.ts +17 -0
  170. package/dist/svelte/components/OpportunityBoard.svelte.d.ts.map +1 -0
  171. package/dist/svelte/components/OpportunityDetail.svelte +472 -0
  172. package/dist/svelte/components/OpportunityDetail.svelte.d.ts +27 -0
  173. package/dist/svelte/components/OpportunityDetail.svelte.d.ts.map +1 -0
  174. package/dist/svelte/components/PayoutBatchReview.svelte +285 -0
  175. package/dist/svelte/components/PayoutBatchReview.svelte.d.ts +21 -0
  176. package/dist/svelte/components/PayoutBatchReview.svelte.d.ts.map +1 -0
  177. package/dist/svelte/components/PayoutHistoryList.svelte +245 -0
  178. package/dist/svelte/components/PayoutHistoryList.svelte.d.ts +11 -0
  179. package/dist/svelte/components/PayoutHistoryList.svelte.d.ts.map +1 -0
  180. package/dist/svelte/components/ReferralLinkManager.svelte +216 -0
  181. package/dist/svelte/components/ReferralLinkManager.svelte.d.ts +17 -0
  182. package/dist/svelte/components/ReferralLinkManager.svelte.d.ts.map +1 -0
  183. package/dist/svelte/components/ReferralStatusList.svelte +118 -0
  184. package/dist/svelte/components/ReferralStatusList.svelte.d.ts +11 -0
  185. package/dist/svelte/components/ReferralStatusList.svelte.d.ts.map +1 -0
  186. package/dist/svelte/components/ReferrerEarningsSummary.svelte +106 -0
  187. package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts +11 -0
  188. package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts.map +1 -0
  189. package/dist/svelte/components/SalesDashboard.svelte +192 -0
  190. package/dist/svelte/components/SalesDashboard.svelte.d.ts +29 -0
  191. package/dist/svelte/components/SalesDashboard.svelte.d.ts.map +1 -0
  192. package/dist/svelte/format.d.ts +46 -0
  193. package/dist/svelte/format.d.ts.map +1 -0
  194. package/dist/svelte/format.js +75 -0
  195. package/dist/svelte/index.d.ts +50 -0
  196. package/dist/svelte/index.d.ts.map +1 -0
  197. package/dist/svelte/index.js +37 -0
  198. package/dist/svelte/types.d.ts +422 -0
  199. package/dist/svelte/types.d.ts.map +1 -0
  200. package/dist/svelte/types.js +376 -0
  201. package/package.json +89 -0
@@ -0,0 +1,2880 @@
1
+ import { D as CommissionCollection, p as CommissionPlanCollection, r as CommissionCalculationService } from "./commissions-BCxqOKVM.js";
2
+ import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, smrt } from "@happyvertical/smrt-core";
3
+ import { TenantScoped, tenantId } from "@happyvertical/smrt-tenancy";
4
+ import { randomBytes, randomUUID } from "node:crypto";
5
+ //#region src/referrals/models/AttributionException.ts
6
+ var __defProp$8 = Object.defineProperty;
7
+ var __getOwnPropDesc$8 = Object.getOwnPropertyDescriptor;
8
+ var __decorateClass$8 = (decorators, target, key, kind) => {
9
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$8(target, key) : target;
10
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
11
+ if (kind && result) __defProp$8(target, key, result);
12
+ return result;
13
+ };
14
+ var AttributionException = class extends SmrtObject {
15
+ tenantId = null;
16
+ targetKind = "";
17
+ targetId = "";
18
+ programId = "";
19
+ /** `open` (default, awaiting review) or `resolved`. */
20
+ status = "open";
21
+ /**
22
+ * Why the exception was raised — recommended vocabulary: `'tie'`
23
+ * (exact-timestamp tie between distinct referrers),
24
+ * `'competing_touches'` (competing manual assignments), and
25
+ * `'policy_review'` (`conflictBehavior: 'review'` with multiple
26
+ * referrers). Open string.
27
+ */
28
+ conflictReason = "";
29
+ /**
30
+ * The competing touches as a JSON-string array of
31
+ * {@link AttributionExceptionCandidate}. Use
32
+ * {@link getCandidates}/{@link setCandidates}.
33
+ */
34
+ candidates = "[]";
35
+ /**
36
+ * How the exception was resolved — `'override'` (human award) or
37
+ * `'policy'` (policy re-run). Empty while open.
38
+ */
39
+ resolutionMode = "";
40
+ /** Why credit was awarded the way it was. REQUIRED to resolve. */
41
+ resolutionReason = "";
42
+ resolvedByProfileId = "";
43
+ /**
44
+ * Ids of the Referral rows the resolution created, as a JSON-string
45
+ * array. Use {@link getResolvedReferralIds}/{@link setResolvedReferralIds}.
46
+ */
47
+ resolvedReferralIds = "[]";
48
+ /** When the exception was resolved. */
49
+ resolvedAt = null;
50
+ /**
51
+ * Free-form JSON object stored as a string (the attribution service
52
+ * stamps the policy pin here for later resolution). Use
53
+ * {@link getMetadata}/{@link setMetadata}.
54
+ */
55
+ metadata = "{}";
56
+ constructor(options = {}) {
57
+ super(options);
58
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
59
+ if (options.targetKind !== void 0) this.targetKind = options.targetKind;
60
+ if (options.targetId !== void 0) this.targetId = options.targetId;
61
+ if (options.programId !== void 0) this.programId = options.programId;
62
+ if (options.status !== void 0) this.status = options.status;
63
+ if (options.conflictReason !== void 0) this.conflictReason = options.conflictReason;
64
+ if (options.candidates !== void 0) this.candidates = options.candidates;
65
+ if (options.resolutionMode !== void 0) this.resolutionMode = options.resolutionMode;
66
+ if (options.resolutionReason !== void 0) this.resolutionReason = options.resolutionReason;
67
+ if (options.resolvedByProfileId !== void 0) this.resolvedByProfileId = options.resolvedByProfileId;
68
+ if (options.resolvedReferralIds !== void 0) this.resolvedReferralIds = options.resolvedReferralIds;
69
+ if (options.resolvedAt !== void 0) this.resolvedAt = AttributionException.coerceDate(options.resolvedAt);
70
+ if (options.metadata !== void 0) this.metadata = options.metadata;
71
+ }
72
+ /** Re-coerce date fields after the framework reapplies raw option values. */
73
+ async initialize() {
74
+ await super.initialize();
75
+ this.resolvedAt = AttributionException.coerceDate(this.resolvedAt);
76
+ return this;
77
+ }
78
+ /** Whether the exception still awaits review. */
79
+ isOpen() {
80
+ return this.status === "open";
81
+ }
82
+ /** Parse {@link candidates}; returns `[]` on empty/invalid JSON. */
83
+ getCandidates() {
84
+ if (!this.candidates) return [];
85
+ try {
86
+ const parsed = JSON.parse(this.candidates);
87
+ return Array.isArray(parsed) ? parsed : [];
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+ /** Serialize and store {@link candidates}. */
93
+ setCandidates(candidates) {
94
+ this.candidates = JSON.stringify(candidates ?? []);
95
+ }
96
+ /** Parse {@link resolvedReferralIds}; returns `[]` on empty/invalid JSON. */
97
+ getResolvedReferralIds() {
98
+ if (!this.resolvedReferralIds) return [];
99
+ try {
100
+ const parsed = JSON.parse(this.resolvedReferralIds);
101
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+ /** Serialize and store {@link resolvedReferralIds}. */
107
+ setResolvedReferralIds(ids) {
108
+ this.resolvedReferralIds = JSON.stringify(ids ?? []);
109
+ }
110
+ /** Parse {@link metadata}; returns `{}` on malformed content. */
111
+ getMetadata() {
112
+ if (!this.metadata) return {};
113
+ try {
114
+ const parsed = JSON.parse(this.metadata);
115
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
116
+ } catch {
117
+ return {};
118
+ }
119
+ }
120
+ /** Serialize and store {@link metadata}. */
121
+ setMetadata(data) {
122
+ this.metadata = JSON.stringify(data ?? {});
123
+ }
124
+ /**
125
+ * Mark the exception resolved (mutates; the caller saves). Requires a
126
+ * non-empty reason — resolutions without a recorded rationale are
127
+ * rejected.
128
+ */
129
+ markResolved(input) {
130
+ if (this.status !== "open") throw new Error(`AttributionException ${this.id ?? "<new>"}: cannot resolve from status '${this.status}'`);
131
+ if (!input.reason?.trim()) throw new Error(`AttributionException ${this.id ?? "<new>"}: a non-empty resolutionReason is required`);
132
+ this.status = "resolved";
133
+ this.resolutionMode = input.mode;
134
+ this.resolutionReason = input.reason;
135
+ this.resolvedByProfileId = input.resolvedByProfileId ?? "";
136
+ this.setResolvedReferralIds(input.referralIds);
137
+ this.resolvedAt = input.at ?? /* @__PURE__ */ new Date();
138
+ }
139
+ static coerceDate(value) {
140
+ if (value == null) return null;
141
+ if (value instanceof Date) return value;
142
+ if (typeof value === "number" || typeof value === "string") {
143
+ const d = new Date(value);
144
+ return Number.isNaN(d.getTime()) ? null : d;
145
+ }
146
+ return null;
147
+ }
148
+ };
149
+ __decorateClass$8([tenantId({ nullable: true })], AttributionException.prototype, "tenantId", 2);
150
+ __decorateClass$8([field({ required: true })], AttributionException.prototype, "targetKind", 2);
151
+ __decorateClass$8([field({ required: true })], AttributionException.prototype, "targetId", 2);
152
+ __decorateClass$8([foreignKey("ReferralProgram")], AttributionException.prototype, "programId", 2);
153
+ __decorateClass$8([crossPackageRef("@happyvertical/smrt-profiles:Profile")], AttributionException.prototype, "resolvedByProfileId", 2);
154
+ AttributionException = __decorateClass$8([TenantScoped({ mode: "optional" }), smrt({
155
+ api: { include: ["list", "get"] },
156
+ mcp: { include: ["list", "get"] },
157
+ cli: false
158
+ })], AttributionException);
159
+ //#endregion
160
+ //#region src/referrals/collections/AttributionExceptionCollection.ts
161
+ var AttributionExceptionCollection = class extends SmrtCollection {
162
+ static _itemClass = AttributionException;
163
+ /** Every open exception, oldest first (the review queue). */
164
+ async findOpen() {
165
+ return await this.list({
166
+ where: { status: "open" },
167
+ orderBy: "created_at ASC"
168
+ });
169
+ }
170
+ /**
171
+ * The open exception for one target within one program, or `null`.
172
+ * `AttributionService.resolve()` returns this instead of minting a
173
+ * duplicate when a conflict is already parked for review.
174
+ */
175
+ async findOpenByTarget(targetKind, targetId, programId) {
176
+ return (await this.list({
177
+ where: {
178
+ targetKind,
179
+ targetId,
180
+ programId,
181
+ status: "open"
182
+ },
183
+ orderBy: "created_at ASC",
184
+ limit: 1
185
+ }))[0] ?? null;
186
+ }
187
+ /** Every exception (open and resolved) for one target, newest first. */
188
+ async findByTarget(targetKind, targetId) {
189
+ return await this.list({
190
+ where: {
191
+ targetKind,
192
+ targetId
193
+ },
194
+ orderBy: "created_at DESC"
195
+ });
196
+ }
197
+ };
198
+ //#endregion
199
+ //#region src/referrals/types.ts
200
+ var REFERRER_STATUSES = [
201
+ "pending",
202
+ "active",
203
+ "suspended"
204
+ ];
205
+ var REFERRAL_PROGRAM_STATUSES = [
206
+ "draft",
207
+ "active",
208
+ "paused",
209
+ "archived"
210
+ ];
211
+ var ATTRIBUTION_POLICY_STATUSES = [
212
+ "draft",
213
+ "active",
214
+ "superseded",
215
+ "retired"
216
+ ];
217
+ var ATTRIBUTION_CREDIT_MODES = [
218
+ "first_touch",
219
+ "last_touch",
220
+ "assigned",
221
+ "split"
222
+ ];
223
+ var ATTRIBUTION_CONFLICT_BEHAVIORS = ["auto", "review"];
224
+ var REFERRAL_LINK_STATUSES = ["active", "disabled"];
225
+ var REFERRAL_TOUCH_KINDS = [
226
+ "click",
227
+ "code_entry",
228
+ "manual_assignment",
229
+ "partner_entry"
230
+ ];
231
+ var REFERRAL_STATUSES = [
232
+ "pending",
233
+ "attributed",
234
+ "qualified",
235
+ "disqualified",
236
+ "expired",
237
+ "under_review"
238
+ ];
239
+ var ATTRIBUTION_EXCEPTION_STATUSES = ["open", "resolved"];
240
+ var ATTRIBUTION_RESOLUTION_MODES = ["override", "policy"];
241
+ var REFERRAL_AGREEMENT_STATUSES = [
242
+ "draft",
243
+ "active",
244
+ "superseded",
245
+ "terminated"
246
+ ];
247
+ var REFERRAL_AGREEMENT_APPROVAL_MODES = ["manual", "auto"];
248
+ //#endregion
249
+ //#region src/referrals/models/AttributionPolicy.ts
250
+ var __defProp$7 = Object.defineProperty;
251
+ var __getOwnPropDesc$7 = Object.getOwnPropertyDescriptor;
252
+ var __decorateClass$7 = (decorators, target, key, kind) => {
253
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$7(target, key) : target;
254
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
255
+ if (kind && result) __defProp$7(target, key, result);
256
+ return result;
257
+ };
258
+ var POLICY_STATUS_TRANSITIONS = {
259
+ draft: ["active", "retired"],
260
+ active: ["superseded", "retired"],
261
+ superseded: [],
262
+ retired: []
263
+ };
264
+ var loadedPolicyStatus = /* @__PURE__ */ new WeakMap();
265
+ var frozenPolicySnapshot = /* @__PURE__ */ new WeakMap();
266
+ function validateAttributionPolicyTerms(policy) {
267
+ if (!Number.isInteger(policy.windowDays) || policy.windowDays <= 0) throw new Error(`AttributionPolicy ${policy.policyKey}@${policy.version}: windowDays must be a positive integer`);
268
+ if (!ATTRIBUTION_CREDIT_MODES.includes(policy.creditMode)) throw new Error(`AttributionPolicy ${policy.policyKey}@${policy.version}: invalid creditMode '${policy.creditMode}'`);
269
+ if (!ATTRIBUTION_CONFLICT_BEHAVIORS.includes(policy.conflictBehavior)) throw new Error(`AttributionPolicy ${policy.policyKey}@${policy.version}: invalid conflictBehavior '${policy.conflictBehavior}'`);
270
+ for (const [name, raw] of [
271
+ ["eligibleServices", policy.eligibleServices],
272
+ ["eligibleCampaigns", policy.eligibleCampaigns],
273
+ ["eligibleRegions", policy.eligibleRegions]
274
+ ]) {
275
+ let parsed;
276
+ try {
277
+ parsed = JSON.parse(raw || "[]");
278
+ } catch {
279
+ throw new Error(`AttributionPolicy ${policy.policyKey}@${policy.version}: ${name} must be a JSON array of strings`);
280
+ }
281
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error(`AttributionPolicy ${policy.policyKey}@${policy.version}: ${name} must be a JSON array of strings`);
282
+ }
283
+ }
284
+ var AttributionPolicy = class extends SmrtObject {
285
+ tenantId = null;
286
+ policyKey = "";
287
+ /** Monotonic version within `policyKey`. Amendments insert `max + 1`. */
288
+ version = 1;
289
+ /**
290
+ * Lifecycle status — see {@link POLICY_STATUS_TRANSITIONS}. Mutate via
291
+ * {@link activate} / {@link supersede} / {@link retire} (or a legal
292
+ * single-step assignment; the save-time guard rejects illegal edges).
293
+ */
294
+ status = "draft";
295
+ /** When this version takes effect. Frozen once the policy activates. */
296
+ effectiveFrom = null;
297
+ /**
298
+ * Attribution window in days: only touches occurring within `windowDays`
299
+ * of the resolution instant are credit candidates, and attributed
300
+ * referrals expire `windowDays` after attribution if never qualified.
301
+ */
302
+ windowDays = 30;
303
+ /** How competing touches are credited. */
304
+ creditMode = "first_touch";
305
+ /** Whether multi-referrer contention auto-resolves or forces review. */
306
+ conflictBehavior = "auto";
307
+ /**
308
+ * Whether a touch whose referrer IS the prospect (matching
309
+ * `subjectProfileId`) may earn credit. Default false: self-referrals are
310
+ * dropped from candidate sets.
311
+ */
312
+ allowSelfReferral = false;
313
+ /**
314
+ * Whether referrals of already-existing clients are eligible. Default
315
+ * false: `AttributionService.resolve()` refuses outright when the caller
316
+ * flags the prospect as an existing client.
317
+ */
318
+ allowExistingClients = false;
319
+ /**
320
+ * Eligible service keys as a JSON-string array — empty array means ALL
321
+ * services are eligible. Interpretation belongs to the application layer
322
+ * that maps its offerings onto service keys; this module records and
323
+ * validates the list. Use {@link getEligibleServices}/{@link setEligibleServices}.
324
+ */
325
+ eligibleServices = "[]";
326
+ /** Eligible campaign keys (JSON-string array; empty = all eligible). */
327
+ eligibleCampaigns = "[]";
328
+ /** Eligible region codes (JSON-string array; empty = all eligible). */
329
+ eligibleRegions = "[]";
330
+ /** Additional metadata as a JSON string (not part of the frozen identity). */
331
+ metadata = "{}";
332
+ constructor(options = {}) {
333
+ super(options);
334
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
335
+ if (options.policyKey !== void 0) this.policyKey = options.policyKey;
336
+ if (options.version !== void 0) this.version = options.version;
337
+ if (options.status !== void 0) this.status = options.status;
338
+ if (options.effectiveFrom !== void 0) this.effectiveFrom = AttributionPolicy.coerceDate(options.effectiveFrom);
339
+ if (options.windowDays !== void 0) this.windowDays = options.windowDays;
340
+ if (options.creditMode !== void 0) this.creditMode = options.creditMode;
341
+ if (options.conflictBehavior !== void 0) this.conflictBehavior = options.conflictBehavior;
342
+ if (options.allowSelfReferral !== void 0) this.allowSelfReferral = options.allowSelfReferral;
343
+ if (options.allowExistingClients !== void 0) this.allowExistingClients = options.allowExistingClients;
344
+ if (options.eligibleServices !== void 0) this.eligibleServices = options.eligibleServices;
345
+ if (options.eligibleCampaigns !== void 0) this.eligibleCampaigns = options.eligibleCampaigns;
346
+ if (options.eligibleRegions !== void 0) this.eligibleRegions = options.eligibleRegions;
347
+ if (options.metadata !== void 0) this.metadata = options.metadata;
348
+ }
349
+ /**
350
+ * Re-coerce date fields after the framework reapplies raw option values,
351
+ * record the loaded status for the transition guard, and capture the
352
+ * frozen snapshot when the row arrived already non-draft (superseded and
353
+ * retired versions — history — can't be rewritten either).
354
+ */
355
+ async initialize() {
356
+ await super.initialize();
357
+ this.effectiveFrom = AttributionPolicy.coerceDate(this.effectiveFrom);
358
+ if (await this.isSaved()) {
359
+ loadedPolicyStatus.set(this, this.status);
360
+ if (this.status !== "draft") frozenPolicySnapshot.set(this, this.serializeFrozenSnapshot());
361
+ }
362
+ return this;
363
+ }
364
+ isDraft() {
365
+ return this.status === "draft";
366
+ }
367
+ isActive() {
368
+ return this.status === "active";
369
+ }
370
+ /** Parse {@link eligibleServices}; returns `[]` on empty/invalid JSON. */
371
+ getEligibleServices() {
372
+ return AttributionPolicy.parseStringArray(this.eligibleServices);
373
+ }
374
+ /** Serialize and store {@link eligibleServices}. */
375
+ setEligibleServices(services) {
376
+ this.eligibleServices = JSON.stringify(services ?? []);
377
+ }
378
+ /** Parse {@link eligibleCampaigns}; returns `[]` on empty/invalid JSON. */
379
+ getEligibleCampaigns() {
380
+ return AttributionPolicy.parseStringArray(this.eligibleCampaigns);
381
+ }
382
+ /** Serialize and store {@link eligibleCampaigns}. */
383
+ setEligibleCampaigns(campaigns) {
384
+ this.eligibleCampaigns = JSON.stringify(campaigns ?? []);
385
+ }
386
+ /** Parse {@link eligibleRegions}; returns `[]` on empty/invalid JSON. */
387
+ getEligibleRegions() {
388
+ return AttributionPolicy.parseStringArray(this.eligibleRegions);
389
+ }
390
+ /** Serialize and store {@link eligibleRegions}. */
391
+ setEligibleRegions(regions) {
392
+ this.eligibleRegions = JSON.stringify(regions ?? []);
393
+ }
394
+ /** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
395
+ getMetadata() {
396
+ if (!this.metadata) return {};
397
+ try {
398
+ const parsed = JSON.parse(this.metadata);
399
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
400
+ } catch {
401
+ return {};
402
+ }
403
+ }
404
+ /** Serialize and store {@link metadata}. */
405
+ setMetadata(data) {
406
+ this.metadata = JSON.stringify(data ?? {});
407
+ }
408
+ /**
409
+ * Transition `draft → active`. Validates the terms first so no active
410
+ * policy can carry malformed window/mode/eligibility values.
411
+ */
412
+ activate() {
413
+ if (this.status !== "draft") throw new Error(`AttributionPolicy ${this.policyKey}@${this.version}: cannot activate from status '${this.status}'`);
414
+ validateAttributionPolicyTerms(this);
415
+ this.status = "active";
416
+ }
417
+ /** Transition `active → superseded` (a newer version took over). */
418
+ supersede() {
419
+ if (this.status !== "active") throw new Error(`AttributionPolicy ${this.policyKey}@${this.version}: cannot supersede from status '${this.status}'`);
420
+ this.status = "superseded";
421
+ }
422
+ /** Transition `draft | active → retired` (terminal). */
423
+ retire() {
424
+ if (this.status !== "draft" && this.status !== "active") throw new Error(`AttributionPolicy ${this.policyKey}@${this.version}: cannot retire from status '${this.status}'`);
425
+ this.status = "retired";
426
+ }
427
+ /**
428
+ * Save with two guards (CommissionPlan pattern):
429
+ *
430
+ * 1. **Status transition** — the about-to-be-written status must be a
431
+ * legal edge from the authoritative prior persisted status (re-read
432
+ * from the DB so a `create({ id: <existing>, _skipLoad: true })` upsert
433
+ * can't sidestep the guard).
434
+ * 2. **Frozen policy identity** — once the row has been saved non-draft,
435
+ * every policy-defining field must match the captured snapshot. Amend
436
+ * by inserting a new version instead.
437
+ */
438
+ async save() {
439
+ const prior = await this.resolvePriorStatus();
440
+ this.assertStatusTransition(prior);
441
+ this.assertFrozenIdentityUnchanged();
442
+ await this.assertNaturalKeyNotTaken();
443
+ if (this.status === "active") validateAttributionPolicyTerms(this);
444
+ const result = await super.save();
445
+ loadedPolicyStatus.set(this, this.status);
446
+ if (this.status !== "draft" && !frozenPolicySnapshot.has(this)) frozenPolicySnapshot.set(this, this.serializeFrozenSnapshot());
447
+ return result;
448
+ }
449
+ /**
450
+ * Refuse a save whose `(tenantId, policyKey, version)` natural key
451
+ * already belongs to a DIFFERENT row — the frozen-identity guard is
452
+ * instance-local, so a fresh instance would otherwise upsert over the
453
+ * persisted policy (rewriting attribution rules and rotating the row
454
+ * id). Edit drafts by hydrating them; change terms with
455
+ * `AttributionPolicyCollection.createAmendment()`.
456
+ */
457
+ async assertNaturalKeyNotTaken() {
458
+ if (!this.policyKey) return;
459
+ try {
460
+ const res = await this.db.query(`SELECT id, tenant_id FROM ${this.tableName} WHERE policy_key = $1 AND version = $2`, this.policyKey, this.version);
461
+ if ((Array.isArray(res) ? res : res.rows ?? []).find((row) => (row.tenant_id ?? null) === (this.tenantId ?? null) && row.id !== this.id)) throw new Error(`AttributionPolicy ${this.policyKey}@${this.version}: this version already exists for the tenant \u2014 policy versions are immutable records. Hydrate the existing row to edit a draft, or create new terms with AttributionPolicyCollection.createAmendment().`);
462
+ } catch (error) {
463
+ if (error instanceof Error && error.message.includes("immutable")) throw error;
464
+ }
465
+ }
466
+ /**
467
+ * Resolve the AUTHORITATIVE prior status from the database; fall back to
468
+ * the loaded-status WeakMap only when the DB is unavailable. `undefined`
469
+ * means no persisted row exists (genuinely new).
470
+ */
471
+ async resolvePriorStatus() {
472
+ if (this.id) try {
473
+ const row = await this.db.get(this.tableName, { id: this.id });
474
+ if (row && row.status != null) return row.status;
475
+ } catch {}
476
+ return loadedPolicyStatus.get(this);
477
+ }
478
+ assertStatusTransition(prior) {
479
+ if (prior === void 0) return;
480
+ if (prior === this.status) return;
481
+ if (!(POLICY_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`AttributionPolicy ${this.policyKey}@${this.version}: illegal status transition '${prior}' \u2192 '${this.status}'. Use activate() / supersede() / retire().`);
482
+ }
483
+ assertFrozenIdentityUnchanged() {
484
+ const captured = frozenPolicySnapshot.get(this);
485
+ if (!captured) return;
486
+ if (captured !== this.serializeFrozenSnapshot()) throw new Error(`AttributionPolicy ${this.policyKey}@${this.version}: the policy-defining fields are immutable once the policy has been active. Create an amendment (AttributionPolicyCollection.createAmendment) instead of editing this version.`);
487
+ }
488
+ serializeFrozenSnapshot() {
489
+ return JSON.stringify({
490
+ policyKey: this.policyKey,
491
+ version: this.version,
492
+ windowDays: this.windowDays,
493
+ creditMode: this.creditMode,
494
+ conflictBehavior: this.conflictBehavior,
495
+ allowSelfReferral: Boolean(this.allowSelfReferral),
496
+ allowExistingClients: Boolean(this.allowExistingClients),
497
+ eligibleServices: this.eligibleServices,
498
+ eligibleCampaigns: this.eligibleCampaigns,
499
+ eligibleRegions: this.eligibleRegions,
500
+ effectiveFrom: this.effectiveFrom ? this.effectiveFrom.toISOString() : null
501
+ });
502
+ }
503
+ static parseStringArray(raw) {
504
+ if (!raw) return [];
505
+ try {
506
+ const parsed = JSON.parse(raw);
507
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
508
+ } catch {
509
+ return [];
510
+ }
511
+ }
512
+ static coerceDate(value) {
513
+ if (value == null) return null;
514
+ if (value instanceof Date) return value;
515
+ if (typeof value === "number" || typeof value === "string") {
516
+ const d = new Date(value);
517
+ return Number.isNaN(d.getTime()) ? null : d;
518
+ }
519
+ return null;
520
+ }
521
+ };
522
+ __decorateClass$7([tenantId({ nullable: true })], AttributionPolicy.prototype, "tenantId", 2);
523
+ __decorateClass$7([field({ required: true })], AttributionPolicy.prototype, "policyKey", 2);
524
+ AttributionPolicy = __decorateClass$7([TenantScoped({ mode: "optional" }), smrt({
525
+ conflictColumns: [
526
+ "tenant_id",
527
+ "policy_key",
528
+ "version"
529
+ ],
530
+ api: { include: [
531
+ "list",
532
+ "get",
533
+ "create"
534
+ ] },
535
+ mcp: { include: ["list", "get"] },
536
+ cli: true
537
+ })], AttributionPolicy);
538
+ //#endregion
539
+ //#region src/referrals/collections/AttributionPolicyCollection.ts
540
+ var AttributionPolicyCollection = class extends SmrtCollection {
541
+ static _itemClass = AttributionPolicy;
542
+ /** Every version of a policy, newest version first. */
543
+ async findByPolicyKey(policyKey) {
544
+ return await this.list({
545
+ where: { policyKey },
546
+ orderBy: "version DESC"
547
+ });
548
+ }
549
+ /** Policies by status. */
550
+ async findByStatus(status) {
551
+ return await this.list({
552
+ where: { status },
553
+ orderBy: "created_at DESC"
554
+ });
555
+ }
556
+ /**
557
+ * The highest ACTIVE version of a policy already IN EFFECT at `at`, or
558
+ * `null` when none is. This is what `AttributionService.resolve()` loads
559
+ * when the caller doesn't pin a version. A future-dated amendment can be
560
+ * activated ahead of its effective date without governing attributions
561
+ * resolved before it takes effect (`effectiveFrom: null` = effective
562
+ * immediately).
563
+ */
564
+ async latestActiveByKey(policyKey, at = /* @__PURE__ */ new Date(), tenantId) {
565
+ const inEffect = (await this.list({
566
+ where: {
567
+ policyKey,
568
+ status: "active"
569
+ },
570
+ orderBy: "version DESC"
571
+ })).filter((policy) => policy.effectiveFrom === null || policy.effectiveFrom <= at);
572
+ if (tenantId === void 0) return inEffect[0] ?? null;
573
+ return inEffect.find((policy) => policy.tenantId === tenantId) ?? inEffect.find((policy) => policy.tenantId === null) ?? null;
574
+ }
575
+ /**
576
+ * Create an amendment: insert a new DRAFT row with
577
+ * `version = max(existing versions) + 1`, copying the latest existing
578
+ * version's fields and then applying `changes`. The source version is not
579
+ * touched — activate the draft (and supersede the prior active version)
580
+ * as a separate, explicit step.
581
+ *
582
+ * Throws when no version of `policyKey` exists (nothing to amend — use
583
+ * `create` for a brand-new policy).
584
+ */
585
+ async createAmendment(policyKey, changes = {}) {
586
+ const latest = (await this.findByPolicyKey(policyKey))[0];
587
+ if (!latest) throw new Error(`AttributionPolicyCollection.createAmendment: no versions exist for policy key '${policyKey}' \u2014 create the policy first`);
588
+ return await this.create({
589
+ tenantId: latest.tenantId,
590
+ policyKey,
591
+ version: latest.version + 1,
592
+ status: "draft",
593
+ windowDays: changes.windowDays ?? latest.windowDays,
594
+ creditMode: changes.creditMode ?? latest.creditMode,
595
+ conflictBehavior: changes.conflictBehavior ?? latest.conflictBehavior,
596
+ allowSelfReferral: changes.allowSelfReferral ?? latest.allowSelfReferral,
597
+ allowExistingClients: changes.allowExistingClients ?? latest.allowExistingClients,
598
+ eligibleServices: changes.eligibleServices !== void 0 ? JSON.stringify(changes.eligibleServices) : latest.eligibleServices,
599
+ eligibleCampaigns: changes.eligibleCampaigns !== void 0 ? JSON.stringify(changes.eligibleCampaigns) : latest.eligibleCampaigns,
600
+ eligibleRegions: changes.eligibleRegions !== void 0 ? JSON.stringify(changes.eligibleRegions) : latest.eligibleRegions,
601
+ effectiveFrom: changes.effectiveFrom !== void 0 ? changes.effectiveFrom : latest.effectiveFrom,
602
+ metadata: changes.metadata !== void 0 ? JSON.stringify(changes.metadata) : latest.metadata
603
+ });
604
+ }
605
+ };
606
+ //#endregion
607
+ //#region src/referrals/models/ReferralAgreement.ts
608
+ var __defProp$6 = Object.defineProperty;
609
+ var __getOwnPropDesc$6 = Object.getOwnPropertyDescriptor;
610
+ var __decorateClass$6 = (decorators, target, key, kind) => {
611
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$6(target, key) : target;
612
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
613
+ if (kind && result) __defProp$6(target, key, result);
614
+ return result;
615
+ };
616
+ var AGREEMENT_STATUS_TRANSITIONS = {
617
+ draft: ["active", "terminated"],
618
+ active: ["superseded", "terminated"],
619
+ superseded: [],
620
+ terminated: []
621
+ };
622
+ var loadedAgreementStatus = /* @__PURE__ */ new WeakMap();
623
+ var frozenAgreementSnapshot = /* @__PURE__ */ new WeakMap();
624
+ var ReferralAgreement = class extends SmrtObject {
625
+ tenantId = null;
626
+ referrerId = "";
627
+ programId = "";
628
+ /** Monotonic version within `(referrerId, programId)`. */
629
+ version = 1;
630
+ /**
631
+ * Lifecycle status — see the module transition map. Mutate via
632
+ * {@link activate} / {@link supersede} / {@link terminate}.
633
+ */
634
+ status = "draft";
635
+ /** Start of the effective window (`null` = effective immediately). */
636
+ effectiveFrom = null;
637
+ /**
638
+ * End of the effective window (`null` = open-ended). Deliberately NOT
639
+ * frozen: end-dating an active agreement is a legitimate operation.
640
+ */
641
+ effectiveTo = null;
642
+ /**
643
+ * The commissions-module CommissionPlan key these terms bind. Required to
644
+ * activate.
645
+ */
646
+ commissionPlanKey = "";
647
+ /**
648
+ * Pinned plan version. `0` (default) means "resolve the latest ACTIVE
649
+ * version at qualification time and pin it in the ReferralTermSnapshot";
650
+ * `> 0` pins a specific version now.
651
+ */
652
+ commissionPlanVersion = 0;
653
+ /**
654
+ * Clearing window in days applied to commissions earned under these
655
+ * terms (refund/chargeback holdback). Frozen into every snapshot.
656
+ */
657
+ clearingDays = 0;
658
+ /** How earned commissions are approved downstream. */
659
+ approvalMode = "manual";
660
+ contractRef = "";
661
+ /** URL of the executed agreement artifact (e-signature PDF, …). */
662
+ executedArtifactUrl = "";
663
+ /** Content hash of the executed artifact for tamper evidence. */
664
+ executedArtifactHash = "";
665
+ /**
666
+ * Acceptance evidence as a JSON object string (click-through record, IP,
667
+ * timestamp, signer, …). Use
668
+ * {@link getAcceptanceEvidence}/{@link setAcceptanceEvidence}.
669
+ */
670
+ acceptanceEvidence = "{}";
671
+ /**
672
+ * Free-form JSON object stored as a string. Use
673
+ * {@link getMetadata}/{@link setMetadata} instead of parsing manually.
674
+ */
675
+ metadata = "{}";
676
+ constructor(options = {}) {
677
+ super(options);
678
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
679
+ if (options.referrerId !== void 0) this.referrerId = options.referrerId;
680
+ if (options.programId !== void 0) this.programId = options.programId;
681
+ if (options.version !== void 0) this.version = options.version;
682
+ if (options.status !== void 0) this.status = options.status;
683
+ if (options.effectiveFrom !== void 0) this.effectiveFrom = ReferralAgreement.coerceDate(options.effectiveFrom);
684
+ if (options.effectiveTo !== void 0) this.effectiveTo = ReferralAgreement.coerceDate(options.effectiveTo);
685
+ if (options.commissionPlanKey !== void 0) this.commissionPlanKey = options.commissionPlanKey;
686
+ if (options.commissionPlanVersion !== void 0) this.commissionPlanVersion = options.commissionPlanVersion;
687
+ if (options.clearingDays !== void 0) this.clearingDays = options.clearingDays;
688
+ if (options.approvalMode !== void 0) this.approvalMode = options.approvalMode;
689
+ if (options.contractRef !== void 0) this.contractRef = options.contractRef;
690
+ if (options.executedArtifactUrl !== void 0) this.executedArtifactUrl = options.executedArtifactUrl;
691
+ if (options.executedArtifactHash !== void 0) this.executedArtifactHash = options.executedArtifactHash;
692
+ if (options.acceptanceEvidence !== void 0) this.acceptanceEvidence = options.acceptanceEvidence;
693
+ if (options.metadata !== void 0) this.metadata = options.metadata;
694
+ }
695
+ /**
696
+ * Re-coerce date fields after the framework reapplies raw option values,
697
+ * record the loaded status for the transition guard, and capture the
698
+ * frozen terms snapshot when the row arrived already non-draft
699
+ * (superseded / terminated history can't be rewritten either).
700
+ */
701
+ async initialize() {
702
+ await super.initialize();
703
+ this.effectiveFrom = ReferralAgreement.coerceDate(this.effectiveFrom);
704
+ this.effectiveTo = ReferralAgreement.coerceDate(this.effectiveTo);
705
+ if (await this.isSaved()) {
706
+ loadedAgreementStatus.set(this, this.status);
707
+ if (this.status !== "draft") frozenAgreementSnapshot.set(this, this.serializeFrozenTerms());
708
+ }
709
+ return this;
710
+ }
711
+ isDraft() {
712
+ return this.status === "draft";
713
+ }
714
+ isActive() {
715
+ return this.status === "active";
716
+ }
717
+ /**
718
+ * Whether the effective window contains `at` (`effectiveFrom` null =
719
+ * always started; `effectiveTo` null = open-ended). Status is NOT part of
720
+ * this check — combine with {@link isActive}.
721
+ */
722
+ isEffectiveAt(at) {
723
+ if (this.effectiveFrom && at.getTime() < this.effectiveFrom.getTime()) return false;
724
+ if (this.effectiveTo && at.getTime() > this.effectiveTo.getTime()) return false;
725
+ return true;
726
+ }
727
+ /** Parse {@link acceptanceEvidence}; returns `{}` on malformed content. */
728
+ getAcceptanceEvidence() {
729
+ if (!this.acceptanceEvidence) return {};
730
+ try {
731
+ const parsed = JSON.parse(this.acceptanceEvidence);
732
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
733
+ } catch {
734
+ return {};
735
+ }
736
+ }
737
+ /** Serialize and store {@link acceptanceEvidence}. */
738
+ setAcceptanceEvidence(evidence) {
739
+ this.acceptanceEvidence = JSON.stringify(evidence ?? {});
740
+ }
741
+ /** Parse {@link metadata}; returns `{}` on malformed content. */
742
+ getMetadata() {
743
+ if (!this.metadata) return {};
744
+ try {
745
+ const parsed = JSON.parse(this.metadata);
746
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
747
+ } catch {
748
+ return {};
749
+ }
750
+ }
751
+ /** Serialize and store {@link metadata}. */
752
+ setMetadata(data) {
753
+ this.metadata = JSON.stringify(data ?? {});
754
+ }
755
+ /**
756
+ * Transition `draft → active`. Requires a non-empty
757
+ * {@link commissionPlanKey} — an agreement without bound terms cannot
758
+ * govern qualification.
759
+ */
760
+ activate() {
761
+ if (this.status !== "draft") throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: cannot activate from status '${this.status}'`);
762
+ if (!this.commissionPlanKey) throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: commissionPlanKey is required to activate`);
763
+ this.status = "active";
764
+ }
765
+ /** Transition `active → superseded` (a newer version took over). */
766
+ supersede() {
767
+ if (this.status !== "active") throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: cannot supersede from status '${this.status}'`);
768
+ this.status = "superseded";
769
+ }
770
+ /** Transition `draft | active → terminated` (terminal). */
771
+ terminate() {
772
+ if (this.status !== "draft" && this.status !== "active") throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: cannot terminate from status '${this.status}'`);
773
+ this.status = "terminated";
774
+ }
775
+ /**
776
+ * Save with two guards (CommissionPlan pattern):
777
+ *
778
+ * 1. **Status transition** — the about-to-be-written status must be a
779
+ * legal edge from the authoritative prior persisted status.
780
+ * 2. **Frozen terms** — once the row has been saved non-draft, the terms
781
+ * fields must match the captured snapshot. Amend by inserting a new
782
+ * version instead.
783
+ *
784
+ * Activating saves also re-require {@link commissionPlanKey}.
785
+ */
786
+ async save() {
787
+ const prior = await this.resolvePriorStatus();
788
+ this.assertStatusTransition(prior);
789
+ this.assertFrozenTermsUnchanged();
790
+ await this.assertNaturalKeyNotTaken();
791
+ if (this.status === "active" && !this.commissionPlanKey) throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: commissionPlanKey is required while active`);
792
+ const result = await super.save();
793
+ loadedAgreementStatus.set(this, this.status);
794
+ if (this.status !== "draft" && !frozenAgreementSnapshot.has(this)) frozenAgreementSnapshot.set(this, this.serializeFrozenTerms());
795
+ return result;
796
+ }
797
+ /**
798
+ * Refuse a save whose `(referrerId, programId, version)` natural key
799
+ * already belongs to a DIFFERENT row — the frozen-terms guard is
800
+ * instance-local, so a fresh instance would otherwise upsert over the
801
+ * persisted agreement (replacing legally frozen referral terms without
802
+ * an amendment, and rotating the row id). Edit drafts by hydrating
803
+ * them; change terms with
804
+ * `ReferralAgreementCollection.createAmendment()`.
805
+ */
806
+ async assertNaturalKeyNotTaken() {
807
+ if (!this.referrerId || !this.programId) return;
808
+ try {
809
+ const res = await this.db.query(`SELECT id FROM ${this.tableName} WHERE referrer_id = $1 AND program_id = $2 AND version = $3`, this.referrerId, this.programId, this.version);
810
+ if ((Array.isArray(res) ? res : res.rows ?? []).find((row) => row.id !== this.id)) throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: this version already exists \u2014 agreement versions are immutable records. Hydrate the existing row to edit a draft, or create new terms with ReferralAgreementCollection.createAmendment().`);
811
+ } catch (error) {
812
+ if (error instanceof Error && error.message.includes("immutable")) throw error;
813
+ }
814
+ }
815
+ async resolvePriorStatus() {
816
+ if (this.id) try {
817
+ const row = await this.db.get(this.tableName, { id: this.id });
818
+ if (row && row.status != null) return row.status;
819
+ } catch {}
820
+ return loadedAgreementStatus.get(this);
821
+ }
822
+ assertStatusTransition(prior) {
823
+ if (prior === void 0) return;
824
+ if (prior === this.status) return;
825
+ if (!(AGREEMENT_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: illegal status transition '${prior}' \u2192 '${this.status}'. Use activate() / supersede() / terminate().`);
826
+ }
827
+ assertFrozenTermsUnchanged() {
828
+ const captured = frozenAgreementSnapshot.get(this);
829
+ if (!captured) return;
830
+ if (captured !== this.serializeFrozenTerms()) throw new Error(`ReferralAgreement ${this.referrerId}/${this.programId}@${this.version}: terms are immutable once the agreement has been active. Create an amendment (ReferralAgreementCollection.createAmendment) instead of editing this version.`);
831
+ }
832
+ serializeFrozenTerms() {
833
+ return JSON.stringify({
834
+ referrerId: this.referrerId,
835
+ programId: this.programId,
836
+ version: this.version,
837
+ commissionPlanKey: this.commissionPlanKey,
838
+ commissionPlanVersion: this.commissionPlanVersion,
839
+ clearingDays: this.clearingDays,
840
+ approvalMode: this.approvalMode,
841
+ effectiveFrom: this.effectiveFrom ? this.effectiveFrom.toISOString() : null
842
+ });
843
+ }
844
+ static coerceDate(value) {
845
+ if (value == null) return null;
846
+ if (value instanceof Date) return value;
847
+ if (typeof value === "number" || typeof value === "string") {
848
+ const d = new Date(value);
849
+ return Number.isNaN(d.getTime()) ? null : d;
850
+ }
851
+ return null;
852
+ }
853
+ };
854
+ __decorateClass$6([tenantId({ nullable: true })], ReferralAgreement.prototype, "tenantId", 2);
855
+ __decorateClass$6([foreignKey("Referrer", { required: true })], ReferralAgreement.prototype, "referrerId", 2);
856
+ __decorateClass$6([foreignKey("ReferralProgram", { required: true })], ReferralAgreement.prototype, "programId", 2);
857
+ __decorateClass$6([crossPackageRef("@happyvertical/smrt-commerce:Contract")], ReferralAgreement.prototype, "contractRef", 2);
858
+ ReferralAgreement = __decorateClass$6([TenantScoped({ mode: "optional" }), smrt({
859
+ conflictColumns: [
860
+ "referrer_id",
861
+ "program_id",
862
+ "version"
863
+ ],
864
+ api: { include: [
865
+ "list",
866
+ "get",
867
+ "create"
868
+ ] },
869
+ mcp: { include: ["list", "get"] },
870
+ cli: true
871
+ })], ReferralAgreement);
872
+ //#endregion
873
+ //#region src/referrals/collections/ReferralAgreementCollection.ts
874
+ var ReferralAgreementCollection = class extends SmrtCollection {
875
+ static _itemClass = ReferralAgreement;
876
+ /** Every version of one referrer+program agreement, newest version first. */
877
+ async findByReferrerAndProgram(referrerId, programId) {
878
+ return await this.list({
879
+ where: {
880
+ referrerId,
881
+ programId
882
+ },
883
+ orderBy: "version DESC"
884
+ });
885
+ }
886
+ /**
887
+ * The agreement governing `(referrerId, programId)` at instant `at`
888
+ * (default now): status `active` AND effective window containing `at`
889
+ * (`effectiveFrom` null = always started; `effectiveTo` null =
890
+ * open-ended). When several active versions overlap — transitional states
891
+ * during amendment activation — the highest version wins. `null` when
892
+ * nothing governs.
893
+ */
894
+ async activeFor(referrerId, programId, at = /* @__PURE__ */ new Date()) {
895
+ return (await this.list({
896
+ where: {
897
+ referrerId,
898
+ programId,
899
+ status: "active"
900
+ },
901
+ orderBy: "version DESC"
902
+ })).find((agreement) => agreement.isEffectiveAt(at)) ?? null;
903
+ }
904
+ /**
905
+ * Create an amendment: insert a new DRAFT row with
906
+ * `version = max(existing versions) + 1`, copying the latest existing
907
+ * version's fields and then applying `changes`. The source version is not
908
+ * touched — activate the draft (and supersede the prior active version)
909
+ * as a separate, explicit step.
910
+ *
911
+ * Throws when no version exists for the pair (nothing to amend — use
912
+ * `create` for a brand-new agreement).
913
+ */
914
+ async createAmendment(referrerId, programId, changes = {}) {
915
+ const latest = (await this.findByReferrerAndProgram(referrerId, programId))[0];
916
+ if (!latest) throw new Error(`ReferralAgreementCollection.createAmendment: no versions exist for referrer '${referrerId}' in program '${programId}' \u2014 create the agreement first`);
917
+ return await this.create({
918
+ tenantId: latest.tenantId,
919
+ referrerId,
920
+ programId,
921
+ version: latest.version + 1,
922
+ status: "draft",
923
+ commissionPlanKey: changes.commissionPlanKey ?? latest.commissionPlanKey,
924
+ commissionPlanVersion: changes.commissionPlanVersion ?? latest.commissionPlanVersion,
925
+ clearingDays: changes.clearingDays ?? latest.clearingDays,
926
+ approvalMode: changes.approvalMode ?? latest.approvalMode,
927
+ effectiveFrom: changes.effectiveFrom !== void 0 ? changes.effectiveFrom : latest.effectiveFrom,
928
+ effectiveTo: changes.effectiveTo !== void 0 ? changes.effectiveTo : latest.effectiveTo,
929
+ contractRef: changes.contractRef ?? latest.contractRef,
930
+ executedArtifactUrl: changes.executedArtifactUrl ?? latest.executedArtifactUrl,
931
+ executedArtifactHash: changes.executedArtifactHash ?? latest.executedArtifactHash,
932
+ acceptanceEvidence: changes.acceptanceEvidence !== void 0 ? JSON.stringify(changes.acceptanceEvidence) : latest.acceptanceEvidence,
933
+ metadata: changes.metadata !== void 0 ? JSON.stringify(changes.metadata) : latest.metadata
934
+ });
935
+ }
936
+ };
937
+ //#endregion
938
+ //#region src/referrals/models/Referral.ts
939
+ var __defProp$5 = Object.defineProperty;
940
+ var __getOwnPropDesc$5 = Object.getOwnPropertyDescriptor;
941
+ var __decorateClass$5 = (decorators, target, key, kind) => {
942
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$5(target, key) : target;
943
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
944
+ if (kind && result) __defProp$5(target, key, result);
945
+ return result;
946
+ };
947
+ var REFERRAL_STATUS_TRANSITIONS = {
948
+ pending: [
949
+ "attributed",
950
+ "under_review",
951
+ "disqualified",
952
+ "expired"
953
+ ],
954
+ under_review: ["attributed", "disqualified"],
955
+ attributed: [
956
+ "qualified",
957
+ "disqualified",
958
+ "expired"
959
+ ],
960
+ qualified: ["disqualified"],
961
+ disqualified: [],
962
+ expired: []
963
+ };
964
+ var loadedReferralStatus = /* @__PURE__ */ new WeakMap();
965
+ var Referral = class extends SmrtObject {
966
+ tenantId = null;
967
+ referrerId = "";
968
+ programId = "";
969
+ /** AttributionPolicy key resolved at attribution ('' until attributed). */
970
+ policyKey = "";
971
+ /** AttributionPolicy version resolved at attribution (0 until attributed). */
972
+ policyVersion = 0;
973
+ /**
974
+ * Lifecycle status — see the module transition map. Mutate via the
975
+ * transition methods; the save-time guard rejects illegal edges.
976
+ */
977
+ status = "pending";
978
+ /**
979
+ * Generic qualifying target: what the introduction became/points at —
980
+ * `('lead', <id>)`, `('opportunity', <id>)`, `('client', <id>)`,
981
+ * `('subscription', <id>)`, … This module attaches no semantics to it.
982
+ */
983
+ targetKind = "";
984
+ /** Identifier within the {@link targetKind} namespace. */
985
+ targetId = "";
986
+ /**
987
+ * Credit share (0–1) this referral carries. `1.0` for sole attribution;
988
+ * split attribution creates sibling Referrals whose fractions sum to 1.0
989
+ * sharing one {@link splitGroupId}. Flows into Commission
990
+ * `shareFraction` at earning time.
991
+ */
992
+ creditFraction = 1;
993
+ /** Groups the sibling referrals of one split. Empty for unsplit rows. */
994
+ splitGroupId = "";
995
+ primaryTouchId = "";
996
+ /** When the referral was attributed. */
997
+ attributedAt = null;
998
+ /** When the referral was qualified. */
999
+ qualifiedAt = null;
1000
+ /**
1001
+ * When an attributed-but-unqualified referral lapses. Stamped at
1002
+ * attribution (`attributedAt + policy.windowDays`); enforced by
1003
+ * `ReferralCollection.sweepExpired()`.
1004
+ */
1005
+ expiresAt = null;
1006
+ /**
1007
+ * The ReferralTermSnapshot frozen at qualification ('' until qualified).
1008
+ * Requalification repoints this at a NEW snapshot; old snapshots remain
1009
+ * as history.
1010
+ *
1011
+ * Deliberately a PLAIN string reference (not `@foreignKey`): the
1012
+ * snapshot's own `referralId` FK carries the relationship, and declaring
1013
+ * both directions would put a cycle in the model graph (breaking the
1014
+ * registry's topological initialization order). Services fetch the
1015
+ * snapshot by id explicitly.
1016
+ */
1017
+ snapshotId = "";
1018
+ /**
1019
+ * Free-form JSON object stored as a string. Use
1020
+ * {@link getMetadata}/{@link setMetadata} instead of parsing manually.
1021
+ */
1022
+ metadata = "{}";
1023
+ constructor(options = {}) {
1024
+ super(options);
1025
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
1026
+ if (options.referrerId !== void 0) this.referrerId = options.referrerId;
1027
+ if (options.programId !== void 0) this.programId = options.programId;
1028
+ if (options.policyKey !== void 0) this.policyKey = options.policyKey;
1029
+ if (options.policyVersion !== void 0) this.policyVersion = options.policyVersion;
1030
+ if (options.status !== void 0) this.status = options.status;
1031
+ if (options.targetKind !== void 0) this.targetKind = options.targetKind;
1032
+ if (options.targetId !== void 0) this.targetId = options.targetId;
1033
+ if (options.creditFraction !== void 0) this.creditFraction = options.creditFraction;
1034
+ if (options.splitGroupId !== void 0) this.splitGroupId = options.splitGroupId;
1035
+ if (options.primaryTouchId !== void 0) this.primaryTouchId = options.primaryTouchId;
1036
+ if (options.attributedAt !== void 0) this.attributedAt = Referral.coerceDate(options.attributedAt);
1037
+ if (options.qualifiedAt !== void 0) this.qualifiedAt = Referral.coerceDate(options.qualifiedAt);
1038
+ if (options.expiresAt !== void 0) this.expiresAt = Referral.coerceDate(options.expiresAt);
1039
+ if (options.snapshotId !== void 0) this.snapshotId = options.snapshotId;
1040
+ if (options.metadata !== void 0) this.metadata = options.metadata;
1041
+ }
1042
+ /**
1043
+ * Re-coerce date fields after the framework reapplies raw option values
1044
+ * and record the loaded status for the save-time transition guard.
1045
+ */
1046
+ async initialize() {
1047
+ await super.initialize();
1048
+ this.attributedAt = Referral.coerceDate(this.attributedAt);
1049
+ this.qualifiedAt = Referral.coerceDate(this.qualifiedAt);
1050
+ this.expiresAt = Referral.coerceDate(this.expiresAt);
1051
+ if (await this.isSaved()) loadedReferralStatus.set(this, this.status);
1052
+ return this;
1053
+ }
1054
+ isAttributed() {
1055
+ return this.status === "attributed";
1056
+ }
1057
+ isQualified() {
1058
+ return this.status === "qualified";
1059
+ }
1060
+ /** Whether the referral sits in a terminal status. */
1061
+ isTerminal() {
1062
+ return this.status === "disqualified" || this.status === "expired";
1063
+ }
1064
+ /** Parse {@link metadata}; returns `{}` on malformed content. */
1065
+ getMetadata() {
1066
+ if (!this.metadata) return {};
1067
+ try {
1068
+ const parsed = JSON.parse(this.metadata);
1069
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1070
+ } catch {
1071
+ return {};
1072
+ }
1073
+ }
1074
+ /** Serialize and store {@link metadata}. */
1075
+ setMetadata(data) {
1076
+ this.metadata = JSON.stringify(data ?? {});
1077
+ }
1078
+ /** Transition `pending → under_review` (conflict parked for humans). */
1079
+ markUnderReview() {
1080
+ if (this.status !== "pending") throw new Error(`Referral ${this.id ?? "<new>"}: cannot move to under_review from status '${this.status}'`);
1081
+ this.status = "under_review";
1082
+ }
1083
+ /**
1084
+ * Transition `pending | under_review → attributed` and stamp
1085
+ * {@link attributedAt}.
1086
+ */
1087
+ markAttributed(at = /* @__PURE__ */ new Date()) {
1088
+ if (this.status !== "pending" && this.status !== "under_review") throw new Error(`Referral ${this.id ?? "<new>"}: cannot attribute from status '${this.status}'`);
1089
+ this.status = "attributed";
1090
+ this.attributedAt = at;
1091
+ }
1092
+ /** Transition `attributed → qualified` and stamp {@link qualifiedAt}. */
1093
+ markQualified(at = /* @__PURE__ */ new Date()) {
1094
+ if (this.status !== "attributed") throw new Error(`Referral ${this.id ?? "<new>"}: cannot qualify from status '${this.status}'`);
1095
+ this.status = "qualified";
1096
+ this.qualifiedAt = at;
1097
+ }
1098
+ /**
1099
+ * Transition to `disqualified` (terminal). Legal from every non-terminal
1100
+ * status — including `qualified` (the rare clawback path).
1101
+ */
1102
+ disqualify() {
1103
+ if (this.isTerminal()) throw new Error(`Referral ${this.id ?? "<new>"}: cannot disqualify from terminal status '${this.status}'`);
1104
+ this.status = "disqualified";
1105
+ }
1106
+ /** Transition `pending | attributed → expired` (terminal). */
1107
+ markExpired() {
1108
+ if (this.status !== "pending" && this.status !== "attributed") throw new Error(`Referral ${this.id ?? "<new>"}: cannot expire from status '${this.status}'`);
1109
+ this.status = "expired";
1110
+ }
1111
+ /**
1112
+ * Validate the status transition before persisting, then save. A forged
1113
+ * `status` (e.g. un-expiring a referral or skipping straight to
1114
+ * qualified) is rejected regardless of how the instance was constructed.
1115
+ */
1116
+ async save() {
1117
+ const prior = await this.resolvePriorStatus();
1118
+ this.assertStatusTransition(prior);
1119
+ const result = await super.save();
1120
+ loadedReferralStatus.set(this, this.status);
1121
+ return result;
1122
+ }
1123
+ /**
1124
+ * Resolve the AUTHORITATIVE prior status from the database; fall back to
1125
+ * the loaded-status WeakMap only when the DB is unavailable (commerce
1126
+ * pattern — an upsert via `create({ id, _skipLoad: true })` can't
1127
+ * sidestep the guard).
1128
+ */
1129
+ async resolvePriorStatus() {
1130
+ if (this.id) try {
1131
+ const row = await this.db.get(this.tableName, { id: this.id });
1132
+ if (row && row.status != null) return row.status;
1133
+ } catch {}
1134
+ return loadedReferralStatus.get(this);
1135
+ }
1136
+ assertStatusTransition(prior) {
1137
+ if (prior === void 0) return;
1138
+ if (prior === this.status) return;
1139
+ if (!(REFERRAL_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`Referral ${this.id ?? "<new>"}: illegal status transition '${prior}' \u2192 '${this.status}'.`);
1140
+ }
1141
+ static coerceDate(value) {
1142
+ if (value == null) return null;
1143
+ if (value instanceof Date) return value;
1144
+ if (typeof value === "number" || typeof value === "string") {
1145
+ const d = new Date(value);
1146
+ return Number.isNaN(d.getTime()) ? null : d;
1147
+ }
1148
+ return null;
1149
+ }
1150
+ };
1151
+ __decorateClass$5([tenantId({ nullable: true })], Referral.prototype, "tenantId", 2);
1152
+ __decorateClass$5([foreignKey("Referrer", { required: true })], Referral.prototype, "referrerId", 2);
1153
+ __decorateClass$5([foreignKey("ReferralProgram", { required: true })], Referral.prototype, "programId", 2);
1154
+ __decorateClass$5([foreignKey("ReferralTouch")], Referral.prototype, "primaryTouchId", 2);
1155
+ Referral = __decorateClass$5([TenantScoped({ mode: "optional" }), smrt({
1156
+ api: { include: [
1157
+ "list",
1158
+ "get",
1159
+ "create"
1160
+ ] },
1161
+ mcp: { include: ["list", "get"] },
1162
+ cli: true
1163
+ })], Referral);
1164
+ //#endregion
1165
+ //#region src/referrals/collections/ReferralCollection.ts
1166
+ var ReferralCollection = class extends SmrtCollection {
1167
+ static _itemClass = Referral;
1168
+ /** All referrals credited to a referrer, newest first. */
1169
+ async findByReferrer(referrerId) {
1170
+ return await this.list({
1171
+ where: { referrerId },
1172
+ orderBy: "created_at DESC"
1173
+ });
1174
+ }
1175
+ /** All referrals pointing at one qualifying target, newest first. */
1176
+ async findByTarget(targetKind, targetId) {
1177
+ return await this.list({
1178
+ where: {
1179
+ targetKind,
1180
+ targetId
1181
+ },
1182
+ orderBy: "created_at DESC"
1183
+ });
1184
+ }
1185
+ /**
1186
+ * QUALIFIED referrals for one target — what
1187
+ * `ReferralCommissionService.processEarningEvent` resolves when given a
1188
+ * target instead of explicit referral ids.
1189
+ */
1190
+ async findQualifiedByTarget(targetKind, targetId) {
1191
+ return await this.list({
1192
+ where: {
1193
+ targetKind,
1194
+ targetId,
1195
+ status: "qualified"
1196
+ },
1197
+ orderBy: "created_at ASC"
1198
+ });
1199
+ }
1200
+ /**
1201
+ * Expire lapsed attributions: every `attributed` referral whose
1202
+ * `expiresAt` is set and `<= now` transitions to `expired` and is saved.
1203
+ * Referrals without an `expiresAt` never lapse.
1204
+ *
1205
+ * @returns The referrals expired by this sweep.
1206
+ */
1207
+ async sweepExpired(now = /* @__PURE__ */ new Date()) {
1208
+ const attributed = await this.list({
1209
+ where: { status: "attributed" },
1210
+ orderBy: "created_at ASC"
1211
+ });
1212
+ const swept = [];
1213
+ for (const referral of attributed) {
1214
+ const expiresAt = referral.expiresAt;
1215
+ if (expiresAt === null || expiresAt.getTime() > now.getTime()) continue;
1216
+ referral.markExpired();
1217
+ await referral.save();
1218
+ swept.push(referral);
1219
+ }
1220
+ return swept;
1221
+ }
1222
+ };
1223
+ //#endregion
1224
+ //#region src/referrals/models/ReferralLink.ts
1225
+ var __defProp$4 = Object.defineProperty;
1226
+ var __getOwnPropDesc$4 = Object.getOwnPropertyDescriptor;
1227
+ var __decorateClass$4 = (decorators, target, key, kind) => {
1228
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$4(target, key) : target;
1229
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
1230
+ if (kind && result) __defProp$4(target, key, result);
1231
+ return result;
1232
+ };
1233
+ function assertHttpTargetUrl(targetUrl) {
1234
+ let parsed;
1235
+ try {
1236
+ parsed = new URL(targetUrl);
1237
+ } catch {
1238
+ throw new Error(`ReferralLink targetUrl must be an absolute http(s) URL \u2014 got '${targetUrl}'`);
1239
+ }
1240
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`ReferralLink targetUrl must use http or https \u2014 got '${parsed.protocol}'`);
1241
+ }
1242
+ var ReferralLink = class extends SmrtObject {
1243
+ tenantId = null;
1244
+ referrerId = "";
1245
+ programId = "";
1246
+ code = "";
1247
+ /**
1248
+ * Optional destination URL the shared link forwards to. Must be an
1249
+ * absolute http/https URL when non-empty (validated at save time).
1250
+ */
1251
+ targetUrl = "";
1252
+ /** Human-readable label for the referrer's own bookkeeping. */
1253
+ label = "";
1254
+ /** `active` (default) accepts clicks; `disabled` refuses them. */
1255
+ status = "active";
1256
+ /** Number of recorded clicks (incremented by `recordClick`). */
1257
+ clickCount = 0;
1258
+ /**
1259
+ * Free-form JSON object stored as a string. Use
1260
+ * {@link getMetadata}/{@link setMetadata} instead of parsing manually.
1261
+ */
1262
+ metadata = "{}";
1263
+ constructor(options = {}) {
1264
+ super(options);
1265
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
1266
+ if (options.referrerId !== void 0) this.referrerId = options.referrerId;
1267
+ if (options.programId !== void 0) this.programId = options.programId;
1268
+ if (options.code !== void 0) this.code = options.code;
1269
+ if (options.targetUrl !== void 0) this.targetUrl = options.targetUrl;
1270
+ if (options.label !== void 0) this.label = options.label;
1271
+ if (options.status !== void 0) this.status = options.status;
1272
+ if (options.clickCount !== void 0) this.clickCount = options.clickCount;
1273
+ if (options.metadata !== void 0) this.metadata = options.metadata;
1274
+ }
1275
+ /** Whether the link currently accepts clicks. */
1276
+ isActive() {
1277
+ return this.status === "active";
1278
+ }
1279
+ /** Parse the metadata JSON string; returns `{}` on malformed content. */
1280
+ getMetadata() {
1281
+ try {
1282
+ const parsed = JSON.parse(this.metadata);
1283
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1284
+ return {};
1285
+ } catch {
1286
+ return {};
1287
+ }
1288
+ }
1289
+ /** Serialize and store the metadata object. */
1290
+ setMetadata(metadata) {
1291
+ this.metadata = JSON.stringify(metadata);
1292
+ }
1293
+ /**
1294
+ * Save with two guards:
1295
+ *
1296
+ * - a non-empty {@link targetUrl} must be an absolute http/https URL
1297
+ * regardless of which write path set it;
1298
+ * - the {@link code} must not belong to a DIFFERENT persisted link. Codes
1299
+ * are the upsert natural key and they are public — without this check a
1300
+ * generated-surface `create` carrying an existing code would silently
1301
+ * UPSERT over that link (rewriting its referrer/program/target: a
1302
+ * referral-code hijack). Mint codes through
1303
+ * `ReferralLinkCollection.createWithUniqueCode()`.
1304
+ */
1305
+ async save() {
1306
+ if (this.targetUrl) assertHttpTargetUrl(this.targetUrl);
1307
+ if (this.code) try {
1308
+ const row = await this.db.get(this.tableName, { code: this.code });
1309
+ if (row && row.id !== this.id) throw new Error(`ReferralLink (code '${this.code}'): this code already belongs to another link \u2014 referral codes are unique and cannot be taken over. Mint links through ReferralLinkCollection.createWithUniqueCode().`);
1310
+ } catch (error) {
1311
+ if (error instanceof Error && error.message.includes("cannot be taken over")) throw error;
1312
+ }
1313
+ return await super.save();
1314
+ }
1315
+ };
1316
+ __decorateClass$4([tenantId({ nullable: true })], ReferralLink.prototype, "tenantId", 2);
1317
+ __decorateClass$4([foreignKey("Referrer", { required: true })], ReferralLink.prototype, "referrerId", 2);
1318
+ __decorateClass$4([foreignKey("ReferralProgram", { required: true })], ReferralLink.prototype, "programId", 2);
1319
+ __decorateClass$4([field({ required: true })], ReferralLink.prototype, "code", 2);
1320
+ ReferralLink = __decorateClass$4([TenantScoped({ mode: "optional" }), smrt({
1321
+ conflictColumns: ["code"],
1322
+ api: { include: [
1323
+ "list",
1324
+ "get",
1325
+ "create",
1326
+ "update"
1327
+ ] },
1328
+ mcp: { include: [
1329
+ "list",
1330
+ "get",
1331
+ "create"
1332
+ ] },
1333
+ cli: true
1334
+ })], ReferralLink);
1335
+ //#endregion
1336
+ //#region src/referrals/models/ReferralTouch.ts
1337
+ var __defProp$3 = Object.defineProperty;
1338
+ var __getOwnPropDesc$3 = Object.getOwnPropertyDescriptor;
1339
+ var __decorateClass$3 = (decorators, target, key, kind) => {
1340
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$3(target, key) : target;
1341
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
1342
+ if (kind && result) __defProp$3(target, key, result);
1343
+ return result;
1344
+ };
1345
+ var ReferralTouch = class extends SmrtObject {
1346
+ tenantId = null;
1347
+ linkId = "";
1348
+ /** The share code involved, when the touch came through a code/link. */
1349
+ code = "";
1350
+ referrerId = "";
1351
+ programId = "";
1352
+ /** What kind of interaction this evidence records. */
1353
+ kind = "click";
1354
+ /**
1355
+ * Prospect identity when known — a generic `(subjectKind, subjectId)`
1356
+ * pair (e.g. `('lead', <id>)`, `('email', 'a@b.test')`). Attribution
1357
+ * matches touches to a resolution's subject and/or target through these.
1358
+ */
1359
+ subjectKind = "";
1360
+ /** Identifier within the {@link subjectKind} namespace. */
1361
+ subjectId = "";
1362
+ /** When the interaction occurred (not when it was ingested). */
1363
+ occurredAt = /* @__PURE__ */ new Date();
1364
+ /**
1365
+ * Evidence payload as a JSON object string (the code/link/url for clicks,
1366
+ * actor + reason for manual assignments, …). Use
1367
+ * {@link getEvidence}/{@link setEvidence}.
1368
+ */
1369
+ evidence = "{}";
1370
+ /**
1371
+ * Free-form JSON object stored as a string. Use
1372
+ * {@link getMetadata}/{@link setMetadata} instead of parsing manually.
1373
+ */
1374
+ metadata = "{}";
1375
+ constructor(options = {}) {
1376
+ super(options);
1377
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
1378
+ if (options.linkId !== void 0) this.linkId = options.linkId;
1379
+ if (options.code !== void 0) this.code = options.code;
1380
+ if (options.referrerId !== void 0) this.referrerId = options.referrerId;
1381
+ if (options.programId !== void 0) this.programId = options.programId;
1382
+ if (options.kind !== void 0) this.kind = options.kind;
1383
+ if (options.subjectKind !== void 0) this.subjectKind = options.subjectKind;
1384
+ if (options.subjectId !== void 0) this.subjectId = options.subjectId;
1385
+ if (options.occurredAt !== void 0) this.occurredAt = ReferralTouch.coerceDate(options.occurredAt) ?? /* @__PURE__ */ new Date();
1386
+ if (options.evidence !== void 0) this.evidence = options.evidence;
1387
+ if (options.metadata !== void 0) this.metadata = options.metadata;
1388
+ }
1389
+ /**
1390
+ * Re-coerce {@link occurredAt} after the framework reapplies raw option /
1391
+ * hydrated row values (SQLite hands back ISO strings).
1392
+ */
1393
+ async initialize() {
1394
+ await super.initialize();
1395
+ this.occurredAt = ReferralTouch.coerceDate(this.occurredAt) ?? /* @__PURE__ */ new Date();
1396
+ return this;
1397
+ }
1398
+ /** Parse {@link evidence}; returns `{}` on empty/invalid JSON. */
1399
+ getEvidence() {
1400
+ if (!this.evidence) return {};
1401
+ try {
1402
+ const parsed = JSON.parse(this.evidence);
1403
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1404
+ } catch {
1405
+ return {};
1406
+ }
1407
+ }
1408
+ /** Serialize and store {@link evidence}. */
1409
+ setEvidence(evidence) {
1410
+ this.evidence = JSON.stringify(evidence ?? {});
1411
+ }
1412
+ /** Parse {@link metadata}; returns `{}` on malformed content. */
1413
+ getMetadata() {
1414
+ if (!this.metadata) return {};
1415
+ try {
1416
+ const parsed = JSON.parse(this.metadata);
1417
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1418
+ } catch {
1419
+ return {};
1420
+ }
1421
+ }
1422
+ /** Serialize and store {@link metadata}. */
1423
+ setMetadata(data) {
1424
+ this.metadata = JSON.stringify(data ?? {});
1425
+ }
1426
+ static coerceDate(value) {
1427
+ if (value == null) return null;
1428
+ if (value instanceof Date) return value;
1429
+ if (typeof value === "number" || typeof value === "string") {
1430
+ const d = new Date(value);
1431
+ return Number.isNaN(d.getTime()) ? null : d;
1432
+ }
1433
+ return null;
1434
+ }
1435
+ };
1436
+ __decorateClass$3([tenantId({ nullable: true })], ReferralTouch.prototype, "tenantId", 2);
1437
+ __decorateClass$3([foreignKey("ReferralLink")], ReferralTouch.prototype, "linkId", 2);
1438
+ __decorateClass$3([foreignKey("Referrer", { required: true })], ReferralTouch.prototype, "referrerId", 2);
1439
+ __decorateClass$3([foreignKey("ReferralProgram")], ReferralTouch.prototype, "programId", 2);
1440
+ ReferralTouch = __decorateClass$3([TenantScoped({ mode: "optional" }), smrt({
1441
+ api: { include: [
1442
+ "create",
1443
+ "list",
1444
+ "get"
1445
+ ] },
1446
+ mcp: { include: ["list", "create"] },
1447
+ cli: false
1448
+ })], ReferralTouch);
1449
+ //#endregion
1450
+ //#region src/referrals/collections/ReferralTouchCollection.ts
1451
+ var ReferralTouchCollection = class extends SmrtCollection {
1452
+ static _itemClass = ReferralTouch;
1453
+ /** All touches for a referrer, oldest first. */
1454
+ async findByReferrer(referrerId) {
1455
+ return await this.list({
1456
+ where: { referrerId },
1457
+ orderBy: "occurred_at ASC"
1458
+ });
1459
+ }
1460
+ /**
1461
+ * Every touch recorded against one subject identity, oldest first —
1462
+ * attribution's raw candidate stream.
1463
+ */
1464
+ async findBySubject(subjectKind, subjectId) {
1465
+ return await this.list({
1466
+ where: {
1467
+ subjectKind,
1468
+ subjectId
1469
+ },
1470
+ orderBy: "occurred_at ASC"
1471
+ });
1472
+ }
1473
+ /**
1474
+ * Touches for one subject within one program occurring at or after
1475
+ * `since`, oldest first. The date bound is applied in memory after the
1476
+ * indexed equality filters (sibling-module pattern — the semantics hold
1477
+ * whether an adapter returns ISO strings or Dates).
1478
+ */
1479
+ async findByTargetWindow(query) {
1480
+ return (await this.list({
1481
+ where: {
1482
+ subjectKind: query.subjectKind,
1483
+ subjectId: query.subjectId,
1484
+ programId: query.programId
1485
+ },
1486
+ orderBy: "occurred_at ASC"
1487
+ })).filter((touch) => touch.occurredAt.getTime() >= query.since.getTime());
1488
+ }
1489
+ };
1490
+ //#endregion
1491
+ //#region src/referrals/collections/ReferralLinkCollection.ts
1492
+ var REFERRAL_CODE_LENGTH = 10;
1493
+ var REFERRAL_CODE_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1494
+ var MAX_CODE_GENERATION_ATTEMPTS = 5;
1495
+ function generateReferralCode() {
1496
+ const alphabetSize = 36;
1497
+ const rejectionBound = 256 - 256 % alphabetSize;
1498
+ let code = "";
1499
+ while (code.length < 10) {
1500
+ const bytes = randomBytes(20);
1501
+ for (const byte of bytes) {
1502
+ if (byte >= rejectionBound) continue;
1503
+ code += REFERRAL_CODE_ALPHABET[byte % alphabetSize];
1504
+ if (code.length === 10) break;
1505
+ }
1506
+ }
1507
+ return code;
1508
+ }
1509
+ var ReferralLinkCollection = class extends SmrtCollection {
1510
+ static _itemClass = ReferralLink;
1511
+ /** All links for a referrer, newest first. */
1512
+ async findByReferrer(referrerId) {
1513
+ return await this.list({
1514
+ where: { referrerId },
1515
+ orderBy: "created_at DESC"
1516
+ });
1517
+ }
1518
+ /**
1519
+ * Look up a link by its globally unique code (codes are minted lowercase;
1520
+ * the lookup normalizes case so shared codes survive re-typing).
1521
+ */
1522
+ async findByCode(code) {
1523
+ const normalized = code.trim().toLowerCase();
1524
+ if (!normalized) return null;
1525
+ return (await this.list({
1526
+ where: { code: normalized },
1527
+ limit: 1
1528
+ }))[0] ?? null;
1529
+ }
1530
+ /**
1531
+ * Create a link with a freshly minted, uniqueness-checked code.
1532
+ *
1533
+ * Codes come from `crypto.randomBytes` mapped to
1534
+ * {@link REFERRAL_CODE_LENGTH} lowercase-alphanumeric characters. The
1535
+ * check-then-insert loop retries on collision up to
1536
+ * {@link MAX_CODE_GENERATION_ATTEMPTS} times, then throws — at 36^10
1537
+ * possible codes repeated collisions mean the generator is broken (or a
1538
+ * test seam returns a constant), not bad luck.
1539
+ */
1540
+ async createWithUniqueCode(input) {
1541
+ if (!input.referrerId || !input.programId) throw new Error("ReferralLinkCollection.createWithUniqueCode requires referrerId and programId");
1542
+ if (input.targetUrl) assertHttpTargetUrl(input.targetUrl);
1543
+ const generate = input.generateCode ?? generateReferralCode;
1544
+ for (let attempt = 0; attempt < 5; attempt++) {
1545
+ const code = generate();
1546
+ if (await this.findByCode(code)) continue;
1547
+ return await this.create({
1548
+ tenantId: input.tenantId,
1549
+ referrerId: input.referrerId,
1550
+ programId: input.programId,
1551
+ code,
1552
+ targetUrl: input.targetUrl ?? "",
1553
+ label: input.label ?? "",
1554
+ status: "active"
1555
+ });
1556
+ }
1557
+ throw new Error(`ReferralLinkCollection.createWithUniqueCode: could not mint a unique code after 5 attempts \u2014 the code generator is returning colliding codes`);
1558
+ }
1559
+ /**
1560
+ * Record a click on a share code: find the ACTIVE link, increment its
1561
+ * {@link ReferralLink.clickCount}, and write an immutable ReferralTouch
1562
+ * (kind `'click'`, evidence carrying the code/link/url plus any caller
1563
+ * evidence). Returns `{ link, touch }` on success.
1564
+ *
1565
+ * Refusals return a typed result instead of throwing (clicks arrive from
1566
+ * the edge — an unknown or disabled code is an expected outcome, not an
1567
+ * exception): `refused: 'unknown_code'` (no such code; `link: null`) or
1568
+ * `refused: 'link_disabled'` (link exists but is disabled; the link is
1569
+ * returned, nothing is written).
1570
+ */
1571
+ async recordClick(input) {
1572
+ const link = await this.findByCode(input.code);
1573
+ if (!link) return {
1574
+ link: null,
1575
+ touch: null,
1576
+ refused: "unknown_code"
1577
+ };
1578
+ if (!link.isActive()) return {
1579
+ link,
1580
+ touch: null,
1581
+ refused: "link_disabled"
1582
+ };
1583
+ const occurredAt = input.occurredAt ?? /* @__PURE__ */ new Date();
1584
+ const touch = await (await ReferralTouchCollection.create({ db: this.db })).create({
1585
+ tenantId: link.tenantId,
1586
+ linkId: link.id ?? "",
1587
+ code: link.code,
1588
+ referrerId: link.referrerId,
1589
+ programId: link.programId,
1590
+ kind: "click",
1591
+ subjectKind: input.subjectKind ?? "",
1592
+ subjectId: input.subjectId ?? "",
1593
+ occurredAt,
1594
+ evidence: JSON.stringify({
1595
+ ...input.evidence ?? {},
1596
+ code: link.code,
1597
+ linkId: link.id ?? "",
1598
+ targetUrl: link.targetUrl
1599
+ })
1600
+ });
1601
+ await this.db.execute`UPDATE referral_links SET click_count = click_count + 1 WHERE id = ${link.id ?? ""}`;
1602
+ return {
1603
+ link: await this.get({ id: link.id }) ?? link,
1604
+ touch
1605
+ };
1606
+ }
1607
+ };
1608
+ //#endregion
1609
+ //#region src/referrals/models/ReferralProgram.ts
1610
+ var __defProp$2 = Object.defineProperty;
1611
+ var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
1612
+ var __decorateClass$2 = (decorators, target, key, kind) => {
1613
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
1614
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
1615
+ if (kind && result) __defProp$2(target, key, result);
1616
+ return result;
1617
+ };
1618
+ var ReferralProgram = class extends SmrtObject {
1619
+ tenantId = null;
1620
+ key = "";
1621
+ /** Human-readable program name. */
1622
+ name = "";
1623
+ /**
1624
+ * Program lifecycle: `draft` (default) → `active` → `paused` /
1625
+ * `archived`. Applications gate intake on `active`; this module records
1626
+ * the status without enforcing transition edges.
1627
+ */
1628
+ status = "draft";
1629
+ /**
1630
+ * Default commissions-plan key suggested to new ReferralAgreements.
1631
+ * Empty means the program has no default — agreements must name a plan.
1632
+ */
1633
+ defaultCommissionPlanKey = "";
1634
+ /**
1635
+ * Default AttributionPolicy key `AttributionService.resolve()` uses when
1636
+ * the caller passes no `policyKey` override.
1637
+ */
1638
+ defaultAttributionPolicyKey = "";
1639
+ /**
1640
+ * Free-form JSON object stored as a string. Use
1641
+ * {@link getMetadata}/{@link setMetadata} instead of parsing manually.
1642
+ */
1643
+ metadata = "{}";
1644
+ constructor(options = {}) {
1645
+ super(options);
1646
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
1647
+ if (options.key !== void 0) this.key = options.key;
1648
+ if (options.name !== void 0) this.name = options.name;
1649
+ if (options.status !== void 0) this.status = options.status;
1650
+ if (options.defaultCommissionPlanKey !== void 0) this.defaultCommissionPlanKey = options.defaultCommissionPlanKey;
1651
+ if (options.defaultAttributionPolicyKey !== void 0) this.defaultAttributionPolicyKey = options.defaultAttributionPolicyKey;
1652
+ if (options.metadata !== void 0) this.metadata = options.metadata;
1653
+ }
1654
+ /** Whether the program is accepting new referral activity. */
1655
+ isActive() {
1656
+ return this.status === "active";
1657
+ }
1658
+ /** Parse the metadata JSON string; returns `{}` on malformed content. */
1659
+ getMetadata() {
1660
+ try {
1661
+ const parsed = JSON.parse(this.metadata);
1662
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1663
+ return {};
1664
+ } catch {
1665
+ return {};
1666
+ }
1667
+ }
1668
+ /** Serialize and store the metadata object. */
1669
+ setMetadata(metadata) {
1670
+ this.metadata = JSON.stringify(metadata);
1671
+ }
1672
+ };
1673
+ __decorateClass$2([tenantId({ nullable: true })], ReferralProgram.prototype, "tenantId", 2);
1674
+ __decorateClass$2([field({ required: true })], ReferralProgram.prototype, "key", 2);
1675
+ ReferralProgram = __decorateClass$2([TenantScoped({ mode: "optional" }), smrt({
1676
+ conflictColumns: ["tenant_id", "key"],
1677
+ api: { include: [
1678
+ "list",
1679
+ "get",
1680
+ "create",
1681
+ "update"
1682
+ ] },
1683
+ mcp: { include: ["list", "get"] },
1684
+ cli: true
1685
+ })], ReferralProgram);
1686
+ //#endregion
1687
+ //#region src/referrals/collections/ReferralProgramCollection.ts
1688
+ var ReferralProgramCollection = class extends SmrtCollection {
1689
+ static _itemClass = ReferralProgram;
1690
+ /**
1691
+ * Look up a program by its natural key within the ambient tenant scope
1692
+ * (`(tenant_id, key)` is the conflict key, so under a tenant context the
1693
+ * auto-filter narrows this to at most one row).
1694
+ */
1695
+ async findByKey(key) {
1696
+ return (await this.list({
1697
+ where: { key },
1698
+ limit: 1
1699
+ }))[0] ?? null;
1700
+ }
1701
+ /** Programs by status. */
1702
+ async findByStatus(status) {
1703
+ return await this.list({
1704
+ where: { status },
1705
+ orderBy: "created_at DESC"
1706
+ });
1707
+ }
1708
+ /** All active programs. */
1709
+ async findActive() {
1710
+ return await this.findByStatus("active");
1711
+ }
1712
+ };
1713
+ //#endregion
1714
+ //#region src/referrals/models/ReferralTermSnapshot.ts
1715
+ var __defProp$1 = Object.defineProperty;
1716
+ var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
1717
+ var __decorateClass$1 = (decorators, target, key, kind) => {
1718
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
1719
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
1720
+ if (kind && result) __defProp$1(target, key, result);
1721
+ return result;
1722
+ };
1723
+ var persistedSnapshotState = /* @__PURE__ */ new WeakMap();
1724
+ var ReferralTermSnapshot = class extends SmrtObject {
1725
+ tenantId = null;
1726
+ referralId = "";
1727
+ /** Id of the ReferralAgreement the terms came from. */
1728
+ agreementId = "";
1729
+ /** Version of the agreement at qualification time. */
1730
+ agreementVersion = 0;
1731
+ /** CommissionPlan key the components were copied from. */
1732
+ planKey = "";
1733
+ /**
1734
+ * CommissionPlan version PINNED at qualification — when the agreement
1735
+ * left the version unpinned (`0`), this records the latest-active version
1736
+ * that was resolved.
1737
+ */
1738
+ planVersion = 0;
1739
+ /** AttributionPolicy key stamped on the referral at attribution. */
1740
+ policyKey = "";
1741
+ /** AttributionPolicy version stamped on the referral at attribution. */
1742
+ policyVersion = 0;
1743
+ /**
1744
+ * FROZEN copy of the plan's components JSON — THE calculation inputs.
1745
+ * Use {@link getComponents} for the typed array.
1746
+ */
1747
+ components = "[]";
1748
+ /** ISO 4217 currency the frozen terms are denominated in. */
1749
+ currency = "USD";
1750
+ /** Clearing window (days) copied from the agreement. */
1751
+ clearingDays = 0;
1752
+ /** Approval mode copied from the agreement. */
1753
+ approvalMode = "manual";
1754
+ /** Additional metadata as a JSON string (immutable like every field). */
1755
+ metadata = "{}";
1756
+ constructor(options = {}) {
1757
+ super(options);
1758
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
1759
+ if (options.referralId !== void 0) this.referralId = options.referralId;
1760
+ if (options.agreementId !== void 0) this.agreementId = options.agreementId;
1761
+ if (options.agreementVersion !== void 0) this.agreementVersion = options.agreementVersion;
1762
+ if (options.planKey !== void 0) this.planKey = options.planKey;
1763
+ if (options.planVersion !== void 0) this.planVersion = options.planVersion;
1764
+ if (options.policyKey !== void 0) this.policyKey = options.policyKey;
1765
+ if (options.policyVersion !== void 0) this.policyVersion = options.policyVersion;
1766
+ if (options.components !== void 0) this.components = options.components;
1767
+ if (options.currency !== void 0) this.currency = options.currency;
1768
+ if (options.clearingDays !== void 0) this.clearingDays = options.clearingDays;
1769
+ if (options.approvalMode !== void 0) this.approvalMode = options.approvalMode;
1770
+ if (options.metadata !== void 0) this.metadata = options.metadata;
1771
+ }
1772
+ /** Capture the persisted state so the save guard can reject mutations. */
1773
+ async initialize() {
1774
+ await super.initialize();
1775
+ if (await this.isSaved()) persistedSnapshotState.set(this, this.serializeState());
1776
+ return this;
1777
+ }
1778
+ /**
1779
+ * Parse {@link components} into the typed plan-component array the
1780
+ * calculation service consumes; returns `[]` on empty/invalid JSON.
1781
+ */
1782
+ getComponents() {
1783
+ if (!this.components) return [];
1784
+ try {
1785
+ const parsed = JSON.parse(this.components);
1786
+ return Array.isArray(parsed) ? parsed : [];
1787
+ } catch {
1788
+ return [];
1789
+ }
1790
+ }
1791
+ /** Parse {@link metadata}; returns `{}` on malformed content. */
1792
+ getMetadata() {
1793
+ if (!this.metadata) return {};
1794
+ try {
1795
+ const parsed = JSON.parse(this.metadata);
1796
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1797
+ } catch {
1798
+ return {};
1799
+ }
1800
+ }
1801
+ /**
1802
+ * Save with the full-immutability guard:
1803
+ *
1804
+ * - A hydrated persisted row must serialize IDENTICALLY to its captured
1805
+ * state — any field change throws (no-op re-saves pass).
1806
+ * - An instance carrying an existing row's id WITHOUT having hydrated it
1807
+ * (e.g. `create({ id, _skipLoad: true })`) is rejected outright — blind
1808
+ * overwrites can't sidestep the comparison.
1809
+ */
1810
+ async save() {
1811
+ const captured = persistedSnapshotState.get(this);
1812
+ if (captured !== void 0) {
1813
+ if (captured !== this.serializeState()) throw new Error(`ReferralTermSnapshot ${this.id ?? "<new>"}: snapshots are immutable once created. Requalify the referral (ReferralQualificationService.requalify) to snapshot new terms instead of editing this row.`);
1814
+ } else if (this.id && await this.isSaved()) throw new Error(`ReferralTermSnapshot ${this.id}: refusing to overwrite an existing snapshot row from a non-hydrated instance \u2014 snapshots are immutable once created.`);
1815
+ const result = await super.save();
1816
+ persistedSnapshotState.set(this, this.serializeState());
1817
+ return result;
1818
+ }
1819
+ serializeState() {
1820
+ return JSON.stringify({
1821
+ tenantId: this.tenantId,
1822
+ referralId: this.referralId,
1823
+ agreementId: this.agreementId,
1824
+ agreementVersion: this.agreementVersion,
1825
+ planKey: this.planKey,
1826
+ planVersion: this.planVersion,
1827
+ policyKey: this.policyKey,
1828
+ policyVersion: this.policyVersion,
1829
+ components: this.components,
1830
+ currency: this.currency,
1831
+ clearingDays: this.clearingDays,
1832
+ approvalMode: this.approvalMode,
1833
+ metadata: this.metadata
1834
+ });
1835
+ }
1836
+ };
1837
+ __decorateClass$1([tenantId({ nullable: true })], ReferralTermSnapshot.prototype, "tenantId", 2);
1838
+ __decorateClass$1([foreignKey("Referral", { required: true })], ReferralTermSnapshot.prototype, "referralId", 2);
1839
+ ReferralTermSnapshot = __decorateClass$1([TenantScoped({ mode: "optional" }), smrt({
1840
+ api: { include: ["list", "get"] },
1841
+ mcp: { include: ["list", "get"] },
1842
+ cli: false
1843
+ })], ReferralTermSnapshot);
1844
+ //#endregion
1845
+ //#region src/referrals/collections/ReferralTermSnapshotCollection.ts
1846
+ var ReferralTermSnapshotCollection = class extends SmrtCollection {
1847
+ static _itemClass = ReferralTermSnapshot;
1848
+ /**
1849
+ * Every snapshot minted for one referral, newest first — index 0 is the
1850
+ * currently governing snapshot only if the referral's `snapshotId` says
1851
+ * so (requalification keeps old rows as history).
1852
+ */
1853
+ async findByReferral(referralId) {
1854
+ return await this.list({
1855
+ where: { referralId },
1856
+ orderBy: "created_at DESC"
1857
+ });
1858
+ }
1859
+ };
1860
+ //#endregion
1861
+ //#region src/referrals/models/Referrer.ts
1862
+ var __defProp = Object.defineProperty;
1863
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
1864
+ var __decorateClass = (decorators, target, key, kind) => {
1865
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
1866
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
1867
+ if (kind && result) __defProp(target, key, result);
1868
+ return result;
1869
+ };
1870
+ var Referrer = class extends SmrtObject {
1871
+ tenantId = null;
1872
+ profileId = "";
1873
+ earnerId = "";
1874
+ /** Human-readable display name for portals and operator views. */
1875
+ displayName = "";
1876
+ /**
1877
+ * Role lifecycle: `pending` (default, awaiting approval) → `active` /
1878
+ * `suspended`. Only active referrers should receive new links/agreements.
1879
+ */
1880
+ status = "pending";
1881
+ /**
1882
+ * Free-form JSON object stored as a string. Use
1883
+ * {@link getMetadata}/{@link setMetadata} instead of parsing manually.
1884
+ */
1885
+ metadata = "{}";
1886
+ constructor(options = {}) {
1887
+ super(options);
1888
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
1889
+ if (options.profileId !== void 0) this.profileId = options.profileId;
1890
+ if (options.earnerId !== void 0) this.earnerId = options.earnerId;
1891
+ if (options.displayName !== void 0) this.displayName = options.displayName;
1892
+ if (options.status !== void 0) this.status = options.status;
1893
+ if (options.metadata !== void 0) this.metadata = options.metadata;
1894
+ }
1895
+ /** Whether the referrer may receive new links/agreements. */
1896
+ isActive() {
1897
+ return this.status === "active";
1898
+ }
1899
+ isPending() {
1900
+ return this.status === "pending";
1901
+ }
1902
+ isSuspended() {
1903
+ return this.status === "suspended";
1904
+ }
1905
+ /** Parse the metadata JSON string; returns `{}` on malformed content. */
1906
+ getMetadata() {
1907
+ try {
1908
+ const parsed = JSON.parse(this.metadata);
1909
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1910
+ return {};
1911
+ } catch {
1912
+ return {};
1913
+ }
1914
+ }
1915
+ /** Serialize and store the metadata object. */
1916
+ setMetadata(metadata) {
1917
+ this.metadata = JSON.stringify(metadata);
1918
+ }
1919
+ };
1920
+ __decorateClass([tenantId({ nullable: true })], Referrer.prototype, "tenantId", 2);
1921
+ __decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], Referrer.prototype, "profileId", 2);
1922
+ __decorateClass([foreignKey("Earner")], Referrer.prototype, "earnerId", 2);
1923
+ Referrer = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
1924
+ api: { include: [
1925
+ "list",
1926
+ "get",
1927
+ "create",
1928
+ "update"
1929
+ ] },
1930
+ mcp: { include: [
1931
+ "list",
1932
+ "get",
1933
+ "create"
1934
+ ] },
1935
+ cli: true
1936
+ })], Referrer);
1937
+ //#endregion
1938
+ //#region src/referrals/collections/ReferrerCollection.ts
1939
+ var ReferrerCollection = class extends SmrtCollection {
1940
+ static _itemClass = Referrer;
1941
+ /**
1942
+ * Find referrer roles held by a given profile (a profile may hold the
1943
+ * role in several tenants/contexts).
1944
+ */
1945
+ async findByProfile(profileId) {
1946
+ return await this.list({
1947
+ where: { profileId },
1948
+ orderBy: "created_at DESC"
1949
+ });
1950
+ }
1951
+ /** Referrers by status. */
1952
+ async findByStatus(status) {
1953
+ return await this.list({
1954
+ where: { status },
1955
+ orderBy: "created_at DESC"
1956
+ });
1957
+ }
1958
+ /** All active referrers (eligible for new links/agreements). */
1959
+ async findActive() {
1960
+ return await this.findByStatus("active");
1961
+ }
1962
+ };
1963
+ //#endregion
1964
+ //#region src/referrals/services/AttributionService.ts
1965
+ var MS_PER_DAY = 1440 * 60 * 1e3;
1966
+ var QualifiedReferralOverrideError = class extends Error {
1967
+ constructor(referralIds) {
1968
+ super(`AttributionService.override: target already has qualified referral(s) [${referralIds.join(", ")}] \u2014 qualified credit is governed by term snapshots and commission adjustments downstream, not re-attribution.`);
1969
+ this.name = "QualifiedReferralOverrideError";
1970
+ }
1971
+ };
1972
+ var AttributionService = class AttributionService {
1973
+ constructor(deps) {
1974
+ this.deps = deps;
1975
+ }
1976
+ deps;
1977
+ static async create(classOptions = {}) {
1978
+ return new AttributionService({
1979
+ touches: await ReferralTouchCollection.create(classOptions),
1980
+ referrals: await ReferralCollection.create(classOptions),
1981
+ exceptions: await AttributionExceptionCollection.create(classOptions),
1982
+ policies: await AttributionPolicyCollection.create(classOptions),
1983
+ programs: await ReferralProgramCollection.create(classOptions),
1984
+ referrers: await ReferrerCollection.create(classOptions)
1985
+ });
1986
+ }
1987
+ /**
1988
+ * Resolve attribution for one target under one program.
1989
+ *
1990
+ * IDEMPOTENT: existing non-disqualified `attributed`/`qualified`
1991
+ * referrals for the target+program are returned as-is (no new rows), and
1992
+ * an already-OPEN exception for the target+program is returned instead of
1993
+ * a duplicate.
1994
+ *
1995
+ * Flow:
1996
+ * 1. **Policy** — the program's `defaultAttributionPolicyKey` (or the
1997
+ * `policyKey` override) is resolved via `latestActiveByKey`; no active
1998
+ * version → `refused: 'no_active_policy'`.
1999
+ * 2. **Existing-client gate** — `isExistingClient` with a policy that
2000
+ * disallows it → `refused: 'existing_client_ineligible'`.
2001
+ * 3. **Candidates** — touches gathered by the subject pair (when given)
2002
+ * and by the target pair, deduplicated, restricted to the program and
2003
+ * to `occurredAt` within `[now - windowDays, now]`.
2004
+ * 4. **Eligibility** — self-referrals (candidate referrer's `profileId`
2005
+ * equals `subjectProfileId`) are dropped unless the policy allows
2006
+ * them. Nothing left → `refused: 'no_eligible_touches'`.
2007
+ * 5. **Conflicts** — `conflictBehavior: 'review'` with more than one
2008
+ * distinct eligible referrer, exact-timestamp ties between distinct
2009
+ * referrers in `first_touch`/`last_touch`, or competing manual
2010
+ * assignments in `assigned` mode → an OPEN AttributionException
2011
+ * carrying the candidates; NO referral rows.
2012
+ * 6. **Credit** — otherwise the mode elects the winner(s):
2013
+ * `first_touch`/`last_touch` (earliest/latest), `assigned` (most
2014
+ * recent manual assignment; assignments beat clicks by definition),
2015
+ * or `split` (every distinct eligible referrer, equal fractions
2016
+ * rounded to 4 dp with the remainder on the last, one shared
2017
+ * `splitGroupId`). Referrals are created `pending → attributed` with
2018
+ * the policy pin, winning touch, `attributedAt = now`, and
2019
+ * `expiresAt = now + windowDays`.
2020
+ */
2021
+ async resolve(input) {
2022
+ const now = input.now ?? /* @__PURE__ */ new Date();
2023
+ const existing = (await this.deps.referrals.findByTarget(input.targetKind, input.targetId)).filter((referral) => referral.programId === input.programId && (referral.status === "attributed" || referral.status === "qualified"));
2024
+ if (existing.length > 0) return {
2025
+ referrals: existing,
2026
+ exception: null
2027
+ };
2028
+ const openException = await this.deps.exceptions.findOpenByTarget(input.targetKind, input.targetId, input.programId);
2029
+ if (openException) return {
2030
+ referrals: [],
2031
+ exception: openException
2032
+ };
2033
+ const program = await this.requireProgram(input.programId);
2034
+ const policy = await this.resolvePolicy(program, input.policyKey, now);
2035
+ if (!policy) return {
2036
+ referrals: [],
2037
+ exception: null,
2038
+ refused: "no_active_policy"
2039
+ };
2040
+ if (input.isExistingClient && !policy.allowExistingClients) return {
2041
+ referrals: [],
2042
+ exception: null,
2043
+ refused: "existing_client_ineligible"
2044
+ };
2045
+ const eligible = await this.gatherEligibleTouches(input, policy, now);
2046
+ if (eligible.length === 0) return {
2047
+ referrals: [],
2048
+ exception: null,
2049
+ refused: "no_eligible_touches"
2050
+ };
2051
+ const distinctReferrerIds = [...new Set(eligible.map((touch) => touch.referrerId))];
2052
+ if (policy.conflictBehavior === "review" && distinctReferrerIds.length > 1) return {
2053
+ referrals: [],
2054
+ exception: await this.createOpenException({
2055
+ input,
2056
+ program,
2057
+ policy,
2058
+ conflictReason: "policy_review",
2059
+ candidates: eligible
2060
+ })
2061
+ };
2062
+ switch (policy.creditMode) {
2063
+ case "first_touch":
2064
+ case "last_touch": {
2065
+ const winnerTime = policy.creditMode === "first_touch" ? eligible[0].occurredAt.getTime() : eligible[eligible.length - 1].occurredAt.getTime();
2066
+ const atWinnerTime = eligible.filter((touch) => touch.occurredAt.getTime() === winnerTime);
2067
+ if (new Set(atWinnerTime.map((touch) => touch.referrerId)).size > 1) return {
2068
+ referrals: [],
2069
+ exception: await this.createOpenException({
2070
+ input,
2071
+ program,
2072
+ policy,
2073
+ conflictReason: "tie",
2074
+ candidates: eligible
2075
+ })
2076
+ };
2077
+ const winner = policy.creditMode === "first_touch" ? atWinnerTime[0] : atWinnerTime[atWinnerTime.length - 1];
2078
+ return {
2079
+ referrals: [await this.createAttributedReferral({
2080
+ tenantId: program.tenantId,
2081
+ referrerId: winner.referrerId,
2082
+ programId: input.programId,
2083
+ targetKind: input.targetKind,
2084
+ targetId: input.targetId,
2085
+ policy,
2086
+ creditFraction: 1,
2087
+ splitGroupId: "",
2088
+ primaryTouchId: winner.id ?? "",
2089
+ now
2090
+ })],
2091
+ exception: null
2092
+ };
2093
+ }
2094
+ case "assigned": {
2095
+ const assignments = eligible.filter((touch) => touch.kind === "manual_assignment");
2096
+ if (assignments.length === 0) return {
2097
+ referrals: [],
2098
+ exception: null,
2099
+ refused: "no_eligible_touches"
2100
+ };
2101
+ if (new Set(assignments.map((touch) => touch.referrerId)).size > 1) return {
2102
+ referrals: [],
2103
+ exception: await this.createOpenException({
2104
+ input,
2105
+ program,
2106
+ policy,
2107
+ conflictReason: "competing_touches",
2108
+ candidates: assignments
2109
+ })
2110
+ };
2111
+ const winner = assignments[assignments.length - 1];
2112
+ return {
2113
+ referrals: [await this.createAttributedReferral({
2114
+ tenantId: program.tenantId,
2115
+ referrerId: winner.referrerId,
2116
+ programId: input.programId,
2117
+ targetKind: input.targetKind,
2118
+ targetId: input.targetId,
2119
+ policy,
2120
+ creditFraction: 1,
2121
+ splitGroupId: "",
2122
+ primaryTouchId: winner.id ?? "",
2123
+ now
2124
+ })],
2125
+ exception: null
2126
+ };
2127
+ }
2128
+ case "split": {
2129
+ const fractions = AttributionService.equalFractions(distinctReferrerIds.length);
2130
+ const splitGroupId = distinctReferrerIds.length > 1 ? randomUUID() : "";
2131
+ const referrals = [];
2132
+ for (let i = 0; i < distinctReferrerIds.length; i++) {
2133
+ const referrerId = distinctReferrerIds[i];
2134
+ const primaryTouch = eligible.find((touch) => touch.referrerId === referrerId);
2135
+ referrals.push(await this.createAttributedReferral({
2136
+ tenantId: program.tenantId,
2137
+ referrerId,
2138
+ programId: input.programId,
2139
+ targetKind: input.targetKind,
2140
+ targetId: input.targetId,
2141
+ policy,
2142
+ creditFraction: fractions[i],
2143
+ splitGroupId,
2144
+ primaryTouchId: primaryTouch?.id ?? "",
2145
+ now
2146
+ }));
2147
+ }
2148
+ return {
2149
+ referrals,
2150
+ exception: null
2151
+ };
2152
+ }
2153
+ }
2154
+ }
2155
+ /**
2156
+ * Record an operator's manual assignment as an immutable
2157
+ * `manual_assignment` ReferralTouch (evidence carries the actor, reason,
2158
+ * and target) and return it. The caller then runs {@link resolve} — under
2159
+ * an `assigned`-mode policy the assignment wins over click evidence.
2160
+ */
2161
+ async recordManualAssignment(input) {
2162
+ if (!input.actorProfileId) throw new Error("AttributionService.recordManualAssignment requires an actorProfileId");
2163
+ const program = await this.requireProgram(input.programId);
2164
+ const occurredAt = input.occurredAt ?? /* @__PURE__ */ new Date();
2165
+ const useSubject = Boolean(input.subjectKind && input.subjectId);
2166
+ return await this.deps.touches.create({
2167
+ tenantId: program.tenantId,
2168
+ referrerId: input.referrerId,
2169
+ programId: input.programId,
2170
+ kind: "manual_assignment",
2171
+ subjectKind: useSubject ? input.subjectKind : input.targetKind,
2172
+ subjectId: useSubject ? input.subjectId : input.targetId,
2173
+ occurredAt,
2174
+ evidence: JSON.stringify({
2175
+ actorProfileId: input.actorProfileId,
2176
+ reason: input.reason ?? "",
2177
+ targetKind: input.targetKind,
2178
+ targetId: input.targetId
2179
+ })
2180
+ });
2181
+ }
2182
+ /**
2183
+ * Resolve an OPEN exception by awarding credit explicitly.
2184
+ *
2185
+ * Requires a non-empty `resolutionReason` (throws otherwise) and awards
2186
+ * whose `creditFraction`s sum to 1.0 (±0.0001) across distinct referrers.
2187
+ * Creates the attributed referral(s) from the exception's target/program
2188
+ * (split semantics — shared `splitGroupId` — when several awards), then
2189
+ * marks the exception resolved (`resolutionMode: 'override'`, the award
2190
+ * audit fields, `resolvedReferralIds`).
2191
+ */
2192
+ async resolveException(input) {
2193
+ AttributionService.assertResolutionReason(input.resolutionReason, "resolveException");
2194
+ AttributionService.assertActor(input.actorProfileId, "resolveException");
2195
+ AttributionService.assertAwards(input.awards);
2196
+ const now = input.now ?? /* @__PURE__ */ new Date();
2197
+ const exception = await this.deps.exceptions.get({ id: input.exceptionId });
2198
+ if (!exception) throw new Error(`AttributionService.resolveException: exception '${input.exceptionId}' not found`);
2199
+ if (!exception.isOpen()) throw new Error(`AttributionService.resolveException: exception '${input.exceptionId}' is already resolved`);
2200
+ const policyPin = await this.resolvePolicyPin(exception);
2201
+ const candidates = exception.getCandidates();
2202
+ const splitGroupId = input.awards.length > 1 ? randomUUID() : "";
2203
+ const referrals = [];
2204
+ for (const award of input.awards) {
2205
+ const candidate = candidates.find((entry) => entry.referrerId === award.referrerId);
2206
+ referrals.push(await this.createAttributedReferral({
2207
+ tenantId: exception.tenantId,
2208
+ referrerId: award.referrerId,
2209
+ programId: exception.programId,
2210
+ targetKind: exception.targetKind,
2211
+ targetId: exception.targetId,
2212
+ policy: null,
2213
+ policyPin,
2214
+ creditFraction: award.creditFraction,
2215
+ splitGroupId,
2216
+ primaryTouchId: candidate?.touchId ?? "",
2217
+ now
2218
+ }));
2219
+ }
2220
+ exception.markResolved({
2221
+ mode: "override",
2222
+ reason: input.resolutionReason,
2223
+ resolvedByProfileId: input.actorProfileId,
2224
+ referralIds: referrals.map((referral) => referral.id ?? ""),
2225
+ at: now
2226
+ });
2227
+ await exception.save();
2228
+ return {
2229
+ referrals,
2230
+ exception
2231
+ };
2232
+ }
2233
+ /**
2234
+ * Re-attribute an already-attributed target.
2235
+ *
2236
+ * Requires a non-empty `resolutionReason` and awards summing to 1.0.
2237
+ * REFUSES (throws {@link QualifiedReferralOverrideError}) when the target
2238
+ * has a QUALIFIED referral — those are governed by term snapshots and
2239
+ * commission adjustments downstream. Otherwise: disqualifies the existing
2240
+ * `attributed` referrals for the target+program (stamping the audit
2241
+ * exception's id into their metadata), creates the newly awarded
2242
+ * attributed referral(s), and writes a RESOLVED AttributionException as
2243
+ * the audit record (candidates = the displaced + awarded referrers).
2244
+ *
2245
+ * The new referrals inherit the POLICY PIN the displaced credit carried
2246
+ * (an override re-decides WHO earned the introduction, not which policy
2247
+ * version governed it); the program's current default active policy is
2248
+ * the fallback when nothing was displaced.
2249
+ */
2250
+ async override(input) {
2251
+ AttributionService.assertResolutionReason(input.resolutionReason, "override");
2252
+ AttributionService.assertActor(input.actorProfileId, "override");
2253
+ AttributionService.assertAwards(input.awards);
2254
+ const now = input.now ?? /* @__PURE__ */ new Date();
2255
+ const forTarget = (await this.deps.referrals.findByTarget(input.targetKind, input.targetId)).filter((referral) => referral.programId === input.programId);
2256
+ const qualified = forTarget.filter((referral) => referral.status === "qualified");
2257
+ if (qualified.length > 0) throw new QualifiedReferralOverrideError(qualified.map((referral) => referral.id ?? ""));
2258
+ const displaced = forTarget.filter((referral) => referral.status === "attributed");
2259
+ const program = await this.requireProgram(input.programId);
2260
+ const policyPin = await this.resolveOverridePolicyPin(program, displaced);
2261
+ const candidates = [];
2262
+ for (const referral of displaced) {
2263
+ const touch = referral.primaryTouchId ? await this.deps.touches.get({ id: referral.primaryTouchId }) : null;
2264
+ candidates.push({
2265
+ touchId: referral.primaryTouchId,
2266
+ referrerId: referral.referrerId,
2267
+ kind: touch?.kind ?? "manual_assignment",
2268
+ occurredAt: (touch?.occurredAt ?? referral.attributedAt ?? now).toISOString()
2269
+ });
2270
+ }
2271
+ for (const award of input.awards) candidates.push({
2272
+ touchId: "",
2273
+ referrerId: award.referrerId,
2274
+ kind: "manual_assignment",
2275
+ occurredAt: now.toISOString()
2276
+ });
2277
+ const exception = await this.deps.exceptions.create({
2278
+ tenantId: program.tenantId,
2279
+ targetKind: input.targetKind,
2280
+ targetId: input.targetId,
2281
+ programId: input.programId,
2282
+ status: "open",
2283
+ conflictReason: "override",
2284
+ candidates: JSON.stringify(candidates),
2285
+ metadata: JSON.stringify(policyPin)
2286
+ });
2287
+ for (const referral of displaced) {
2288
+ referral.setMetadata({
2289
+ ...referral.getMetadata(),
2290
+ overriddenByExceptionId: exception.id ?? "",
2291
+ overriddenAt: now.toISOString()
2292
+ });
2293
+ referral.disqualify();
2294
+ await referral.save();
2295
+ }
2296
+ const splitGroupId = input.awards.length > 1 ? randomUUID() : "";
2297
+ const referrals = [];
2298
+ for (const award of input.awards) referrals.push(await this.createAttributedReferral({
2299
+ tenantId: program.tenantId,
2300
+ referrerId: award.referrerId,
2301
+ programId: input.programId,
2302
+ targetKind: input.targetKind,
2303
+ targetId: input.targetId,
2304
+ policy: null,
2305
+ policyPin,
2306
+ creditFraction: award.creditFraction,
2307
+ splitGroupId,
2308
+ primaryTouchId: "",
2309
+ now
2310
+ }));
2311
+ exception.markResolved({
2312
+ mode: "override",
2313
+ reason: input.resolutionReason,
2314
+ resolvedByProfileId: input.actorProfileId,
2315
+ referralIds: referrals.map((referral) => referral.id ?? ""),
2316
+ at: now
2317
+ });
2318
+ await exception.save();
2319
+ return {
2320
+ referrals,
2321
+ exception
2322
+ };
2323
+ }
2324
+ async requireProgram(programId) {
2325
+ const program = await this.deps.programs.get({ id: programId });
2326
+ if (!program) throw new Error(`AttributionService: program '${programId}' not found`);
2327
+ return program;
2328
+ }
2329
+ /**
2330
+ * The active policy governing a resolution: the explicit `policyKey`
2331
+ * override when given, else the program's default key; resolved to its
2332
+ * latest ACTIVE version. `null` when no key or no active version.
2333
+ */
2334
+ async resolvePolicy(program, policyKeyOverride, at = /* @__PURE__ */ new Date()) {
2335
+ const policyKey = policyKeyOverride ?? program.defaultAttributionPolicyKey;
2336
+ if (!policyKey) return null;
2337
+ return await this.deps.policies.latestActiveByKey(policyKey, at, program.tenantId);
2338
+ }
2339
+ /**
2340
+ * Candidate touches for a resolution: gathered by the subject pair (when
2341
+ * given) and by the target pair, deduplicated, restricted to the program,
2342
+ * to `occurredAt` within `[now - windowDays, now]`, and to referrers that
2343
+ * pass the self-referral gate. Sorted by `occurredAt` ascending.
2344
+ */
2345
+ async gatherEligibleTouches(input, policy, now) {
2346
+ const byId = /* @__PURE__ */ new Map();
2347
+ if (input.subjectKind && input.subjectId) for (const touch of await this.deps.touches.findBySubject(input.subjectKind, input.subjectId)) byId.set(touch.id ?? "", touch);
2348
+ for (const touch of await this.deps.touches.findBySubject(input.targetKind, input.targetId)) byId.set(touch.id ?? "", touch);
2349
+ const windowStart = now.getTime() - policy.windowDays * MS_PER_DAY;
2350
+ const inWindow = [...byId.values()].filter((touch) => {
2351
+ if (touch.programId !== input.programId) return false;
2352
+ const at = touch.occurredAt.getTime();
2353
+ return at >= windowStart && at <= now.getTime();
2354
+ });
2355
+ let eligible = inWindow;
2356
+ if (!policy.allowSelfReferral && input.subjectProfileId) {
2357
+ const referrerProfiles = /* @__PURE__ */ new Map();
2358
+ for (const touch of inWindow) {
2359
+ if (referrerProfiles.has(touch.referrerId)) continue;
2360
+ const referrer = await this.deps.referrers.get({ id: touch.referrerId });
2361
+ referrerProfiles.set(touch.referrerId, referrer?.profileId ?? "");
2362
+ }
2363
+ eligible = inWindow.filter((touch) => referrerProfiles.get(touch.referrerId) !== input.subjectProfileId);
2364
+ }
2365
+ return eligible.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
2366
+ }
2367
+ /** Create an OPEN exception carrying the competing candidates. */
2368
+ async createOpenException(params) {
2369
+ const { input, program, policy } = params;
2370
+ return await this.deps.exceptions.create({
2371
+ tenantId: program.tenantId,
2372
+ targetKind: input.targetKind,
2373
+ targetId: input.targetId,
2374
+ programId: input.programId,
2375
+ status: "open",
2376
+ conflictReason: params.conflictReason,
2377
+ candidates: JSON.stringify(params.candidates.map((touch) => ({
2378
+ touchId: touch.id ?? "",
2379
+ referrerId: touch.referrerId,
2380
+ kind: touch.kind,
2381
+ occurredAt: touch.occurredAt.toISOString()
2382
+ }))),
2383
+ metadata: JSON.stringify({
2384
+ policyKey: policy.policyKey,
2385
+ policyVersion: policy.version,
2386
+ windowDays: policy.windowDays
2387
+ })
2388
+ });
2389
+ }
2390
+ /**
2391
+ * The policy pin an exception was raised under (stamped into its
2392
+ * metadata at creation); falls back to the program's current default
2393
+ * active policy for exceptions created without one.
2394
+ */
2395
+ async resolvePolicyPin(exception) {
2396
+ const meta = exception.getMetadata();
2397
+ if (typeof meta.policyKey === "string" && meta.policyKey) return {
2398
+ policyKey: meta.policyKey,
2399
+ policyVersion: typeof meta.policyVersion === "number" ? meta.policyVersion : 0,
2400
+ windowDays: typeof meta.windowDays === "number" ? meta.windowDays : null
2401
+ };
2402
+ if (exception.programId) {
2403
+ const program = await this.deps.programs.get({ id: exception.programId });
2404
+ if (program) {
2405
+ const policy = await this.resolvePolicy(program, void 0);
2406
+ if (policy) return {
2407
+ policyKey: policy.policyKey,
2408
+ policyVersion: policy.version,
2409
+ windowDays: policy.windowDays
2410
+ };
2411
+ }
2412
+ }
2413
+ return {
2414
+ policyKey: "",
2415
+ policyVersion: 0,
2416
+ windowDays: null
2417
+ };
2418
+ }
2419
+ /**
2420
+ * The policy pin an override re-attributes under: the pin the displaced
2421
+ * credit carried (looking the exact version's `windowDays` back up for
2422
+ * the new expiry), falling back to the program's current default active
2423
+ * policy when nothing pinned was displaced.
2424
+ */
2425
+ async resolveOverridePolicyPin(program, displaced) {
2426
+ const pinned = displaced.find((referral) => referral.policyKey);
2427
+ if (pinned) {
2428
+ const versions = await this.deps.policies.list({ where: {
2429
+ policyKey: pinned.policyKey,
2430
+ version: pinned.policyVersion
2431
+ } });
2432
+ const laneVersion = versions.find((policy2) => policy2.tenantId === program.tenantId) ?? versions.find((policy2) => policy2.tenantId === null) ?? null;
2433
+ return {
2434
+ policyKey: pinned.policyKey,
2435
+ policyVersion: pinned.policyVersion,
2436
+ windowDays: laneVersion?.windowDays ?? null
2437
+ };
2438
+ }
2439
+ const policy = await this.resolvePolicy(program, void 0);
2440
+ return policy ? {
2441
+ policyKey: policy.policyKey,
2442
+ policyVersion: policy.version,
2443
+ windowDays: policy.windowDays
2444
+ } : {
2445
+ policyKey: "",
2446
+ policyVersion: 0,
2447
+ windowDays: null
2448
+ };
2449
+ }
2450
+ /**
2451
+ * Create one referral as `pending`, transition it `pending → attributed`
2452
+ * (stamping `attributedAt`), set the policy pin and expiry, and save.
2453
+ */
2454
+ async createAttributedReferral(params) {
2455
+ const pin = params.policy ? {
2456
+ policyKey: params.policy.policyKey,
2457
+ policyVersion: params.policy.version,
2458
+ windowDays: params.policy.windowDays
2459
+ } : params.policyPin ?? {
2460
+ policyKey: "",
2461
+ policyVersion: 0,
2462
+ windowDays: null
2463
+ };
2464
+ const referral = await this.deps.referrals.create({
2465
+ tenantId: params.tenantId,
2466
+ referrerId: params.referrerId,
2467
+ programId: params.programId,
2468
+ targetKind: params.targetKind,
2469
+ targetId: params.targetId,
2470
+ policyKey: pin.policyKey,
2471
+ policyVersion: pin.policyVersion,
2472
+ creditFraction: params.creditFraction,
2473
+ splitGroupId: params.splitGroupId,
2474
+ primaryTouchId: params.primaryTouchId,
2475
+ status: "pending"
2476
+ });
2477
+ referral.markAttributed(params.now);
2478
+ referral.expiresAt = pin.windowDays !== null ? new Date(params.now.getTime() + pin.windowDays * MS_PER_DAY) : null;
2479
+ await referral.save();
2480
+ return referral;
2481
+ }
2482
+ /**
2483
+ * Equal split fractions for `n` referrers, each rounded to 4 decimal
2484
+ * places, with the LAST adjusted so the set sums to exactly 1.0.
2485
+ */
2486
+ static equalFractions(n) {
2487
+ if (n <= 1) return [1];
2488
+ const base = Math.floor(1e4 / n) / 1e4;
2489
+ const fractions = new Array(n).fill(base);
2490
+ fractions[n - 1] = Math.round((1 - base * (n - 1)) * 1e4) / 1e4;
2491
+ return fractions;
2492
+ }
2493
+ static assertResolutionReason(reason, method) {
2494
+ if (!reason?.trim()) throw new Error(`AttributionService.${method} requires a non-empty resolutionReason`);
2495
+ }
2496
+ /**
2497
+ * Attribution decisions are audited — a blank actor would permanently
2498
+ * resolve/override credit with an empty `resolvedByProfileId`, defeating
2499
+ * the audit trail.
2500
+ */
2501
+ static assertActor(actorProfileId, method) {
2502
+ if (!actorProfileId?.trim()) throw new Error(`AttributionService.${method} requires a non-empty actorProfileId`);
2503
+ }
2504
+ static assertAwards(awards) {
2505
+ if (!awards || awards.length === 0) throw new Error("AttributionService: at least one award is required");
2506
+ const seen = /* @__PURE__ */ new Set();
2507
+ let sum = 0;
2508
+ for (const award of awards) {
2509
+ if (!award.referrerId) throw new Error("AttributionService: award.referrerId is required");
2510
+ if (seen.has(award.referrerId)) throw new Error(`AttributionService: duplicate award for referrer '${award.referrerId}'`);
2511
+ seen.add(award.referrerId);
2512
+ if (!Number.isFinite(award.creditFraction) || award.creditFraction <= 0 || award.creditFraction > 1) throw new Error(`AttributionService: award.creditFraction must be in (0, 1] \u2014 got ${award.creditFraction}`);
2513
+ sum += award.creditFraction;
2514
+ }
2515
+ if (Math.abs(sum - 1) > 1e-4) throw new Error(`AttributionService: award creditFractions must sum to 1.0 (\xB10.0001) \u2014 got ${sum}`);
2516
+ }
2517
+ };
2518
+ //#endregion
2519
+ //#region src/referrals/services/ReferralCommissionService.ts
2520
+ var REFERRAL_TERMS_SNAPSHOT_KIND = "referral_term_snapshot";
2521
+ var ReferralCommissionService = class ReferralCommissionService {
2522
+ constructor(deps) {
2523
+ this.deps = deps;
2524
+ }
2525
+ deps;
2526
+ static async create(classOptions = {}) {
2527
+ return new ReferralCommissionService({
2528
+ referrals: await ReferralCollection.create(classOptions),
2529
+ snapshots: await ReferralTermSnapshotCollection.create(classOptions),
2530
+ referrers: await ReferrerCollection.create(classOptions),
2531
+ calculation: await CommissionCalculationService.create(classOptions),
2532
+ commissions: await CommissionCollection.create(classOptions)
2533
+ });
2534
+ }
2535
+ /**
2536
+ * Turn one earning event into Commissions for every qualified referral it
2537
+ * concerns.
2538
+ *
2539
+ * Referral resolution: explicit `referralIds` when given (rows that are
2540
+ * missing / not qualified are reported in `skippedReferrals`), else
2541
+ * `findQualifiedByTarget(targetKind, targetId)`. One of the two forms is
2542
+ * required.
2543
+ *
2544
+ * Per referral:
2545
+ * 1. Load its ReferralTermSnapshot (`'missing_snapshot'` skip when the
2546
+ * pointer is empty/dangling).
2547
+ * 2. Resolve the referrer's `earnerId`
2548
+ * (`'referrer_missing_earner'` skip when absent — roles without a
2549
+ * payout account cannot earn).
2550
+ * 3. Delegate to `CommissionCalculationService.calculateForEvent` with
2551
+ * the snapshot's plan pin, FROZEN components, currency (mismatches
2552
+ * surface as the calc service's `currency_mismatch` skips),
2553
+ * clearingDays, the referral's `creditFraction` as the share, its
2554
+ * `splitGroupId`, the
2555
+ * `('referral_term_snapshot', snapshot.id)` terms reference, and an
2556
+ * occurrence resolver counting existing Commissions for
2557
+ * `(termsSnapshotId, componentKey)` from PRIOR events.
2558
+ *
2559
+ * Fully idempotent on replay: the calculation service's dedupe keys
2560
+ * return prior rows in each outcome's `existing`.
2561
+ */
2562
+ async processEarningEvent(input) {
2563
+ const { event } = input;
2564
+ if (!event?.id) throw new Error("ReferralCommissionService.processEarningEvent requires a persisted event (missing id)");
2565
+ const skippedReferrals = [];
2566
+ const referrals = [];
2567
+ if (input.referralIds && input.referralIds.length > 0) for (const referralId of input.referralIds) {
2568
+ const referral = await this.deps.referrals.get({ id: referralId });
2569
+ if (!referral) {
2570
+ skippedReferrals.push({
2571
+ referralId,
2572
+ reason: "referral_not_found"
2573
+ });
2574
+ continue;
2575
+ }
2576
+ if (referral.status !== "qualified") {
2577
+ skippedReferrals.push({
2578
+ referralId,
2579
+ reason: "not_qualified"
2580
+ });
2581
+ continue;
2582
+ }
2583
+ referrals.push(referral);
2584
+ }
2585
+ else if (input.targetKind && input.targetId) referrals.push(...await this.deps.referrals.findQualifiedByTarget(input.targetKind, input.targetId));
2586
+ else throw new Error("ReferralCommissionService.processEarningEvent requires referralIds or a (targetKind, targetId) pair");
2587
+ const results = [];
2588
+ for (const referral of referrals) {
2589
+ const referralId = referral.id ?? "";
2590
+ const snapshot = referral.snapshotId ? await this.deps.snapshots.get({ id: referral.snapshotId }) : null;
2591
+ if (!snapshot?.id) {
2592
+ skippedReferrals.push({
2593
+ referralId,
2594
+ reason: "missing_snapshot"
2595
+ });
2596
+ continue;
2597
+ }
2598
+ const lane = referral.tenantId ?? null;
2599
+ if (event.tenantId !== null && event.tenantId !== lane || (snapshot.tenantId ?? null) !== lane) {
2600
+ skippedReferrals.push({
2601
+ referralId,
2602
+ reason: "tenant_mismatch"
2603
+ });
2604
+ continue;
2605
+ }
2606
+ const referrer = await this.deps.referrers.get({ id: referral.referrerId });
2607
+ if (referrer && referrer.tenantId !== null && referrer.tenantId !== lane) {
2608
+ skippedReferrals.push({
2609
+ referralId,
2610
+ reason: "tenant_mismatch"
2611
+ });
2612
+ continue;
2613
+ }
2614
+ if (!referrer?.earnerId) {
2615
+ skippedReferrals.push({
2616
+ referralId,
2617
+ reason: "referrer_missing_earner"
2618
+ });
2619
+ continue;
2620
+ }
2621
+ const snapshotId = snapshot.id;
2622
+ const calculation = await this.deps.calculation.calculateForEvent({
2623
+ event,
2624
+ planKey: snapshot.planKey,
2625
+ planVersion: snapshot.planVersion,
2626
+ components: snapshot.getComponents(),
2627
+ earnerId: referrer.earnerId,
2628
+ shareFraction: referral.creditFraction,
2629
+ splitGroupId: referral.splitGroupId,
2630
+ termsSnapshotKind: REFERRAL_TERMS_SNAPSHOT_KIND,
2631
+ termsSnapshotId: snapshotId,
2632
+ currency: snapshot.currency,
2633
+ clearingDays: snapshot.clearingDays,
2634
+ anchorAt: referral.qualifiedAt ?? void 0,
2635
+ occurrenceCountResolver: async (componentKey) => {
2636
+ return (await this.deps.commissions.list({ where: {
2637
+ termsSnapshotId: snapshotId,
2638
+ componentKey
2639
+ } })).filter((commission) => commission.earningEventId !== event.id).length;
2640
+ }
2641
+ });
2642
+ results.push({
2643
+ referralId,
2644
+ created: calculation.created,
2645
+ existing: calculation.existing,
2646
+ skipped: calculation.skipped
2647
+ });
2648
+ }
2649
+ return {
2650
+ results,
2651
+ skippedReferrals
2652
+ };
2653
+ }
2654
+ };
2655
+ //#endregion
2656
+ //#region src/referrals/services/ReferralQualificationService.ts
2657
+ var ReferralQualificationService = class ReferralQualificationService {
2658
+ constructor(deps) {
2659
+ this.deps = deps;
2660
+ }
2661
+ deps;
2662
+ static async create(classOptions = {}) {
2663
+ return new ReferralQualificationService({
2664
+ referrals: await ReferralCollection.create(classOptions),
2665
+ agreements: await ReferralAgreementCollection.create(classOptions),
2666
+ plans: await CommissionPlanCollection.create(classOptions),
2667
+ policies: await AttributionPolicyCollection.create(classOptions),
2668
+ snapshots: await ReferralTermSnapshotCollection.create(classOptions),
2669
+ referrers: await ReferrerCollection.create(classOptions)
2670
+ });
2671
+ }
2672
+ /**
2673
+ * Qualify an attributed referral.
2674
+ *
2675
+ * Flow:
2676
+ * 1. **Status** — the referral must be `attributed`
2677
+ * (`reason: 'not_attributed'` otherwise). Already `qualified` →
2678
+ * IDEMPOTENT: the existing snapshot returns with `created: false`.
2679
+ * 2. **Eligibility re-check** — the attribution-time policy version's
2680
+ * flags are re-applied against the caller's current knowledge:
2681
+ * `isExistingClient` (`existing_client_ineligible`) and
2682
+ * `subjectProfileId` vs the referrer's profile
2683
+ * (`self_referral_ineligible`). Refusals do NOT change the referral's
2684
+ * status — disqualification stays an explicit caller decision.
2685
+ * 3. **Agreement** — the ACTIVE ReferralAgreement for
2686
+ * (referrerId, programId) effective at `now`
2687
+ * (`reason: 'no_active_agreement'` when none).
2688
+ * 4. **Plan** — the agreement's pinned `commissionPlanVersion`, or the
2689
+ * latest active version when the pin is `0`; the resolved version must
2690
+ * be ACTIVE (`reason: 'no_active_plan'` otherwise).
2691
+ * 5. **Snapshot** — mint the immutable ReferralTermSnapshot (frozen
2692
+ * component copy, currency, clearingDays, approvalMode, agreement +
2693
+ * plan + policy version refs), point `referral.snapshotId` at it, and
2694
+ * transition `attributed → qualified` (stamping `qualifiedAt`).
2695
+ */
2696
+ async qualify(input) {
2697
+ const now = input.now ?? /* @__PURE__ */ new Date();
2698
+ const referral = await this.requireReferral(input.referralId, "qualify");
2699
+ if (referral.status === "qualified") return {
2700
+ qualified: true,
2701
+ created: false,
2702
+ snapshot: referral.snapshotId ? await this.deps.snapshots.get({ id: referral.snapshotId }) : null,
2703
+ referral
2704
+ };
2705
+ if (referral.status !== "attributed") return {
2706
+ qualified: false,
2707
+ created: false,
2708
+ snapshot: null,
2709
+ referral,
2710
+ reason: "not_attributed"
2711
+ };
2712
+ const policy = await this.findPolicyVersion(referral.policyKey, referral.policyVersion, referral.tenantId);
2713
+ if (policy) {
2714
+ if (input.isExistingClient && !policy.allowExistingClients) return {
2715
+ qualified: false,
2716
+ created: false,
2717
+ snapshot: null,
2718
+ referral,
2719
+ reason: "existing_client_ineligible"
2720
+ };
2721
+ if (input.subjectProfileId && !policy.allowSelfReferral) {
2722
+ const referrer = await this.deps.referrers.get({ id: referral.referrerId });
2723
+ if (referrer && referrer.profileId === input.subjectProfileId) return {
2724
+ qualified: false,
2725
+ created: false,
2726
+ snapshot: null,
2727
+ referral,
2728
+ reason: "self_referral_ineligible"
2729
+ };
2730
+ }
2731
+ }
2732
+ const agreement = await this.deps.agreements.activeFor(referral.referrerId, referral.programId, now);
2733
+ if (!agreement) return {
2734
+ qualified: false,
2735
+ created: false,
2736
+ snapshot: null,
2737
+ referral,
2738
+ reason: "no_active_agreement"
2739
+ };
2740
+ const plan = await this.resolvePlan(agreement, now);
2741
+ if (!plan) return {
2742
+ qualified: false,
2743
+ created: false,
2744
+ snapshot: null,
2745
+ referral,
2746
+ reason: "no_active_plan"
2747
+ };
2748
+ const snapshot = await this.mintSnapshot(referral, agreement, plan);
2749
+ referral.snapshotId = snapshot.id ?? "";
2750
+ referral.markQualified(now);
2751
+ await referral.save();
2752
+ return {
2753
+ qualified: true,
2754
+ created: true,
2755
+ snapshot,
2756
+ referral
2757
+ };
2758
+ }
2759
+ /**
2760
+ * Explicitly re-snapshot a QUALIFIED referral under the CURRENT terms —
2761
+ * the "amendment explicitly applied" path: after a plan/agreement
2762
+ * amendment, earnings keep flowing through the OLD snapshot until an
2763
+ * operator invokes this.
2764
+ *
2765
+ * Creates a NEW snapshot from the currently active agreement/plan,
2766
+ * repoints `referral.snapshotId`, leaves the old snapshot row untouched
2767
+ * (history — commissions it produced still reference it), and appends
2768
+ * `{ at, reason, fromSnapshotId, toSnapshotId }` to the referral
2769
+ * metadata's `requalifications` array.
2770
+ *
2771
+ * Throws on misuse (unknown referral, non-qualified status, empty
2772
+ * reason); refuses with a typed reason when no active agreement/plan
2773
+ * currently governs.
2774
+ */
2775
+ async requalify(input) {
2776
+ if (!input.reason?.trim()) throw new Error("ReferralQualificationService.requalify requires a non-empty reason");
2777
+ const now = input.now ?? /* @__PURE__ */ new Date();
2778
+ const referral = await this.requireReferral(input.referralId, "requalify");
2779
+ if (referral.status !== "qualified") throw new Error(`ReferralQualificationService.requalify: referral '${input.referralId}' is '${referral.status}' \u2014 only qualified referrals can be re-snapshotted`);
2780
+ const agreement = await this.deps.agreements.activeFor(referral.referrerId, referral.programId, now);
2781
+ if (!agreement) return {
2782
+ requalified: false,
2783
+ snapshot: null,
2784
+ previousSnapshotId: referral.snapshotId,
2785
+ reason: "no_active_agreement"
2786
+ };
2787
+ const plan = await this.resolvePlan(agreement, now);
2788
+ if (!plan) return {
2789
+ requalified: false,
2790
+ snapshot: null,
2791
+ previousSnapshotId: referral.snapshotId,
2792
+ reason: "no_active_plan"
2793
+ };
2794
+ const previousSnapshotId = referral.snapshotId;
2795
+ const snapshot = await this.mintSnapshot(referral, agreement, plan);
2796
+ const meta = referral.getMetadata();
2797
+ const history = Array.isArray(meta.requalifications) ? meta.requalifications : [];
2798
+ history.push({
2799
+ at: now.toISOString(),
2800
+ reason: input.reason,
2801
+ fromSnapshotId: previousSnapshotId,
2802
+ toSnapshotId: snapshot.id ?? ""
2803
+ });
2804
+ referral.setMetadata({
2805
+ ...meta,
2806
+ requalifications: history
2807
+ });
2808
+ referral.snapshotId = snapshot.id ?? "";
2809
+ await referral.save();
2810
+ return {
2811
+ requalified: true,
2812
+ snapshot,
2813
+ previousSnapshotId
2814
+ };
2815
+ }
2816
+ async requireReferral(referralId, method) {
2817
+ const referral = await this.deps.referrals.get({ id: referralId });
2818
+ if (!referral) throw new Error(`ReferralQualificationService.${method}: referral '${referralId}' not found`);
2819
+ return referral;
2820
+ }
2821
+ /**
2822
+ * The exact policy version pinned on the referral, when it exists —
2823
+ * resolved in the REFERRAL's tenant lane (global fallback): background
2824
+ * qualification runs without ambient tenant context, and another
2825
+ * tenant's same-keyed policy must never supply the eligibility flags.
2826
+ */
2827
+ async findPolicyVersion(policyKey, version, tenantId = null) {
2828
+ if (!policyKey || version <= 0) return null;
2829
+ const results = await this.deps.policies.list({ where: {
2830
+ policyKey,
2831
+ version
2832
+ } });
2833
+ return results.find((policy) => policy.tenantId === tenantId) ?? results.find((policy) => policy.tenantId === null) ?? null;
2834
+ }
2835
+ /**
2836
+ * The CommissionPlan an agreement binds: the pinned version when
2837
+ * `commissionPlanVersion > 0`, else the latest active version. Either
2838
+ * way the resolved row must be ACTIVE — superseded/retired terms cannot
2839
+ * govern a NEW qualification (an amendment should be activated, or the
2840
+ * agreement re-pinned, first).
2841
+ */
2842
+ async resolvePlan(agreement, at = /* @__PURE__ */ new Date()) {
2843
+ if (!agreement.commissionPlanKey) return null;
2844
+ if (agreement.commissionPlanVersion > 0) {
2845
+ const results = await this.deps.plans.list({ where: {
2846
+ planKey: agreement.commissionPlanKey,
2847
+ version: agreement.commissionPlanVersion,
2848
+ status: "active"
2849
+ } });
2850
+ return results.find((plan) => plan.tenantId === agreement.tenantId) ?? results.find((plan) => plan.tenantId === null) ?? null;
2851
+ }
2852
+ return await this.deps.plans.latestActiveByKey(agreement.commissionPlanKey, at, agreement.tenantId);
2853
+ }
2854
+ /**
2855
+ * Mint the immutable snapshot: agreement/plan/policy refs plus the FROZEN
2856
+ * component copy and the calculation inputs. `planVersion` records the
2857
+ * RESOLVED version — this is where an unpinned (`0`) agreement version
2858
+ * gets pinned.
2859
+ */
2860
+ async mintSnapshot(referral, agreement, plan) {
2861
+ return await this.deps.snapshots.create({
2862
+ tenantId: referral.tenantId,
2863
+ referralId: referral.id ?? "",
2864
+ agreementId: agreement.id ?? "",
2865
+ agreementVersion: agreement.version,
2866
+ planKey: plan.planKey,
2867
+ planVersion: plan.version,
2868
+ policyKey: referral.policyKey,
2869
+ policyVersion: referral.policyVersion,
2870
+ components: plan.components,
2871
+ currency: plan.currency,
2872
+ clearingDays: agreement.clearingDays,
2873
+ approvalMode: agreement.approvalMode
2874
+ });
2875
+ }
2876
+ };
2877
+ //#endregion
2878
+ export { ATTRIBUTION_EXCEPTION_STATUSES as A, AttributionExceptionCollection as B, ReferralAgreementCollection as C, validateAttributionPolicyTerms as D, AttributionPolicy as E, REFERRAL_LINK_STATUSES as F, REFERRAL_PROGRAM_STATUSES as I, REFERRAL_STATUSES as L, ATTRIBUTION_RESOLUTION_MODES as M, REFERRAL_AGREEMENT_APPROVAL_MODES as N, ATTRIBUTION_CONFLICT_BEHAVIORS as O, REFERRAL_AGREEMENT_STATUSES as P, REFERRAL_TOUCH_KINDS as R, Referral as S, AttributionPolicyCollection as T, AttributionException as V, ReferralTouchCollection as _, QualifiedReferralOverrideError as a, assertHttpTargetUrl as b, ReferralTermSnapshotCollection as c, ReferralProgram as d, MAX_CODE_GENERATION_ATTEMPTS as f, generateReferralCode as g, ReferralLinkCollection as h, AttributionService as i, ATTRIBUTION_POLICY_STATUSES as j, ATTRIBUTION_CREDIT_MODES as k, ReferralTermSnapshot as l, REFERRAL_CODE_LENGTH as m, REFERRAL_TERMS_SNAPSHOT_KIND as n, ReferrerCollection as o, REFERRAL_CODE_ALPHABET as p, ReferralCommissionService as r, Referrer as s, ReferralQualificationService as t, ReferralProgramCollection as u, ReferralTouch as v, ReferralAgreement as w, ReferralCollection as x, ReferralLink as y, REFERRER_STATUSES as z };
2879
+
2880
+ //# sourceMappingURL=referrals-BY47p70T.js.map