@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.
- package/AGENTS.md +87 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/chunks/__smrt-register__-kWdT7LOt.js +6 -0
- package/dist/chunks/__smrt-register__-kWdT7LOt.js.map +1 -0
- package/dist/chunks/commissions-BCxqOKVM.js +2181 -0
- package/dist/chunks/commissions-BCxqOKVM.js.map +1 -0
- package/dist/chunks/crm-DwEz7E2r.js +1428 -0
- package/dist/chunks/crm-DwEz7E2r.js.map +1 -0
- package/dist/chunks/referrals-BY47p70T.js +2880 -0
- package/dist/chunks/referrals-BY47p70T.js.map +1 -0
- package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts +26 -0
- package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts.map +1 -0
- package/dist/commissions/collections/CommissionCollection.d.ts +43 -0
- package/dist/commissions/collections/CommissionCollection.d.ts.map +1 -0
- package/dist/commissions/collections/CommissionPayoutCollection.d.ts +19 -0
- package/dist/commissions/collections/CommissionPayoutCollection.d.ts.map +1 -0
- package/dist/commissions/collections/CommissionPlanCollection.d.ts +46 -0
- package/dist/commissions/collections/CommissionPlanCollection.d.ts.map +1 -0
- package/dist/commissions/collections/EarnerCollection.d.ts +14 -0
- package/dist/commissions/collections/EarnerCollection.d.ts.map +1 -0
- package/dist/commissions/collections/EarningEventCollection.d.ts +27 -0
- package/dist/commissions/collections/EarningEventCollection.d.ts.map +1 -0
- package/dist/commissions/collections/index.d.ts +11 -0
- package/dist/commissions/collections/index.d.ts.map +1 -0
- package/dist/commissions/index.d.ts +20 -0
- package/dist/commissions/index.d.ts.map +1 -0
- package/dist/commissions/models/Commission.d.ts +150 -0
- package/dist/commissions/models/Commission.d.ts.map +1 -0
- package/dist/commissions/models/CommissionAdjustment.d.ts +62 -0
- package/dist/commissions/models/CommissionAdjustment.d.ts.map +1 -0
- package/dist/commissions/models/CommissionPayout.d.ts +119 -0
- package/dist/commissions/models/CommissionPayout.d.ts.map +1 -0
- package/dist/commissions/models/CommissionPlan.d.ts +117 -0
- package/dist/commissions/models/CommissionPlan.d.ts.map +1 -0
- package/dist/commissions/models/Earner.d.ts +49 -0
- package/dist/commissions/models/Earner.d.ts.map +1 -0
- package/dist/commissions/models/EarningEvent.d.ts +89 -0
- package/dist/commissions/models/EarningEvent.d.ts.map +1 -0
- package/dist/commissions/models/index.d.ts +11 -0
- package/dist/commissions/models/index.d.ts.map +1 -0
- package/dist/commissions/money.d.ts +42 -0
- package/dist/commissions/money.d.ts.map +1 -0
- package/dist/commissions/services/CommissionBalanceService.d.ts +14 -0
- package/dist/commissions/services/CommissionBalanceService.d.ts.map +1 -0
- package/dist/commissions/services/CommissionCalculationService.d.ts +142 -0
- package/dist/commissions/services/CommissionCalculationService.d.ts.map +1 -0
- package/dist/commissions/services/CommissionPayoutService.d.ts +134 -0
- package/dist/commissions/services/CommissionPayoutService.d.ts.map +1 -0
- package/dist/commissions/services/CommissionSettlementService.d.ts +42 -0
- package/dist/commissions/services/CommissionSettlementService.d.ts.map +1 -0
- package/dist/commissions/services/index.d.ts +9 -0
- package/dist/commissions/services/index.d.ts.map +1 -0
- package/dist/commissions/types.d.ts +243 -0
- package/dist/commissions/types.d.ts.map +1 -0
- package/dist/commissions.d.ts +2 -0
- package/dist/commissions.d.ts.map +1 -0
- package/dist/commissions.js +3 -0
- package/dist/crm/collections/LeadCollection.d.ts +79 -0
- package/dist/crm/collections/LeadCollection.d.ts.map +1 -0
- package/dist/crm/collections/OpportunityCollection.d.ts +42 -0
- package/dist/crm/collections/OpportunityCollection.d.ts.map +1 -0
- package/dist/crm/collections/OpportunityConversionCollection.d.ts +26 -0
- package/dist/crm/collections/OpportunityConversionCollection.d.ts.map +1 -0
- package/dist/crm/collections/PipelineDefinitionCollection.d.ts +46 -0
- package/dist/crm/collections/PipelineDefinitionCollection.d.ts.map +1 -0
- package/dist/crm/collections/PipelineStageCollection.d.ts +22 -0
- package/dist/crm/collections/PipelineStageCollection.d.ts.map +1 -0
- package/dist/crm/collections/SalesActivityCollection.d.ts +24 -0
- package/dist/crm/collections/SalesActivityCollection.d.ts.map +1 -0
- package/dist/crm/collections/SalesRepresentativeCollection.d.ts +19 -0
- package/dist/crm/collections/SalesRepresentativeCollection.d.ts.map +1 -0
- package/dist/crm/index.d.ts +27 -0
- package/dist/crm/index.d.ts.map +1 -0
- package/dist/crm/models/Lead.d.ts +135 -0
- package/dist/crm/models/Lead.d.ts.map +1 -0
- package/dist/crm/models/Opportunity.d.ts +111 -0
- package/dist/crm/models/Opportunity.d.ts.map +1 -0
- package/dist/crm/models/OpportunityConversion.d.ts +66 -0
- package/dist/crm/models/OpportunityConversion.d.ts.map +1 -0
- package/dist/crm/models/PipelineDefinition.d.ts +58 -0
- package/dist/crm/models/PipelineDefinition.d.ts.map +1 -0
- package/dist/crm/models/PipelineStage.d.ts +66 -0
- package/dist/crm/models/PipelineStage.d.ts.map +1 -0
- package/dist/crm/models/SalesActivity.d.ts +76 -0
- package/dist/crm/models/SalesActivity.d.ts.map +1 -0
- package/dist/crm/models/SalesRepresentative.d.ts +60 -0
- package/dist/crm/models/SalesRepresentative.d.ts.map +1 -0
- package/dist/crm/types.d.ts +228 -0
- package/dist/crm/types.d.ts.map +1 -0
- package/dist/crm.d.ts +2 -0
- package/dist/crm.d.ts.map +1 -0
- package/dist/crm.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/manifest.json +9340 -0
- package/dist/referrals/collections/AttributionExceptionCollection.d.ts +17 -0
- package/dist/referrals/collections/AttributionExceptionCollection.d.ts.map +1 -0
- package/dist/referrals/collections/AttributionPolicyCollection.d.ts +50 -0
- package/dist/referrals/collections/AttributionPolicyCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralAgreementCollection.d.ts +48 -0
- package/dist/referrals/collections/ReferralAgreementCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralCollection.d.ts +25 -0
- package/dist/referrals/collections/ReferralCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralLinkCollection.d.ts +96 -0
- package/dist/referrals/collections/ReferralLinkCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralProgramCollection.d.ts +18 -0
- package/dist/referrals/collections/ReferralProgramCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts +13 -0
- package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralTouchCollection.d.ts +30 -0
- package/dist/referrals/collections/ReferralTouchCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferrerCollection.d.ts +17 -0
- package/dist/referrals/collections/ReferrerCollection.d.ts.map +1 -0
- package/dist/referrals/index.d.ts +38 -0
- package/dist/referrals/index.d.ts.map +1 -0
- package/dist/referrals/models/AttributionException.d.ts +85 -0
- package/dist/referrals/models/AttributionException.d.ts.map +1 -0
- package/dist/referrals/models/AttributionPolicy.d.ts +128 -0
- package/dist/referrals/models/AttributionPolicy.d.ts.map +1 -0
- package/dist/referrals/models/Referral.d.ts +112 -0
- package/dist/referrals/models/Referral.d.ts.map +1 -0
- package/dist/referrals/models/ReferralAgreement.d.ts +125 -0
- package/dist/referrals/models/ReferralAgreement.d.ts.map +1 -0
- package/dist/referrals/models/ReferralLink.d.ts +71 -0
- package/dist/referrals/models/ReferralLink.d.ts.map +1 -0
- package/dist/referrals/models/ReferralProgram.d.ts +57 -0
- package/dist/referrals/models/ReferralProgram.d.ts.map +1 -0
- package/dist/referrals/models/ReferralTermSnapshot.d.ts +61 -0
- package/dist/referrals/models/ReferralTermSnapshot.d.ts.map +1 -0
- package/dist/referrals/models/ReferralTouch.d.ts +55 -0
- package/dist/referrals/models/ReferralTouch.d.ts.map +1 -0
- package/dist/referrals/models/Referrer.d.ts +69 -0
- package/dist/referrals/models/Referrer.d.ts.map +1 -0
- package/dist/referrals/services/AttributionService.d.ts +248 -0
- package/dist/referrals/services/AttributionService.d.ts.map +1 -0
- package/dist/referrals/services/ReferralCommissionService.d.ts +91 -0
- package/dist/referrals/services/ReferralCommissionService.d.ts.map +1 -0
- package/dist/referrals/services/ReferralQualificationService.d.ts +135 -0
- package/dist/referrals/services/ReferralQualificationService.d.ts.map +1 -0
- package/dist/referrals/types.d.ts +226 -0
- package/dist/referrals/types.d.ts.map +1 -0
- package/dist/referrals.d.ts +2 -0
- package/dist/referrals.d.ts.map +1 -0
- package/dist/referrals.js +3 -0
- package/dist/smrt-knowledge.json +5501 -0
- package/dist/svelte/__tests__/format.test.js +68 -0
- package/dist/svelte/__tests__/types.test.js +349 -0
- package/dist/svelte/components/AttributionConflictQueue.svelte +361 -0
- package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts +15 -0
- package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CommissionBreakdown.svelte +251 -0
- package/dist/svelte/components/CommissionBreakdown.svelte.d.ts +11 -0
- package/dist/svelte/components/CommissionBreakdown.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CommissionExpenseSummary.svelte +186 -0
- package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts +13 -0
- package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ExecutedAgreementsList.svelte +136 -0
- package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts +11 -0
- package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/LeadList.svelte +224 -0
- package/dist/svelte/components/LeadList.svelte.d.ts +19 -0
- package/dist/svelte/components/LeadList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/OpportunityBoard.svelte +230 -0
- package/dist/svelte/components/OpportunityBoard.svelte.d.ts +17 -0
- package/dist/svelte/components/OpportunityBoard.svelte.d.ts.map +1 -0
- package/dist/svelte/components/OpportunityDetail.svelte +472 -0
- package/dist/svelte/components/OpportunityDetail.svelte.d.ts +27 -0
- package/dist/svelte/components/OpportunityDetail.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PayoutBatchReview.svelte +285 -0
- package/dist/svelte/components/PayoutBatchReview.svelte.d.ts +21 -0
- package/dist/svelte/components/PayoutBatchReview.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PayoutHistoryList.svelte +245 -0
- package/dist/svelte/components/PayoutHistoryList.svelte.d.ts +11 -0
- package/dist/svelte/components/PayoutHistoryList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReferralLinkManager.svelte +216 -0
- package/dist/svelte/components/ReferralLinkManager.svelte.d.ts +17 -0
- package/dist/svelte/components/ReferralLinkManager.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReferralStatusList.svelte +118 -0
- package/dist/svelte/components/ReferralStatusList.svelte.d.ts +11 -0
- package/dist/svelte/components/ReferralStatusList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReferrerEarningsSummary.svelte +106 -0
- package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts +11 -0
- package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts.map +1 -0
- package/dist/svelte/components/SalesDashboard.svelte +192 -0
- package/dist/svelte/components/SalesDashboard.svelte.d.ts +29 -0
- package/dist/svelte/components/SalesDashboard.svelte.d.ts.map +1 -0
- package/dist/svelte/format.d.ts +46 -0
- package/dist/svelte/format.d.ts.map +1 -0
- package/dist/svelte/format.js +75 -0
- package/dist/svelte/index.d.ts +50 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +37 -0
- package/dist/svelte/types.d.ts +422 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +376 -0
- 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
|