@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,2181 @@
|
|
|
1
|
+
import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, smrt } from "@happyvertical/smrt-core";
|
|
2
|
+
import { TenantScoped, tenantId } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
//#region src/commissions/models/CommissionAdjustment.ts
|
|
4
|
+
var __defProp$5 = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc$5 = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __decorateClass$5 = (decorators, target, key, kind) => {
|
|
7
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$5(target, key) : target;
|
|
8
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
9
|
+
if (kind && result) __defProp$5(target, key, result);
|
|
10
|
+
return result;
|
|
11
|
+
};
|
|
12
|
+
var frozenAdjustmentSnapshot = /* @__PURE__ */ new WeakMap();
|
|
13
|
+
var CommissionAdjustment = class extends SmrtObject {
|
|
14
|
+
tenantId = null;
|
|
15
|
+
commissionId = "";
|
|
16
|
+
earnerId = "";
|
|
17
|
+
/** What kind of correction this is. */
|
|
18
|
+
adjustmentKind = "correction";
|
|
19
|
+
/**
|
|
20
|
+
* SIGNED amount in integer cents. Negative claws earnings back (refund,
|
|
21
|
+
* chargeback); positive credits extra.
|
|
22
|
+
*/
|
|
23
|
+
amountCents = 0;
|
|
24
|
+
/** ISO 4217 currency — must match the parent commission's. */
|
|
25
|
+
currency = "USD";
|
|
26
|
+
reason = "";
|
|
27
|
+
createdByProfileId = "";
|
|
28
|
+
payoutId = "";
|
|
29
|
+
/** Additional metadata as a JSON string. Frozen once persisted. */
|
|
30
|
+
metadata = "{}";
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
super(options);
|
|
33
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
34
|
+
if (options.commissionId !== void 0) this.commissionId = options.commissionId;
|
|
35
|
+
if (options.earnerId !== void 0) this.earnerId = options.earnerId;
|
|
36
|
+
if (options.adjustmentKind !== void 0) this.adjustmentKind = options.adjustmentKind;
|
|
37
|
+
if (options.amountCents !== void 0) this.amountCents = options.amountCents;
|
|
38
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
39
|
+
if (options.reason !== void 0) this.reason = options.reason;
|
|
40
|
+
if (options.createdByProfileId !== void 0) this.createdByProfileId = options.createdByProfileId;
|
|
41
|
+
if (options.payoutId !== void 0) this.payoutId = options.payoutId;
|
|
42
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Capture the frozen-fields snapshot when the row was loaded from the
|
|
46
|
+
* database — from that moment on, only {@link payoutId} may change.
|
|
47
|
+
*/
|
|
48
|
+
async initialize() {
|
|
49
|
+
await super.initialize();
|
|
50
|
+
if (await this.isSaved()) frozenAdjustmentSnapshot.set(this, this.serializeFrozenSnapshot());
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
/** `true` once a payout batch has stamped {@link payoutId}. */
|
|
54
|
+
isSettled() {
|
|
55
|
+
return !!this.payoutId;
|
|
56
|
+
}
|
|
57
|
+
/** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
|
|
58
|
+
getMetadata() {
|
|
59
|
+
if (!this.metadata) return {};
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(this.metadata);
|
|
62
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Serialize and store {@link metadata}. */
|
|
68
|
+
setMetadata(data) {
|
|
69
|
+
this.metadata = JSON.stringify(data ?? {});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Save with the append-only guard: once the row has been persisted, every
|
|
73
|
+
* field except `payoutId` must match the captured snapshot. Corrections
|
|
74
|
+
* to a wrong adjustment are new counter-adjustments, never edits.
|
|
75
|
+
*/
|
|
76
|
+
async save() {
|
|
77
|
+
this.assertImmutableOncePersisted();
|
|
78
|
+
const result = await super.save();
|
|
79
|
+
if (!frozenAdjustmentSnapshot.has(this)) frozenAdjustmentSnapshot.set(this, this.serializeFrozenSnapshot());
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
assertImmutableOncePersisted() {
|
|
83
|
+
const captured = frozenAdjustmentSnapshot.get(this);
|
|
84
|
+
if (!captured) return;
|
|
85
|
+
if (captured !== this.serializeFrozenSnapshot()) throw new Error(`CommissionAdjustment ${this.id ?? "<new>"}: adjustments are append-only \u2014 only payoutId may change after creation. Append a counter-adjustment instead of editing this one.`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Serialize every field EXCEPT `payoutId` (the sole post-create mutable
|
|
89
|
+
* field) with stable key ordering.
|
|
90
|
+
*/
|
|
91
|
+
serializeFrozenSnapshot() {
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
tenantId: this.tenantId,
|
|
94
|
+
commissionId: this.commissionId,
|
|
95
|
+
earnerId: this.earnerId,
|
|
96
|
+
adjustmentKind: this.adjustmentKind,
|
|
97
|
+
amountCents: this.amountCents,
|
|
98
|
+
currency: this.currency,
|
|
99
|
+
reason: this.reason,
|
|
100
|
+
createdByProfileId: this.createdByProfileId,
|
|
101
|
+
metadata: this.metadata
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
__decorateClass$5([tenantId({ nullable: true })], CommissionAdjustment.prototype, "tenantId", 2);
|
|
106
|
+
__decorateClass$5([foreignKey("Commission", { required: true })], CommissionAdjustment.prototype, "commissionId", 2);
|
|
107
|
+
__decorateClass$5([foreignKey("Earner", { required: true })], CommissionAdjustment.prototype, "earnerId", 2);
|
|
108
|
+
__decorateClass$5([field({ required: true })], CommissionAdjustment.prototype, "reason", 2);
|
|
109
|
+
__decorateClass$5([crossPackageRef("@happyvertical/smrt-profiles:Profile")], CommissionAdjustment.prototype, "createdByProfileId", 2);
|
|
110
|
+
__decorateClass$5([foreignKey("CommissionPayout")], CommissionAdjustment.prototype, "payoutId", 2);
|
|
111
|
+
CommissionAdjustment = __decorateClass$5([TenantScoped({ mode: "optional" }), smrt({
|
|
112
|
+
api: { include: [
|
|
113
|
+
"create",
|
|
114
|
+
"list",
|
|
115
|
+
"get"
|
|
116
|
+
] },
|
|
117
|
+
mcp: { include: ["list", "create"] },
|
|
118
|
+
cli: false
|
|
119
|
+
})], CommissionAdjustment);
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/commissions/collections/CommissionAdjustmentCollection.ts
|
|
122
|
+
var CommissionAdjustmentCollection = class extends SmrtCollection {
|
|
123
|
+
static _itemClass = CommissionAdjustment;
|
|
124
|
+
/** All adjustments appended to one commission, oldest first. */
|
|
125
|
+
async findByCommission(commissionId) {
|
|
126
|
+
return await this.list({
|
|
127
|
+
where: { commissionId },
|
|
128
|
+
orderBy: "created_at ASC"
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** Unsettled adjustments for an earner+currency, oldest first. */
|
|
132
|
+
async findUnsettledByEarner(earnerId, currency) {
|
|
133
|
+
return (await this.list({
|
|
134
|
+
where: {
|
|
135
|
+
earnerId,
|
|
136
|
+
currency
|
|
137
|
+
},
|
|
138
|
+
orderBy: "created_at ASC"
|
|
139
|
+
})).filter((a) => !a.payoutId);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Σ signed amountCents of {@link findUnsettledByEarner} rows (integer
|
|
143
|
+
* cents; clawbacks make it negative).
|
|
144
|
+
*/
|
|
145
|
+
async sumUnsettledByEarner(earnerId, currency) {
|
|
146
|
+
return (await this.findUnsettledByEarner(earnerId, currency)).reduce((sum, a) => sum + a.amountCents, 0);
|
|
147
|
+
}
|
|
148
|
+
/** Adjustments settled by one payout batch. */
|
|
149
|
+
async findByPayout(payoutId) {
|
|
150
|
+
return await this.list({
|
|
151
|
+
where: { payoutId },
|
|
152
|
+
orderBy: "created_at ASC"
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Conditionally claim adjustment rows for a payout batch — the
|
|
157
|
+
* adjustment twin of `CommissionCollection.claimForPayout`. Rows already
|
|
158
|
+
* claimed by a DIFFERENT payout are skipped; rows already claimed by THIS
|
|
159
|
+
* payout pass through (idempotent retry / repair); every claim is
|
|
160
|
+
* verified by a post-save re-read. Returns the claimed rows.
|
|
161
|
+
*/
|
|
162
|
+
async claimForPayout(adjustmentIds, payoutId) {
|
|
163
|
+
const claimed = [];
|
|
164
|
+
for (const id of adjustmentIds) {
|
|
165
|
+
const row = await this.get({ id });
|
|
166
|
+
if (!row) continue;
|
|
167
|
+
if (row.payoutId && row.payoutId !== payoutId) continue;
|
|
168
|
+
if (!row.payoutId) {
|
|
169
|
+
row.payoutId = payoutId;
|
|
170
|
+
await row.save();
|
|
171
|
+
}
|
|
172
|
+
const verified = await this.get({ id });
|
|
173
|
+
if (verified && verified.payoutId === payoutId) claimed.push(verified);
|
|
174
|
+
}
|
|
175
|
+
return claimed;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/commissions/models/Commission.ts
|
|
180
|
+
var __defProp$4 = Object.defineProperty;
|
|
181
|
+
var __getOwnPropDesc$4 = Object.getOwnPropertyDescriptor;
|
|
182
|
+
var __decorateClass$4 = (decorators, target, key, kind) => {
|
|
183
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$4(target, key) : target;
|
|
184
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
185
|
+
if (kind && result) __defProp$4(target, key, result);
|
|
186
|
+
return result;
|
|
187
|
+
};
|
|
188
|
+
var COMMISSION_STATUS_TRANSITIONS = {
|
|
189
|
+
pending: ["earned"],
|
|
190
|
+
earned: ["approved"],
|
|
191
|
+
approved: ["payable"],
|
|
192
|
+
payable: ["paid"],
|
|
193
|
+
paid: []
|
|
194
|
+
};
|
|
195
|
+
var loadedCommissionStatus = /* @__PURE__ */ new WeakMap();
|
|
196
|
+
var Commission = class extends SmrtObject {
|
|
197
|
+
tenantId = null;
|
|
198
|
+
earnerId = "";
|
|
199
|
+
earningEventId = "";
|
|
200
|
+
/** Snapshot reference: plan key at calculation time. */
|
|
201
|
+
planKey = "";
|
|
202
|
+
/** Snapshot reference: plan version at calculation time. */
|
|
203
|
+
planVersion = 0;
|
|
204
|
+
/** Which plan component produced this commission. */
|
|
205
|
+
componentKey = "";
|
|
206
|
+
/**
|
|
207
|
+
* Generic polymorphic reference to the terms snapshot that governed the
|
|
208
|
+
* calculation (e.g. the referrals module sets
|
|
209
|
+
* `('referral_term_snapshot', <id>)`). Free-form; this module attaches no
|
|
210
|
+
* semantics beyond recording it in the dedupe key and trace.
|
|
211
|
+
*/
|
|
212
|
+
termsSnapshotKind = "";
|
|
213
|
+
/** Id of the terms snapshot named by {@link termsSnapshotKind}. */
|
|
214
|
+
termsSnapshotId = "";
|
|
215
|
+
/** How {@link baseAmountCents} was resolved from the event. */
|
|
216
|
+
basis = "gross";
|
|
217
|
+
/** Base amount the rate was applied to, in integer cents. */
|
|
218
|
+
baseAmountCents = 0;
|
|
219
|
+
/** Rate applied (0–1). Recorded as `0` for `fixed`-basis commissions. */
|
|
220
|
+
rate = 0;
|
|
221
|
+
/** Split share applied (0–1). `1.0` for unsplit commissions. */
|
|
222
|
+
shareFraction = 1;
|
|
223
|
+
/**
|
|
224
|
+
* Groups the sibling commissions of one split — every earner sharing an
|
|
225
|
+
* event/component carries the same `splitGroupId`. Empty for unsplit rows.
|
|
226
|
+
*/
|
|
227
|
+
splitGroupId = "";
|
|
228
|
+
/** The earned amount in integer cents. */
|
|
229
|
+
amountCents = 0;
|
|
230
|
+
/** ISO 4217 currency (copied from the earning event). */
|
|
231
|
+
currency = "USD";
|
|
232
|
+
/**
|
|
233
|
+
* Lifecycle status — strict chain `pending → earned → approved → payable
|
|
234
|
+
* → paid`. Mutate via the transition methods; the save-time guard rejects
|
|
235
|
+
* illegal edges.
|
|
236
|
+
*/
|
|
237
|
+
status = "pending";
|
|
238
|
+
/**
|
|
239
|
+
* End of the clearing window (refund/chargeback holdback). `null` means
|
|
240
|
+
* no clearing applies — the commission is immediately sweepable to
|
|
241
|
+
* `earned` (see `CommissionSettlementService.sweepClearing`).
|
|
242
|
+
*/
|
|
243
|
+
clearingEndsAt = null;
|
|
244
|
+
/** When the commission transitioned to `earned`. */
|
|
245
|
+
earnedAt = null;
|
|
246
|
+
/** When the commission transitioned to `approved`. */
|
|
247
|
+
approvedAt = null;
|
|
248
|
+
/** When the commission transitioned to `payable`. */
|
|
249
|
+
payableAt = null;
|
|
250
|
+
/** When the commission transitioned to `paid`. */
|
|
251
|
+
paidAt = null;
|
|
252
|
+
payoutId = "";
|
|
253
|
+
/** Copied from the earning event for reporting (generic source pair). */
|
|
254
|
+
sourceKind = "";
|
|
255
|
+
/** Copied from the earning event for reporting. */
|
|
256
|
+
sourceId = "";
|
|
257
|
+
/**
|
|
258
|
+
* JSON-string {@link CommissionCalculationTrace} — everything needed to
|
|
259
|
+
* reproduce {@link amountCents}. Use {@link getCalculationTrace} /
|
|
260
|
+
* {@link setCalculationTrace}.
|
|
261
|
+
*/
|
|
262
|
+
calculationTrace = "{}";
|
|
263
|
+
dedupeKey = "";
|
|
264
|
+
/** Additional metadata as a JSON string. */
|
|
265
|
+
metadata = "{}";
|
|
266
|
+
constructor(options = {}) {
|
|
267
|
+
super(options);
|
|
268
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
269
|
+
if (options.earnerId !== void 0) this.earnerId = options.earnerId;
|
|
270
|
+
if (options.earningEventId !== void 0) this.earningEventId = options.earningEventId;
|
|
271
|
+
if (options.planKey !== void 0) this.planKey = options.planKey;
|
|
272
|
+
if (options.planVersion !== void 0) this.planVersion = options.planVersion;
|
|
273
|
+
if (options.componentKey !== void 0) this.componentKey = options.componentKey;
|
|
274
|
+
if (options.termsSnapshotKind !== void 0) this.termsSnapshotKind = options.termsSnapshotKind;
|
|
275
|
+
if (options.termsSnapshotId !== void 0) this.termsSnapshotId = options.termsSnapshotId;
|
|
276
|
+
if (options.basis !== void 0) this.basis = options.basis;
|
|
277
|
+
if (options.baseAmountCents !== void 0) this.baseAmountCents = options.baseAmountCents;
|
|
278
|
+
if (options.rate !== void 0) this.rate = options.rate;
|
|
279
|
+
if (options.shareFraction !== void 0) this.shareFraction = options.shareFraction;
|
|
280
|
+
if (options.splitGroupId !== void 0) this.splitGroupId = options.splitGroupId;
|
|
281
|
+
if (options.amountCents !== void 0) this.amountCents = options.amountCents;
|
|
282
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
283
|
+
if (options.status !== void 0) this.status = options.status;
|
|
284
|
+
if (options.clearingEndsAt !== void 0) this.clearingEndsAt = Commission.coerceDate(options.clearingEndsAt);
|
|
285
|
+
if (options.earnedAt !== void 0) this.earnedAt = Commission.coerceDate(options.earnedAt);
|
|
286
|
+
if (options.approvedAt !== void 0) this.approvedAt = Commission.coerceDate(options.approvedAt);
|
|
287
|
+
if (options.payableAt !== void 0) this.payableAt = Commission.coerceDate(options.payableAt);
|
|
288
|
+
if (options.paidAt !== void 0) this.paidAt = Commission.coerceDate(options.paidAt);
|
|
289
|
+
if (options.payoutId !== void 0) this.payoutId = options.payoutId;
|
|
290
|
+
if (options.sourceKind !== void 0) this.sourceKind = options.sourceKind;
|
|
291
|
+
if (options.sourceId !== void 0) this.sourceId = options.sourceId;
|
|
292
|
+
if (options.calculationTrace !== void 0) this.calculationTrace = options.calculationTrace;
|
|
293
|
+
if (options.dedupeKey !== void 0) this.dedupeKey = options.dedupeKey;
|
|
294
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Re-coerce timestamp fields after the framework reapplies raw option /
|
|
298
|
+
* hydrated row values, and record the loaded status for the save guard.
|
|
299
|
+
*/
|
|
300
|
+
async initialize() {
|
|
301
|
+
await super.initialize();
|
|
302
|
+
this.clearingEndsAt = Commission.coerceDate(this.clearingEndsAt);
|
|
303
|
+
this.earnedAt = Commission.coerceDate(this.earnedAt);
|
|
304
|
+
this.approvedAt = Commission.coerceDate(this.approvedAt);
|
|
305
|
+
this.payableAt = Commission.coerceDate(this.payableAt);
|
|
306
|
+
this.paidAt = Commission.coerceDate(this.paidAt);
|
|
307
|
+
if (await this.isSaved()) loadedCommissionStatus.set(this, this.status);
|
|
308
|
+
return this;
|
|
309
|
+
}
|
|
310
|
+
isPending() {
|
|
311
|
+
return this.status === "pending";
|
|
312
|
+
}
|
|
313
|
+
isEarned() {
|
|
314
|
+
return this.status === "earned";
|
|
315
|
+
}
|
|
316
|
+
isApproved() {
|
|
317
|
+
return this.status === "approved";
|
|
318
|
+
}
|
|
319
|
+
isPayable() {
|
|
320
|
+
return this.status === "payable";
|
|
321
|
+
}
|
|
322
|
+
isPaid() {
|
|
323
|
+
return this.status === "paid";
|
|
324
|
+
}
|
|
325
|
+
/** `true` once a payout batch has stamped {@link payoutId}. */
|
|
326
|
+
isSettled() {
|
|
327
|
+
return !!this.payoutId;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* `pending → earned` (clearing window passed). Stamps {@link earnedAt}.
|
|
331
|
+
* Does NOT save — the caller saves.
|
|
332
|
+
*/
|
|
333
|
+
markEarned(now = /* @__PURE__ */ new Date()) {
|
|
334
|
+
this.assertTransitionFrom("pending", "earned");
|
|
335
|
+
this.status = "earned";
|
|
336
|
+
this.earnedAt = now;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* `earned → approved` (operator/automation approved the earning).
|
|
340
|
+
* Stamps {@link approvedAt}. Does NOT save — the caller saves.
|
|
341
|
+
*/
|
|
342
|
+
approve(now = /* @__PURE__ */ new Date()) {
|
|
343
|
+
this.assertTransitionFrom("earned", "approved");
|
|
344
|
+
this.status = "approved";
|
|
345
|
+
this.approvedAt = now;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `approved → payable` (released for the next payout batch).
|
|
349
|
+
* Stamps {@link payableAt}. Does NOT save — the caller saves.
|
|
350
|
+
*/
|
|
351
|
+
markPayable(now = /* @__PURE__ */ new Date()) {
|
|
352
|
+
this.assertTransitionFrom("approved", "payable");
|
|
353
|
+
this.status = "payable";
|
|
354
|
+
this.payableAt = now;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* `payable → paid` (its payout batch completed). Stamps {@link paidAt}.
|
|
358
|
+
* Does NOT save — the caller saves.
|
|
359
|
+
*/
|
|
360
|
+
markPaid(now = /* @__PURE__ */ new Date()) {
|
|
361
|
+
this.assertTransitionFrom("payable", "paid");
|
|
362
|
+
this.status = "paid";
|
|
363
|
+
this.paidAt = now;
|
|
364
|
+
}
|
|
365
|
+
assertTransitionFrom(expected, next) {
|
|
366
|
+
if (this.status !== expected) throw new Error(`Commission ${this.id ?? "<new>"}: cannot transition to '${next}' from status '${this.status}' (chain is pending \u2192 earned \u2192 approved \u2192 payable \u2192 paid)`);
|
|
367
|
+
}
|
|
368
|
+
/** Parse {@link calculationTrace}; returns `null` on empty/invalid JSON. */
|
|
369
|
+
getCalculationTrace() {
|
|
370
|
+
if (!this.calculationTrace) return null;
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(this.calculationTrace);
|
|
373
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
374
|
+
const trace = parsed;
|
|
375
|
+
return typeof trace.componentKey === "string" && typeof trace.baseAmountCents === "number" ? trace : null;
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Serialize and store {@link calculationTrace}. */
|
|
381
|
+
setCalculationTrace(trace) {
|
|
382
|
+
this.calculationTrace = JSON.stringify(trace);
|
|
383
|
+
}
|
|
384
|
+
/** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
|
|
385
|
+
getMetadata() {
|
|
386
|
+
if (!this.metadata) return {};
|
|
387
|
+
try {
|
|
388
|
+
const parsed = JSON.parse(this.metadata);
|
|
389
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
390
|
+
} catch {
|
|
391
|
+
return {};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Serialize and store {@link metadata}. */
|
|
395
|
+
setMetadata(data) {
|
|
396
|
+
this.metadata = JSON.stringify(data ?? {});
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Save-time state-machine guard (commerce pattern). Validates the status
|
|
400
|
+
* transition against the AUTHORITATIVE prior persisted status — re-read
|
|
401
|
+
* from the database so a `create({ id: <existing>, _skipLoad: true })`
|
|
402
|
+
* upsert is correctly treated as an update rather than a guard-free new
|
|
403
|
+
* row. Brand-new rows may start in any status (fixtures/imports); a
|
|
404
|
+
* persisted row may only advance one legal step.
|
|
405
|
+
*/
|
|
406
|
+
async save() {
|
|
407
|
+
const prior = await this.resolvePriorStatus();
|
|
408
|
+
this.assertStatusTransition(prior);
|
|
409
|
+
await this.assertDedupeKeyNotTaken();
|
|
410
|
+
const result = await super.save();
|
|
411
|
+
loadedCommissionStatus.set(this, this.status);
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Refuse a save whose `dedupeKey` already belongs to a DIFFERENT row —
|
|
416
|
+
* commissions are audit rows, and the natural-key upsert would let a
|
|
417
|
+
* fresh instance (generated `create`, or the loser of a calculation
|
|
418
|
+
* race) overwrite the persisted amount/status and rotate the row id.
|
|
419
|
+
* `CommissionCalculationService` treats this refusal as "someone else
|
|
420
|
+
* already earned it" and returns the existing row.
|
|
421
|
+
*/
|
|
422
|
+
async assertDedupeKeyNotTaken() {
|
|
423
|
+
if (!this.dedupeKey) return;
|
|
424
|
+
try {
|
|
425
|
+
const res = await this.db.query(`SELECT id FROM ${this.tableName} WHERE dedupe_key = $1`, this.dedupeKey);
|
|
426
|
+
if ((Array.isArray(res) ? res : res.rows ?? []).find((row) => row.id !== this.id)) throw new Error(`Commission (dedupeKey '${this.dedupeKey}'): a commission with this dedupe key already exists \u2014 commissions are immutable audit rows; corrections append CommissionAdjustments.`);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (error instanceof Error && error.message.includes("immutable")) throw error;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async resolvePriorStatus() {
|
|
432
|
+
if (this.id) try {
|
|
433
|
+
const row = await this.db.get(this.tableName, { id: this.id });
|
|
434
|
+
if (row && row.status != null) return row.status;
|
|
435
|
+
} catch {}
|
|
436
|
+
return loadedCommissionStatus.get(this);
|
|
437
|
+
}
|
|
438
|
+
assertStatusTransition(prior) {
|
|
439
|
+
if (prior === void 0) return;
|
|
440
|
+
if (prior === this.status) return;
|
|
441
|
+
if (!(COMMISSION_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`Commission ${this.id}: illegal status transition '${prior}' \u2192 '${this.status}'. Use markEarned() / approve() / markPayable() / markPaid().`);
|
|
442
|
+
}
|
|
443
|
+
static coerceDate(value) {
|
|
444
|
+
if (value == null) return null;
|
|
445
|
+
if (value instanceof Date) return value;
|
|
446
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
447
|
+
const d = new Date(value);
|
|
448
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
__decorateClass$4([tenantId({ nullable: true })], Commission.prototype, "tenantId", 2);
|
|
454
|
+
__decorateClass$4([foreignKey("Earner", { required: true })], Commission.prototype, "earnerId", 2);
|
|
455
|
+
__decorateClass$4([foreignKey("EarningEvent")], Commission.prototype, "earningEventId", 2);
|
|
456
|
+
__decorateClass$4([foreignKey("CommissionPayout")], Commission.prototype, "payoutId", 2);
|
|
457
|
+
__decorateClass$4([field({ required: true })], Commission.prototype, "dedupeKey", 2);
|
|
458
|
+
Commission = __decorateClass$4([TenantScoped({ mode: "optional" }), smrt({
|
|
459
|
+
conflictColumns: ["dedupe_key"],
|
|
460
|
+
api: { include: [
|
|
461
|
+
"list",
|
|
462
|
+
"get",
|
|
463
|
+
"create"
|
|
464
|
+
] },
|
|
465
|
+
mcp: { include: ["list", "get"] },
|
|
466
|
+
cli: false
|
|
467
|
+
})], Commission);
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/commissions/collections/CommissionCollection.ts
|
|
470
|
+
var CommissionCollection = class extends SmrtCollection {
|
|
471
|
+
static _itemClass = Commission;
|
|
472
|
+
/** All commissions for an earner, newest first. */
|
|
473
|
+
async findByEarner(earnerId) {
|
|
474
|
+
return await this.list({
|
|
475
|
+
where: { earnerId },
|
|
476
|
+
orderBy: "created_at DESC"
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/** All commissions derived from one earning event. */
|
|
480
|
+
async findByEvent(earningEventId) {
|
|
481
|
+
return await this.list({
|
|
482
|
+
where: { earningEventId },
|
|
483
|
+
orderBy: "created_at DESC"
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/** Commissions by lifecycle status, newest first. */
|
|
487
|
+
async findByStatus(status) {
|
|
488
|
+
return await this.list({
|
|
489
|
+
where: { status },
|
|
490
|
+
orderBy: "created_at DESC"
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/** Look up a commission by its idempotency natural key. */
|
|
494
|
+
async findByDedupeKey(dedupeKey) {
|
|
495
|
+
if (!dedupeKey) return null;
|
|
496
|
+
return (await this.list({
|
|
497
|
+
where: { dedupeKey },
|
|
498
|
+
limit: 1
|
|
499
|
+
}))[0] ?? null;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Payable commissions for an earner+currency that no payout batch has
|
|
503
|
+
* settled yet — the rows `CommissionPayoutService.createPayoutBatch`
|
|
504
|
+
* gathers.
|
|
505
|
+
*/
|
|
506
|
+
async findPayableUnsettled(earnerId, currency) {
|
|
507
|
+
return (await this.list({
|
|
508
|
+
where: {
|
|
509
|
+
earnerId,
|
|
510
|
+
currency,
|
|
511
|
+
status: "payable"
|
|
512
|
+
},
|
|
513
|
+
orderBy: "created_at ASC"
|
|
514
|
+
})).filter((c) => !c.payoutId);
|
|
515
|
+
}
|
|
516
|
+
/** Σ amountCents of {@link findPayableUnsettled} rows (integer cents). */
|
|
517
|
+
async sumPayableByEarner(earnerId, currency) {
|
|
518
|
+
return (await this.findPayableUnsettled(earnerId, currency)).reduce((sum, c) => sum + c.amountCents, 0);
|
|
519
|
+
}
|
|
520
|
+
/** Commissions settled by one payout batch. */
|
|
521
|
+
async findByPayout(payoutId) {
|
|
522
|
+
return await this.list({
|
|
523
|
+
where: { payoutId },
|
|
524
|
+
orderBy: "created_at ASC"
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Conditionally claim rows for a payout batch: each row is re-loaded
|
|
529
|
+
* fresh and stamped with `payoutId` only when it is still payable and
|
|
530
|
+
* unclaimed (or already claimed by THIS payout — the idempotent-retry /
|
|
531
|
+
* repair case). Rows claimed by a DIFFERENT payout are skipped, and every
|
|
532
|
+
* claim is verified by a post-save re-read so a lost race never counts
|
|
533
|
+
* toward the caller's totals.
|
|
534
|
+
*
|
|
535
|
+
* This is the single place claim semantics live. It narrows the
|
|
536
|
+
* concurrent-batch window to the re-read granularity; true compare-and-set
|
|
537
|
+
* needs DB transactions the collection layer doesn't expose (settlement
|
|
538
|
+
* runs are expected to be single-writer per earner — see
|
|
539
|
+
* `CommissionPayoutService`).
|
|
540
|
+
*
|
|
541
|
+
* Returns the claimed rows (freshly loaded, `payoutId` verified).
|
|
542
|
+
*/
|
|
543
|
+
async claimForPayout(commissionIds, payoutId) {
|
|
544
|
+
const claimed = [];
|
|
545
|
+
for (const id of commissionIds) {
|
|
546
|
+
const row = await this.get({ id });
|
|
547
|
+
if (!row) continue;
|
|
548
|
+
if (row.payoutId && row.payoutId !== payoutId) continue;
|
|
549
|
+
if (!row.payoutId) {
|
|
550
|
+
if (!row.isPayable()) continue;
|
|
551
|
+
row.payoutId = payoutId;
|
|
552
|
+
await row.save();
|
|
553
|
+
}
|
|
554
|
+
const verified = await this.get({ id });
|
|
555
|
+
if (verified && verified.payoutId === payoutId) claimed.push(verified);
|
|
556
|
+
}
|
|
557
|
+
return claimed;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/commissions/models/CommissionPayout.ts
|
|
562
|
+
var __defProp$3 = Object.defineProperty;
|
|
563
|
+
var __getOwnPropDesc$3 = Object.getOwnPropertyDescriptor;
|
|
564
|
+
var __decorateClass$3 = (decorators, target, key, kind) => {
|
|
565
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$3(target, key) : target;
|
|
566
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
567
|
+
if (kind && result) __defProp$3(target, key, result);
|
|
568
|
+
return result;
|
|
569
|
+
};
|
|
570
|
+
var PAYOUT_STATUS_TRANSITIONS = {
|
|
571
|
+
pending: ["approved"],
|
|
572
|
+
approved: ["processing", "failed"],
|
|
573
|
+
processing: ["completed", "failed"],
|
|
574
|
+
completed: [],
|
|
575
|
+
failed: ["pending"]
|
|
576
|
+
};
|
|
577
|
+
var loadedPayoutStatus = /* @__PURE__ */ new WeakMap();
|
|
578
|
+
var CommissionPayout = class extends SmrtObject {
|
|
579
|
+
tenantId = null;
|
|
580
|
+
earnerId = "";
|
|
581
|
+
/** Start of the settlement period this batch covers (informational). */
|
|
582
|
+
periodStart = null;
|
|
583
|
+
/** End of the settlement period this batch covers (informational). */
|
|
584
|
+
periodEnd = null;
|
|
585
|
+
/** Σ amountCents of the Commissions this batch settled (integer cents). */
|
|
586
|
+
commissionTotalCents = 0;
|
|
587
|
+
/**
|
|
588
|
+
* Σ signed amountCents of the Adjustments this batch settled (integer
|
|
589
|
+
* cents; clawbacks make it negative).
|
|
590
|
+
*/
|
|
591
|
+
adjustmentTotalCents = 0;
|
|
592
|
+
/**
|
|
593
|
+
* Net amount remitted — must equal
|
|
594
|
+
* `commissionTotalCents + adjustmentTotalCents` (enforced on save).
|
|
595
|
+
*/
|
|
596
|
+
totalAmountCents = 0;
|
|
597
|
+
/** ISO 4217 currency of the batch. */
|
|
598
|
+
currency = "USD";
|
|
599
|
+
/** Delivery method for this batch (defaulted from the Earner). */
|
|
600
|
+
payoutMethod = "bank_transfer";
|
|
601
|
+
/**
|
|
602
|
+
* Lifecycle status — see the class doc. Mutate via {@link approve} /
|
|
603
|
+
* {@link markProcessing} / {@link complete} / {@link fail} /
|
|
604
|
+
* {@link resetFromFailed}.
|
|
605
|
+
*/
|
|
606
|
+
status = "pending";
|
|
607
|
+
/**
|
|
608
|
+
* Payment reference recorded at completion (check number, transfer id,
|
|
609
|
+
* …). Cleared by {@link resetFromFailed}.
|
|
610
|
+
*/
|
|
611
|
+
paymentReference = "";
|
|
612
|
+
/**
|
|
613
|
+
* Opaque payout-provider reference (processor batch id, remittance file
|
|
614
|
+
* id, …). Retained across failure/reset for audit.
|
|
615
|
+
*/
|
|
616
|
+
providerRef = "";
|
|
617
|
+
/** When the payout completed. */
|
|
618
|
+
paidAt = null;
|
|
619
|
+
invoiceId = "";
|
|
620
|
+
/** Operator notes — approval memos, failure reasons (append-only). */
|
|
621
|
+
notes = "";
|
|
622
|
+
idempotencyKey = "";
|
|
623
|
+
/** Additional metadata as a JSON string. */
|
|
624
|
+
metadata = "{}";
|
|
625
|
+
constructor(options = {}) {
|
|
626
|
+
super(options);
|
|
627
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
628
|
+
if (options.earnerId !== void 0) this.earnerId = options.earnerId;
|
|
629
|
+
if (options.periodStart !== void 0) this.periodStart = CommissionPayout.coerceDate(options.periodStart);
|
|
630
|
+
if (options.periodEnd !== void 0) this.periodEnd = CommissionPayout.coerceDate(options.periodEnd);
|
|
631
|
+
if (options.commissionTotalCents !== void 0) this.commissionTotalCents = options.commissionTotalCents;
|
|
632
|
+
if (options.adjustmentTotalCents !== void 0) this.adjustmentTotalCents = options.adjustmentTotalCents;
|
|
633
|
+
if (options.totalAmountCents !== void 0) this.totalAmountCents = options.totalAmountCents;
|
|
634
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
635
|
+
if (options.payoutMethod !== void 0) this.payoutMethod = options.payoutMethod;
|
|
636
|
+
if (options.status !== void 0) this.status = options.status;
|
|
637
|
+
if (options.paymentReference !== void 0) this.paymentReference = options.paymentReference;
|
|
638
|
+
if (options.providerRef !== void 0) this.providerRef = options.providerRef;
|
|
639
|
+
if (options.paidAt !== void 0) this.paidAt = CommissionPayout.coerceDate(options.paidAt);
|
|
640
|
+
if (options.invoiceId !== void 0) this.invoiceId = options.invoiceId;
|
|
641
|
+
if (options.notes !== void 0) this.notes = options.notes;
|
|
642
|
+
if (options.idempotencyKey !== void 0) this.idempotencyKey = options.idempotencyKey;
|
|
643
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Re-coerce timestamp fields after the framework reapplies raw option /
|
|
647
|
+
* hydrated row values, and record the loaded status for the save guard.
|
|
648
|
+
*/
|
|
649
|
+
async initialize() {
|
|
650
|
+
await super.initialize();
|
|
651
|
+
this.periodStart = CommissionPayout.coerceDate(this.periodStart);
|
|
652
|
+
this.periodEnd = CommissionPayout.coerceDate(this.periodEnd);
|
|
653
|
+
this.paidAt = CommissionPayout.coerceDate(this.paidAt);
|
|
654
|
+
if (await this.isSaved()) loadedPayoutStatus.set(this, this.status);
|
|
655
|
+
return this;
|
|
656
|
+
}
|
|
657
|
+
isPending() {
|
|
658
|
+
return this.status === "pending";
|
|
659
|
+
}
|
|
660
|
+
isApproved() {
|
|
661
|
+
return this.status === "approved";
|
|
662
|
+
}
|
|
663
|
+
isProcessing() {
|
|
664
|
+
return this.status === "processing";
|
|
665
|
+
}
|
|
666
|
+
isCompleted() {
|
|
667
|
+
return this.status === "completed";
|
|
668
|
+
}
|
|
669
|
+
isFailed() {
|
|
670
|
+
return this.status === "failed";
|
|
671
|
+
}
|
|
672
|
+
/** `pending → approved`. Does NOT save — the caller saves. */
|
|
673
|
+
approve() {
|
|
674
|
+
if (this.status !== "pending") throw new Error(`CommissionPayout ${this.id ?? "<new>"}: cannot approve from status '${this.status}'`);
|
|
675
|
+
if (this.totalAmountCents <= 0) throw new Error(`CommissionPayout ${this.id ?? "<new>"}: cannot approve a batch with non-positive total (${this.totalAmountCents} cents)`);
|
|
676
|
+
this.status = "approved";
|
|
677
|
+
}
|
|
678
|
+
/** `approved → processing`. Does NOT save — the caller saves. */
|
|
679
|
+
markProcessing() {
|
|
680
|
+
if (this.status !== "approved") throw new Error(`CommissionPayout ${this.id ?? "<new>"}: cannot mark processing from status '${this.status}'`);
|
|
681
|
+
this.status = "processing";
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* `processing → completed`. Requires a payment reference — a completed
|
|
685
|
+
* payout with no reference is untraceable. Stamps {@link paidAt}.
|
|
686
|
+
* Does NOT save — the caller saves.
|
|
687
|
+
*/
|
|
688
|
+
complete(paymentReference, now = /* @__PURE__ */ new Date()) {
|
|
689
|
+
if (this.status !== "processing") throw new Error(`CommissionPayout ${this.id ?? "<new>"}: cannot complete from status '${this.status}'`);
|
|
690
|
+
if (!paymentReference) throw new Error(`CommissionPayout ${this.id ?? "<new>"}: complete() requires a paymentReference`);
|
|
691
|
+
this.status = "completed";
|
|
692
|
+
this.paymentReference = paymentReference;
|
|
693
|
+
this.paidAt = now;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* `approved | processing → failed`. Appends the reason to {@link notes}.
|
|
697
|
+
* Does NOT save — the caller saves.
|
|
698
|
+
*/
|
|
699
|
+
fail(reason) {
|
|
700
|
+
if (this.status !== "approved" && this.status !== "processing") throw new Error(`CommissionPayout ${this.id ?? "<new>"}: cannot fail from status '${this.status}'`);
|
|
701
|
+
this.status = "failed";
|
|
702
|
+
const memo = `Failed: ${reason ?? ""}`;
|
|
703
|
+
this.notes = this.notes ? `${this.notes}
|
|
704
|
+
${memo}` : memo;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Operator-driven reset: `failed → pending` after fixing whatever broke.
|
|
708
|
+
* Clears {@link paymentReference} and {@link paidAt} (the next attempt
|
|
709
|
+
* gets fresh ones) but RETAINS {@link providerRef} and {@link notes} for
|
|
710
|
+
* audit. The only path out of `failed`. Does NOT save — the caller saves.
|
|
711
|
+
*/
|
|
712
|
+
resetFromFailed() {
|
|
713
|
+
if (this.status !== "failed") throw new Error(`CommissionPayout ${this.id ?? "<new>"}: cannot reset from status '${this.status}' \u2014 only failed payouts are resettable`);
|
|
714
|
+
this.status = "pending";
|
|
715
|
+
this.paymentReference = "";
|
|
716
|
+
this.paidAt = null;
|
|
717
|
+
}
|
|
718
|
+
/** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
|
|
719
|
+
getMetadata() {
|
|
720
|
+
if (!this.metadata) return {};
|
|
721
|
+
try {
|
|
722
|
+
const parsed = JSON.parse(this.metadata);
|
|
723
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
724
|
+
} catch {
|
|
725
|
+
return {};
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/** Serialize and store {@link metadata}. */
|
|
729
|
+
setMetadata(data) {
|
|
730
|
+
this.metadata = JSON.stringify(data ?? {});
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Save with two guards (commerce pattern):
|
|
734
|
+
*
|
|
735
|
+
* 1. **Totals invariant** — `totalAmountCents` must equal
|
|
736
|
+
* `commissionTotalCents + adjustmentTotalCents` (exact integer
|
|
737
|
+
* arithmetic, no epsilon).
|
|
738
|
+
* 2. **Status transition** — validated against the AUTHORITATIVE prior
|
|
739
|
+
* persisted status (re-read from the database so a
|
|
740
|
+
* `create({ id, _skipLoad: true })` upsert can't sidestep the guard).
|
|
741
|
+
* A `completed` payout additionally requires a payment reference,
|
|
742
|
+
* matching {@link complete}'s invariant, regardless of how the status
|
|
743
|
+
* was set.
|
|
744
|
+
*/
|
|
745
|
+
async save() {
|
|
746
|
+
this.validateTotals();
|
|
747
|
+
const prior = await this.resolvePriorStatus();
|
|
748
|
+
this.assertStatusTransition(prior);
|
|
749
|
+
if (this.status === "completed" && !this.paymentReference) throw new Error(`CommissionPayout ${this.id ?? "<new>"}: a completed payout requires a paymentReference (use complete()).`);
|
|
750
|
+
const result = await super.save();
|
|
751
|
+
loadedPayoutStatus.set(this, this.status);
|
|
752
|
+
return result;
|
|
753
|
+
}
|
|
754
|
+
/** Throws when the totals invariant doesn't hold. */
|
|
755
|
+
validateTotals() {
|
|
756
|
+
for (const [name, value] of [
|
|
757
|
+
["commissionTotalCents", this.commissionTotalCents],
|
|
758
|
+
["adjustmentTotalCents", this.adjustmentTotalCents],
|
|
759
|
+
["totalAmountCents", this.totalAmountCents]
|
|
760
|
+
]) if (!Number.isInteger(value)) throw new Error(`CommissionPayout ${this.id ?? "<new>"}: ${name} must be integer cents (got ${value}).`);
|
|
761
|
+
const expected = this.commissionTotalCents + this.adjustmentTotalCents;
|
|
762
|
+
if (this.totalAmountCents !== expected) throw new Error(`CommissionPayout ${this.id ?? "<new>"}: totals invariant violated \u2014 commission=${this.commissionTotalCents} adjustment=${this.adjustmentTotalCents} total=${this.totalAmountCents} (expected total=${expected}).`);
|
|
763
|
+
}
|
|
764
|
+
async resolvePriorStatus() {
|
|
765
|
+
if (this.id) try {
|
|
766
|
+
const row = await this.db.get(this.tableName, { id: this.id });
|
|
767
|
+
if (row && row.status != null) return row.status;
|
|
768
|
+
} catch {}
|
|
769
|
+
return loadedPayoutStatus.get(this);
|
|
770
|
+
}
|
|
771
|
+
assertStatusTransition(prior) {
|
|
772
|
+
if (prior === void 0) return;
|
|
773
|
+
if (prior === this.status) return;
|
|
774
|
+
if (!(PAYOUT_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`CommissionPayout ${this.id}: illegal status transition '${prior}' \u2192 '${this.status}'. Use approve() / markProcessing() / complete() / fail() / resetFromFailed().`);
|
|
775
|
+
}
|
|
776
|
+
static coerceDate(value) {
|
|
777
|
+
if (value == null) return null;
|
|
778
|
+
if (value instanceof Date) return value;
|
|
779
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
780
|
+
const d = new Date(value);
|
|
781
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
782
|
+
}
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
__decorateClass$3([tenantId({ nullable: true })], CommissionPayout.prototype, "tenantId", 2);
|
|
787
|
+
__decorateClass$3([foreignKey("Earner", { required: true })], CommissionPayout.prototype, "earnerId", 2);
|
|
788
|
+
__decorateClass$3([crossPackageRef("@happyvertical/smrt-commerce:Invoice")], CommissionPayout.prototype, "invoiceId", 2);
|
|
789
|
+
__decorateClass$3([field({ required: true })], CommissionPayout.prototype, "idempotencyKey", 2);
|
|
790
|
+
CommissionPayout = __decorateClass$3([TenantScoped({ mode: "optional" }), smrt({
|
|
791
|
+
conflictColumns: ["idempotency_key"],
|
|
792
|
+
api: { include: ["list", "get"] },
|
|
793
|
+
mcp: { include: ["list", "get"] },
|
|
794
|
+
cli: { include: ["list", "get"] }
|
|
795
|
+
})], CommissionPayout);
|
|
796
|
+
//#endregion
|
|
797
|
+
//#region src/commissions/collections/CommissionPayoutCollection.ts
|
|
798
|
+
var CommissionPayoutCollection = class extends SmrtCollection {
|
|
799
|
+
static _itemClass = CommissionPayout;
|
|
800
|
+
/** All payout batches for an earner, newest first. */
|
|
801
|
+
async findByEarner(earnerId) {
|
|
802
|
+
return await this.list({
|
|
803
|
+
where: { earnerId },
|
|
804
|
+
orderBy: "created_at DESC"
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
/** Payout batches by status, newest first. */
|
|
808
|
+
async findByStatus(status) {
|
|
809
|
+
return await this.list({
|
|
810
|
+
where: { status },
|
|
811
|
+
orderBy: "created_at DESC"
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
/** Look up a payout by its idempotency natural key. */
|
|
815
|
+
async findByIdempotencyKey(idempotencyKey) {
|
|
816
|
+
if (!idempotencyKey) return null;
|
|
817
|
+
return (await this.list({
|
|
818
|
+
where: { idempotencyKey },
|
|
819
|
+
limit: 1
|
|
820
|
+
}))[0] ?? null;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Σ totalAmountCents of COMPLETED payouts for an earner+currency —
|
|
824
|
+
* lifetime settled earnings (integer cents).
|
|
825
|
+
*/
|
|
826
|
+
async sumPaidByEarner(earnerId, currency) {
|
|
827
|
+
return (await this.list({ where: {
|
|
828
|
+
earnerId,
|
|
829
|
+
currency,
|
|
830
|
+
status: "completed"
|
|
831
|
+
} })).reduce((sum, p) => sum + p.totalAmountCents, 0);
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
//#endregion
|
|
835
|
+
//#region src/commissions/types.ts
|
|
836
|
+
var EARNER_STATUSES = [
|
|
837
|
+
"pending",
|
|
838
|
+
"active",
|
|
839
|
+
"suspended"
|
|
840
|
+
];
|
|
841
|
+
var PAYOUT_METHODS = [
|
|
842
|
+
"bank_transfer",
|
|
843
|
+
"check",
|
|
844
|
+
"paypal",
|
|
845
|
+
"credit",
|
|
846
|
+
"other"
|
|
847
|
+
];
|
|
848
|
+
var COMMISSION_PLAN_STATUSES = [
|
|
849
|
+
"draft",
|
|
850
|
+
"active",
|
|
851
|
+
"superseded",
|
|
852
|
+
"retired"
|
|
853
|
+
];
|
|
854
|
+
var COMMISSION_STATUSES = [
|
|
855
|
+
"pending",
|
|
856
|
+
"earned",
|
|
857
|
+
"approved",
|
|
858
|
+
"payable",
|
|
859
|
+
"paid"
|
|
860
|
+
];
|
|
861
|
+
var COMMISSION_BASES = [
|
|
862
|
+
"fixed",
|
|
863
|
+
"gross",
|
|
864
|
+
"net",
|
|
865
|
+
"margin",
|
|
866
|
+
"custom"
|
|
867
|
+
];
|
|
868
|
+
var COMMISSION_ADJUSTMENT_KINDS = [
|
|
869
|
+
"refund",
|
|
870
|
+
"credit",
|
|
871
|
+
"chargeback",
|
|
872
|
+
"dispute",
|
|
873
|
+
"correction"
|
|
874
|
+
];
|
|
875
|
+
var COMMISSION_PAYOUT_STATUSES = [
|
|
876
|
+
"pending",
|
|
877
|
+
"approved",
|
|
878
|
+
"processing",
|
|
879
|
+
"completed",
|
|
880
|
+
"failed"
|
|
881
|
+
];
|
|
882
|
+
var EARNING_EVENT_KINDS = [
|
|
883
|
+
"conversion",
|
|
884
|
+
"agreement_execution",
|
|
885
|
+
"invoice_payment",
|
|
886
|
+
"collected_revenue",
|
|
887
|
+
"recognized_margin",
|
|
888
|
+
"milestone"
|
|
889
|
+
];
|
|
890
|
+
var ADJUSTMENT_SETTLEABLE_COMMISSION_STATUSES = [
|
|
891
|
+
"earned",
|
|
892
|
+
"approved",
|
|
893
|
+
"payable",
|
|
894
|
+
"paid"
|
|
895
|
+
];
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region src/commissions/models/CommissionPlan.ts
|
|
898
|
+
var __defProp$2 = Object.defineProperty;
|
|
899
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
900
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
901
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
902
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
903
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
904
|
+
return result;
|
|
905
|
+
};
|
|
906
|
+
var PLAN_STATUS_TRANSITIONS = {
|
|
907
|
+
draft: ["active", "retired"],
|
|
908
|
+
active: ["superseded", "retired"],
|
|
909
|
+
superseded: [],
|
|
910
|
+
retired: []
|
|
911
|
+
};
|
|
912
|
+
var loadedPlanStatus = /* @__PURE__ */ new WeakMap();
|
|
913
|
+
var frozenPlanSnapshot = /* @__PURE__ */ new WeakMap();
|
|
914
|
+
function validateCommissionPlanComponents(components) {
|
|
915
|
+
if (!Array.isArray(components)) throw new Error("CommissionPlan components must be an array");
|
|
916
|
+
const seen = /* @__PURE__ */ new Set();
|
|
917
|
+
for (const component of components) {
|
|
918
|
+
const label = component?.key || "<missing key>";
|
|
919
|
+
if (!component || typeof component !== "object") throw new Error("CommissionPlan component must be an object");
|
|
920
|
+
if (!component.key || typeof component.key !== "string") throw new Error("CommissionPlan component requires a non-empty key");
|
|
921
|
+
if (seen.has(component.key)) throw new Error(`CommissionPlan component keys must be unique \u2014 duplicate '${component.key}'`);
|
|
922
|
+
seen.add(component.key);
|
|
923
|
+
if (!component.trigger || typeof component.trigger !== "string") throw new Error(`CommissionPlan component '${label}' requires a non-empty trigger ('*' matches all kinds)`);
|
|
924
|
+
if (!COMMISSION_BASES.includes(component.basis)) throw new Error(`CommissionPlan component '${label}' has invalid basis '${component.basis}'`);
|
|
925
|
+
if (component.basis === "fixed") {
|
|
926
|
+
if (typeof component.fixedAmountCents !== "number" || !Number.isInteger(component.fixedAmountCents)) throw new Error(`CommissionPlan component '${label}' with basis 'fixed' requires an integer fixedAmountCents`);
|
|
927
|
+
} else if (typeof component.rate !== "number" || !Number.isFinite(component.rate) || component.rate < 0 || component.rate > 1) throw new Error(`CommissionPlan component '${label}' with basis '${component.basis}' requires a rate in [0, 1]`);
|
|
928
|
+
if (component.basis === "custom" && !component.customBasisKey) throw new Error(`CommissionPlan component '${label}' with basis 'custom' requires a customBasisKey`);
|
|
929
|
+
const recurrence = component.recurrence;
|
|
930
|
+
if (recurrence !== void 0) {
|
|
931
|
+
if (recurrence.kind !== "one_time" && recurrence.kind !== "recurring") throw new Error(`CommissionPlan component '${label}' recurrence.kind must be 'one_time' or 'recurring'`);
|
|
932
|
+
for (const [name, value] of [["maxOccurrences", recurrence.maxOccurrences], ["windowMonths", recurrence.windowMonths]]) if (value !== void 0 && (!Number.isInteger(value) || value <= 0)) throw new Error(`CommissionPlan component '${label}' recurrence.${name} must be a positive integer`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
var CommissionPlan = class extends SmrtObject {
|
|
937
|
+
tenantId = null;
|
|
938
|
+
planKey = "";
|
|
939
|
+
/** Monotonic version within `planKey`. Amendments insert `max + 1`. */
|
|
940
|
+
version = 1;
|
|
941
|
+
/** Human-readable plan name. */
|
|
942
|
+
name = "";
|
|
943
|
+
/** Longer human-readable description of the terms. */
|
|
944
|
+
description = "";
|
|
945
|
+
/**
|
|
946
|
+
* Lifecycle status — see {@link PLAN_STATUS_TRANSITIONS}. Mutate via
|
|
947
|
+
* {@link activate} / {@link supersede} / {@link retire} (or a legal
|
|
948
|
+
* single-step assignment; the save-time guard rejects illegal edges).
|
|
949
|
+
*/
|
|
950
|
+
status = "draft";
|
|
951
|
+
/** When this version takes effect. Frozen once the plan activates. */
|
|
952
|
+
effectiveFrom = null;
|
|
953
|
+
/** ISO 4217 currency the plan's terms are denominated in. */
|
|
954
|
+
currency = "USD";
|
|
955
|
+
/**
|
|
956
|
+
* Calculation components as a JSON-string array — see
|
|
957
|
+
* {@link CommissionPlanComponent}. Use {@link getComponents} /
|
|
958
|
+
* {@link setComponents} (the setter validates).
|
|
959
|
+
*/
|
|
960
|
+
components = "[]";
|
|
961
|
+
/** Additional metadata as a JSON string. */
|
|
962
|
+
metadata = "{}";
|
|
963
|
+
constructor(options = {}) {
|
|
964
|
+
super(options);
|
|
965
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
966
|
+
if (options.planKey !== void 0) this.planKey = options.planKey;
|
|
967
|
+
if (options.version !== void 0) this.version = options.version;
|
|
968
|
+
if (options.name !== void 0) this.name = options.name;
|
|
969
|
+
if (options.description !== void 0) this.description = options.description;
|
|
970
|
+
if (options.status !== void 0) this.status = options.status;
|
|
971
|
+
if (options.effectiveFrom !== void 0) this.effectiveFrom = CommissionPlan.coerceDate(options.effectiveFrom);
|
|
972
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
973
|
+
if (options.components !== void 0) this.components = options.components;
|
|
974
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Re-coerce date fields after the framework reapplies raw option values,
|
|
978
|
+
* record the loaded status for the transition guard, and capture the
|
|
979
|
+
* frozen snapshot when the row arrived already activated. The snapshot is
|
|
980
|
+
* captured for every non-draft status (not just `active`) so a superseded
|
|
981
|
+
* or retired version — history — can't be rewritten either.
|
|
982
|
+
*/
|
|
983
|
+
async initialize() {
|
|
984
|
+
await super.initialize();
|
|
985
|
+
this.effectiveFrom = CommissionPlan.coerceDate(this.effectiveFrom);
|
|
986
|
+
if (await this.isSaved()) {
|
|
987
|
+
loadedPlanStatus.set(this, this.status);
|
|
988
|
+
if (this.status !== "draft") frozenPlanSnapshot.set(this, this.serializeFrozenSnapshot());
|
|
989
|
+
}
|
|
990
|
+
return this;
|
|
991
|
+
}
|
|
992
|
+
isDraft() {
|
|
993
|
+
return this.status === "draft";
|
|
994
|
+
}
|
|
995
|
+
isActive() {
|
|
996
|
+
return this.status === "active";
|
|
997
|
+
}
|
|
998
|
+
/** Parse {@link components}; returns `[]` on empty/invalid JSON. */
|
|
999
|
+
getComponents() {
|
|
1000
|
+
if (!this.components) return [];
|
|
1001
|
+
try {
|
|
1002
|
+
const parsed = JSON.parse(this.components);
|
|
1003
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1004
|
+
} catch {
|
|
1005
|
+
return [];
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Validate and store the components array. Throws on invalid components —
|
|
1010
|
+
* see {@link validateCommissionPlanComponents} for the rules.
|
|
1011
|
+
*/
|
|
1012
|
+
setComponents(components) {
|
|
1013
|
+
validateCommissionPlanComponents(components);
|
|
1014
|
+
this.components = JSON.stringify(components);
|
|
1015
|
+
}
|
|
1016
|
+
/** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
|
|
1017
|
+
getMetadata() {
|
|
1018
|
+
if (!this.metadata) return {};
|
|
1019
|
+
try {
|
|
1020
|
+
const parsed = JSON.parse(this.metadata);
|
|
1021
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1022
|
+
} catch {
|
|
1023
|
+
return {};
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/** Serialize and store {@link metadata}. */
|
|
1027
|
+
setMetadata(data) {
|
|
1028
|
+
this.metadata = JSON.stringify(data ?? {});
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Transition `draft → active`. Validates components first so no active
|
|
1032
|
+
* plan can carry malformed terms.
|
|
1033
|
+
*/
|
|
1034
|
+
activate() {
|
|
1035
|
+
if (this.status !== "draft") throw new Error(`CommissionPlan ${this.planKey}@${this.version}: cannot activate from status '${this.status}'`);
|
|
1036
|
+
validateCommissionPlanComponents(this.getComponents());
|
|
1037
|
+
this.status = "active";
|
|
1038
|
+
}
|
|
1039
|
+
/** Transition `active → superseded` (a newer version took over). */
|
|
1040
|
+
supersede() {
|
|
1041
|
+
if (this.status !== "active") throw new Error(`CommissionPlan ${this.planKey}@${this.version}: cannot supersede from status '${this.status}'`);
|
|
1042
|
+
this.status = "superseded";
|
|
1043
|
+
}
|
|
1044
|
+
/** Transition `draft | active → retired` (terminal). */
|
|
1045
|
+
retire() {
|
|
1046
|
+
if (this.status !== "draft" && this.status !== "active") throw new Error(`CommissionPlan ${this.planKey}@${this.version}: cannot retire from status '${this.status}'`);
|
|
1047
|
+
this.status = "retired";
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Save with two guards:
|
|
1051
|
+
*
|
|
1052
|
+
* 1. **Status transition** — the about-to-be-written status must be a
|
|
1053
|
+
* legal edge from the authoritative prior persisted status (re-read
|
|
1054
|
+
* from the DB so a `create({ id: <existing>, _skipLoad: true })` upsert
|
|
1055
|
+
* can't sidestep the guard — commerce pattern).
|
|
1056
|
+
* 2. **Frozen calculation identity** — once the row has been saved
|
|
1057
|
+
* non-draft, `components` / `currency` / `planKey` / `version` /
|
|
1058
|
+
* `effectiveFrom` must match the captured snapshot. Amend by inserting
|
|
1059
|
+
* a new version instead.
|
|
1060
|
+
*
|
|
1061
|
+
* Activating saves also re-validate components, so an `active` row always
|
|
1062
|
+
* carries well-formed terms regardless of which write path set them.
|
|
1063
|
+
*/
|
|
1064
|
+
async save() {
|
|
1065
|
+
const prior = await this.resolvePriorStatus();
|
|
1066
|
+
this.assertStatusTransition(prior);
|
|
1067
|
+
this.assertFrozenIdentityUnchanged();
|
|
1068
|
+
await this.assertNaturalKeyNotTaken();
|
|
1069
|
+
if (this.status === "active") validateCommissionPlanComponents(this.getComponents());
|
|
1070
|
+
const result = await super.save();
|
|
1071
|
+
loadedPlanStatus.set(this, this.status);
|
|
1072
|
+
if (this.status !== "draft" && !frozenPlanSnapshot.has(this)) frozenPlanSnapshot.set(this, this.serializeFrozenSnapshot());
|
|
1073
|
+
return result;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Refuse a save whose `(tenantId, planKey, version)` natural key already
|
|
1077
|
+
* belongs to a DIFFERENT row. The frozen-identity guard above is
|
|
1078
|
+
* instance-local (WeakMap), so a FRESH instance carrying an existing
|
|
1079
|
+
* natural key would otherwise sail through and the conflict-column
|
|
1080
|
+
* upsert would rewrite the persisted terms (and rotate the row id).
|
|
1081
|
+
* Edit drafts by hydrating them; change terms with
|
|
1082
|
+
* `CommissionPlanCollection.createAmendment()`.
|
|
1083
|
+
*/
|
|
1084
|
+
async assertNaturalKeyNotTaken() {
|
|
1085
|
+
if (!this.planKey) return;
|
|
1086
|
+
try {
|
|
1087
|
+
const res = await this.db.query(`SELECT id, tenant_id FROM ${this.tableName} WHERE plan_key = $1 AND version = $2`, this.planKey, this.version);
|
|
1088
|
+
if ((Array.isArray(res) ? res : res.rows ?? []).find((row) => (row.tenant_id ?? null) === (this.tenantId ?? null) && row.id !== this.id)) throw new Error(`CommissionPlan ${this.planKey}@${this.version}: this version already exists for the tenant \u2014 plan versions are immutable records. Hydrate the existing row to edit a draft, or create new terms with CommissionPlanCollection.createAmendment().`);
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
if (error instanceof Error && error.message.includes("immutable")) throw error;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Resolve the AUTHORITATIVE prior status from the database; fall back to
|
|
1095
|
+
* the loaded-status WeakMap only when the DB is unavailable. `undefined`
|
|
1096
|
+
* means no persisted row exists (genuinely new).
|
|
1097
|
+
*/
|
|
1098
|
+
async resolvePriorStatus() {
|
|
1099
|
+
if (this.id) try {
|
|
1100
|
+
const row = await this.db.get(this.tableName, { id: this.id });
|
|
1101
|
+
if (row && row.status != null) return row.status;
|
|
1102
|
+
} catch {}
|
|
1103
|
+
return loadedPlanStatus.get(this);
|
|
1104
|
+
}
|
|
1105
|
+
assertStatusTransition(prior) {
|
|
1106
|
+
if (prior === void 0) return;
|
|
1107
|
+
if (prior === this.status) return;
|
|
1108
|
+
if (!(PLAN_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`CommissionPlan ${this.planKey}@${this.version}: illegal status transition '${prior}' \u2192 '${this.status}'. Use activate() / supersede() / retire().`);
|
|
1109
|
+
}
|
|
1110
|
+
assertFrozenIdentityUnchanged() {
|
|
1111
|
+
const captured = frozenPlanSnapshot.get(this);
|
|
1112
|
+
if (!captured) return;
|
|
1113
|
+
if (captured !== this.serializeFrozenSnapshot()) throw new Error(`CommissionPlan ${this.planKey}@${this.version}: components, currency, planKey, version, and effectiveFrom are immutable once the plan has been active. Create an amendment (CommissionPlanCollection.createAmendment) instead of editing this version.`);
|
|
1114
|
+
}
|
|
1115
|
+
serializeFrozenSnapshot() {
|
|
1116
|
+
return JSON.stringify({
|
|
1117
|
+
planKey: this.planKey,
|
|
1118
|
+
version: this.version,
|
|
1119
|
+
currency: this.currency,
|
|
1120
|
+
components: this.components,
|
|
1121
|
+
effectiveFrom: this.effectiveFrom ? this.effectiveFrom.toISOString() : null
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
static coerceDate(value) {
|
|
1125
|
+
if (value == null) return null;
|
|
1126
|
+
if (value instanceof Date) return value;
|
|
1127
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
1128
|
+
const d = new Date(value);
|
|
1129
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
1130
|
+
}
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
__decorateClass$2([tenantId({ nullable: true })], CommissionPlan.prototype, "tenantId", 2);
|
|
1135
|
+
__decorateClass$2([field({ required: true })], CommissionPlan.prototype, "planKey", 2);
|
|
1136
|
+
CommissionPlan = __decorateClass$2([TenantScoped({ mode: "optional" }), smrt({
|
|
1137
|
+
conflictColumns: [
|
|
1138
|
+
"tenant_id",
|
|
1139
|
+
"plan_key",
|
|
1140
|
+
"version"
|
|
1141
|
+
],
|
|
1142
|
+
api: { include: [
|
|
1143
|
+
"list",
|
|
1144
|
+
"get",
|
|
1145
|
+
"create"
|
|
1146
|
+
] },
|
|
1147
|
+
mcp: { include: ["list", "get"] },
|
|
1148
|
+
cli: true
|
|
1149
|
+
})], CommissionPlan);
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/commissions/collections/CommissionPlanCollection.ts
|
|
1152
|
+
var CommissionPlanCollection = class extends SmrtCollection {
|
|
1153
|
+
static _itemClass = CommissionPlan;
|
|
1154
|
+
/** Every version of a plan, newest version first. */
|
|
1155
|
+
async findByPlanKey(planKey) {
|
|
1156
|
+
return await this.list({
|
|
1157
|
+
where: { planKey },
|
|
1158
|
+
orderBy: "version DESC"
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
/** Plans by status. */
|
|
1162
|
+
async findByStatus(status) {
|
|
1163
|
+
return await this.list({
|
|
1164
|
+
where: { status },
|
|
1165
|
+
orderBy: "created_at DESC"
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* The highest ACTIVE version of a plan already IN EFFECT at `at`, or
|
|
1170
|
+
* `null` when none is. This is what calculation callers resolve terms
|
|
1171
|
+
* from when no frozen snapshot pins a specific version. A future-dated
|
|
1172
|
+
* amendment can be activated ahead of its effective date without
|
|
1173
|
+
* governing earlier qualifications (`effectiveFrom: null` = effective
|
|
1174
|
+
* immediately).
|
|
1175
|
+
*/
|
|
1176
|
+
async latestActiveByKey(planKey, at = /* @__PURE__ */ new Date(), tenantId) {
|
|
1177
|
+
const inEffect = (await this.list({
|
|
1178
|
+
where: {
|
|
1179
|
+
planKey,
|
|
1180
|
+
status: "active"
|
|
1181
|
+
},
|
|
1182
|
+
orderBy: "version DESC"
|
|
1183
|
+
})).filter((plan) => plan.effectiveFrom === null || plan.effectiveFrom <= at);
|
|
1184
|
+
if (tenantId === void 0) return inEffect[0] ?? null;
|
|
1185
|
+
return inEffect.find((plan) => plan.tenantId === tenantId) ?? inEffect.find((plan) => plan.tenantId === null) ?? null;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Create an amendment: insert a new DRAFT row with
|
|
1189
|
+
* `version = max(existing versions) + 1`, copying the latest existing
|
|
1190
|
+
* version's fields and then applying `changes`. The source version is not
|
|
1191
|
+
* touched — activate the draft (and supersede the prior active version)
|
|
1192
|
+
* as a separate, explicit step.
|
|
1193
|
+
*
|
|
1194
|
+
* Throws when no version of `planKey` exists (nothing to amend — use
|
|
1195
|
+
* `create` for a brand-new plan).
|
|
1196
|
+
*/
|
|
1197
|
+
async createAmendment(planKey, changes = {}) {
|
|
1198
|
+
const latest = (await this.findByPlanKey(planKey))[0];
|
|
1199
|
+
if (!latest) throw new Error(`CommissionPlanCollection.createAmendment: no versions exist for plan key '${planKey}' \u2014 create the plan first`);
|
|
1200
|
+
if (changes.components !== void 0) validateCommissionPlanComponents(changes.components);
|
|
1201
|
+
return await this.create({
|
|
1202
|
+
tenantId: latest.tenantId,
|
|
1203
|
+
planKey,
|
|
1204
|
+
version: latest.version + 1,
|
|
1205
|
+
status: "draft",
|
|
1206
|
+
name: changes.name ?? latest.name,
|
|
1207
|
+
description: changes.description ?? latest.description,
|
|
1208
|
+
currency: changes.currency ?? latest.currency,
|
|
1209
|
+
effectiveFrom: changes.effectiveFrom !== void 0 ? changes.effectiveFrom : latest.effectiveFrom,
|
|
1210
|
+
components: changes.components !== void 0 ? JSON.stringify(changes.components) : latest.components,
|
|
1211
|
+
metadata: changes.metadata !== void 0 ? JSON.stringify(changes.metadata) : latest.metadata
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
//#endregion
|
|
1216
|
+
//#region src/commissions/models/Earner.ts
|
|
1217
|
+
var __defProp$1 = Object.defineProperty;
|
|
1218
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
1219
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
1220
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
1221
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
1222
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
1223
|
+
return result;
|
|
1224
|
+
};
|
|
1225
|
+
var Earner = class extends SmrtObject {
|
|
1226
|
+
tenantId = null;
|
|
1227
|
+
profileId = "";
|
|
1228
|
+
/** Human-readable display name for portals and operator views. */
|
|
1229
|
+
displayName = "";
|
|
1230
|
+
/** Account lifecycle: `pending` (default) → `active` / `suspended`. */
|
|
1231
|
+
status = "pending";
|
|
1232
|
+
/** Preferred payout delivery method. */
|
|
1233
|
+
payoutMethod = "bank_transfer";
|
|
1234
|
+
/**
|
|
1235
|
+
* Minimum unsettled balance (integer cents) before a payout batch is
|
|
1236
|
+
* created. Default $50.00 = 5000 cents.
|
|
1237
|
+
*/
|
|
1238
|
+
payoutThresholdCents = 5e3;
|
|
1239
|
+
/**
|
|
1240
|
+
* Payout cadence key. Open string so applications can define their own
|
|
1241
|
+
* schedules (`manual`, `monthly`, `weekly`, `net_30`, …); `manual` means
|
|
1242
|
+
* an operator triggers batches explicitly.
|
|
1243
|
+
*/
|
|
1244
|
+
payoutScheduleKey = "manual";
|
|
1245
|
+
/** ISO 4217 currency all of this earner's balances settle in. */
|
|
1246
|
+
currency = "USD";
|
|
1247
|
+
/**
|
|
1248
|
+
* Additional metadata as a JSON string (tax info, payout-rail details,
|
|
1249
|
+
* …). Use {@link getMetadata}/{@link setMetadata}.
|
|
1250
|
+
*/
|
|
1251
|
+
metadata = "{}";
|
|
1252
|
+
constructor(options = {}) {
|
|
1253
|
+
super(options);
|
|
1254
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
1255
|
+
if (options.profileId !== void 0) this.profileId = options.profileId;
|
|
1256
|
+
if (options.displayName !== void 0) this.displayName = options.displayName;
|
|
1257
|
+
if (options.status !== void 0) this.status = options.status;
|
|
1258
|
+
if (options.payoutMethod !== void 0) this.payoutMethod = options.payoutMethod;
|
|
1259
|
+
if (options.payoutThresholdCents !== void 0) this.payoutThresholdCents = options.payoutThresholdCents;
|
|
1260
|
+
if (options.payoutScheduleKey !== void 0) this.payoutScheduleKey = options.payoutScheduleKey;
|
|
1261
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
1262
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
1263
|
+
}
|
|
1264
|
+
isActive() {
|
|
1265
|
+
return this.status === "active";
|
|
1266
|
+
}
|
|
1267
|
+
isPending() {
|
|
1268
|
+
return this.status === "pending";
|
|
1269
|
+
}
|
|
1270
|
+
isSuspended() {
|
|
1271
|
+
return this.status === "suspended";
|
|
1272
|
+
}
|
|
1273
|
+
/** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
|
|
1274
|
+
getMetadata() {
|
|
1275
|
+
if (!this.metadata) return {};
|
|
1276
|
+
try {
|
|
1277
|
+
const parsed = JSON.parse(this.metadata);
|
|
1278
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1279
|
+
} catch {
|
|
1280
|
+
return {};
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/** Serialize and store {@link metadata}. */
|
|
1284
|
+
setMetadata(data) {
|
|
1285
|
+
this.metadata = JSON.stringify(data ?? {});
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
__decorateClass$1([tenantId({ nullable: true })], Earner.prototype, "tenantId", 2);
|
|
1289
|
+
__decorateClass$1([crossPackageRef("@happyvertical/smrt-profiles:Profile")], Earner.prototype, "profileId", 2);
|
|
1290
|
+
Earner = __decorateClass$1([TenantScoped({ mode: "optional" }), smrt({
|
|
1291
|
+
api: { include: [
|
|
1292
|
+
"list",
|
|
1293
|
+
"get",
|
|
1294
|
+
"create",
|
|
1295
|
+
"update"
|
|
1296
|
+
] },
|
|
1297
|
+
mcp: { include: [
|
|
1298
|
+
"list",
|
|
1299
|
+
"get",
|
|
1300
|
+
"create"
|
|
1301
|
+
] },
|
|
1302
|
+
cli: true
|
|
1303
|
+
})], Earner);
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/commissions/collections/EarnerCollection.ts
|
|
1306
|
+
var EarnerCollection = class extends SmrtCollection {
|
|
1307
|
+
static _itemClass = Earner;
|
|
1308
|
+
/** Earners linked to a smrt-profiles Profile. */
|
|
1309
|
+
async findByProfile(profileId) {
|
|
1310
|
+
return await this.list({
|
|
1311
|
+
where: { profileId },
|
|
1312
|
+
orderBy: "created_at DESC"
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
/** Earners by status. */
|
|
1316
|
+
async findByStatus(status) {
|
|
1317
|
+
return await this.list({
|
|
1318
|
+
where: { status },
|
|
1319
|
+
orderBy: "created_at DESC"
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
/** All active earners. */
|
|
1323
|
+
async findActive() {
|
|
1324
|
+
return await this.findByStatus("active");
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
//#endregion
|
|
1328
|
+
//#region src/commissions/models/EarningEvent.ts
|
|
1329
|
+
var __defProp = Object.defineProperty;
|
|
1330
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
1331
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
1332
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
1333
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
1334
|
+
if (kind && result) __defProp(target, key, result);
|
|
1335
|
+
return result;
|
|
1336
|
+
};
|
|
1337
|
+
var persistedEventState = /* @__PURE__ */ new WeakMap();
|
|
1338
|
+
var EarningEvent = class extends SmrtObject {
|
|
1339
|
+
tenantId = null;
|
|
1340
|
+
eventKind = "";
|
|
1341
|
+
/** When the commercial event occurred (not when it was ingested). */
|
|
1342
|
+
occurredAt = /* @__PURE__ */ new Date();
|
|
1343
|
+
/**
|
|
1344
|
+
* Generic earning-source discriminator (`referral`, `opportunity`,
|
|
1345
|
+
* `subscription`, `ad_event`, …). Free-form; this module attaches no
|
|
1346
|
+
* semantics to it.
|
|
1347
|
+
*/
|
|
1348
|
+
sourceKind = "";
|
|
1349
|
+
/** Id of the source record named by {@link sourceKind}. */
|
|
1350
|
+
sourceId = "";
|
|
1351
|
+
/** Gross amount of the event in integer cents. */
|
|
1352
|
+
grossAmountCents = 0;
|
|
1353
|
+
netAmountCents = null;
|
|
1354
|
+
marginCents = null;
|
|
1355
|
+
/** ISO 4217 currency of the event's amounts. */
|
|
1356
|
+
currency = "USD";
|
|
1357
|
+
/**
|
|
1358
|
+
* JSON map of `basisKey → integer cents` for `custom`-basis plan
|
|
1359
|
+
* components. Use {@link getCustomBases}/{@link setCustomBases}.
|
|
1360
|
+
*/
|
|
1361
|
+
customBases = "{}";
|
|
1362
|
+
dedupeKey = "";
|
|
1363
|
+
/** Additional metadata as a JSON string. */
|
|
1364
|
+
metadata = "{}";
|
|
1365
|
+
constructor(options = {}) {
|
|
1366
|
+
super(options);
|
|
1367
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
1368
|
+
if (options.eventKind !== void 0) this.eventKind = options.eventKind;
|
|
1369
|
+
if (options.occurredAt !== void 0) this.occurredAt = EarningEvent.coerceDate(options.occurredAt) ?? /* @__PURE__ */ new Date();
|
|
1370
|
+
if (options.sourceKind !== void 0) this.sourceKind = options.sourceKind;
|
|
1371
|
+
if (options.sourceId !== void 0) this.sourceId = options.sourceId;
|
|
1372
|
+
if (options.grossAmountCents !== void 0) this.grossAmountCents = options.grossAmountCents;
|
|
1373
|
+
if (options.netAmountCents !== void 0) this.netAmountCents = options.netAmountCents;
|
|
1374
|
+
if (options.marginCents !== void 0) this.marginCents = options.marginCents;
|
|
1375
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
1376
|
+
if (options.customBases !== void 0) this.customBases = options.customBases;
|
|
1377
|
+
if (options.dedupeKey !== void 0) this.dedupeKey = options.dedupeKey;
|
|
1378
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Re-coerce {@link occurredAt} after the framework reapplies raw option /
|
|
1382
|
+
* hydrated row values (SQLite hands back ISO strings), and capture the
|
|
1383
|
+
* persisted state for the immutability guard when this instance hydrated
|
|
1384
|
+
* an existing row.
|
|
1385
|
+
*/
|
|
1386
|
+
async initialize() {
|
|
1387
|
+
await super.initialize();
|
|
1388
|
+
this.occurredAt = EarningEvent.coerceDate(this.occurredAt) ?? /* @__PURE__ */ new Date();
|
|
1389
|
+
if (await this.isSaved()) persistedEventState.set(this, this.serializeState());
|
|
1390
|
+
return this;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Save with the evidence-immutability guard. EarningEvents are immutable
|
|
1394
|
+
* commercial evidence; three write vectors are closed:
|
|
1395
|
+
*
|
|
1396
|
+
* - a HYDRATED persisted row must serialize identically to its captured
|
|
1397
|
+
* state (no-op re-saves pass, any change throws);
|
|
1398
|
+
* - an instance carrying an existing id WITHOUT having hydrated it
|
|
1399
|
+
* (`create({ id, _skipLoad: true })`) is rejected outright;
|
|
1400
|
+
* - a NEW instance whose `dedupeKey` already belongs to another row is
|
|
1401
|
+
* refused outright: the natural-key upsert would not only rewrite the
|
|
1402
|
+
* evidence values but ROTATE the row's id (orphaning any Commission
|
|
1403
|
+
* whose `earningEventId` points at it). Idempotent ingestion goes
|
|
1404
|
+
* through `EarningEventCollection.getOrCreateByDedupeKey()`, which
|
|
1405
|
+
* finds first and never upserts.
|
|
1406
|
+
*/
|
|
1407
|
+
async save() {
|
|
1408
|
+
const captured = persistedEventState.get(this);
|
|
1409
|
+
if (captured !== void 0) {
|
|
1410
|
+
if (captured !== this.serializeState()) throw new Error(`EarningEvent ${this.id ?? "<new>"}: earning events are immutable evidence \u2014 record a correcting event (or a CommissionAdjustment downstream) instead of editing this row.`);
|
|
1411
|
+
} else if (this.id && await this.isSaved()) throw new Error(`EarningEvent ${this.id}: refusing to overwrite an existing event row from a non-hydrated instance \u2014 earning events are immutable evidence.`);
|
|
1412
|
+
else if (this.dedupeKey) try {
|
|
1413
|
+
const row = await this.db.get(this.tableName, { dedupe_key: this.dedupeKey });
|
|
1414
|
+
if (row && row.id !== this.id) throw new Error(`EarningEvent (dedupeKey '${this.dedupeKey}'): an event with this dedupe key already exists \u2014 earning events are immutable evidence, and the natural-key upsert would rotate the existing row's id (orphaning commissions that reference it). Use EarningEventCollection.getOrCreateByDedupeKey() for idempotent ingestion, or record a new event under its own dedupe key.`);
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
if (error instanceof Error && error.message.includes("immutable evidence")) throw error;
|
|
1417
|
+
}
|
|
1418
|
+
const result = await super.save();
|
|
1419
|
+
persistedEventState.set(this, this.serializeState());
|
|
1420
|
+
return result;
|
|
1421
|
+
}
|
|
1422
|
+
serializeState() {
|
|
1423
|
+
return JSON.stringify({
|
|
1424
|
+
tenantId: this.tenantId,
|
|
1425
|
+
eventKind: this.eventKind,
|
|
1426
|
+
occurredAt: this.occurredAt.toISOString(),
|
|
1427
|
+
sourceKind: this.sourceKind,
|
|
1428
|
+
sourceId: this.sourceId,
|
|
1429
|
+
grossAmountCents: this.grossAmountCents,
|
|
1430
|
+
netAmountCents: this.netAmountCents,
|
|
1431
|
+
marginCents: this.marginCents,
|
|
1432
|
+
currency: this.currency,
|
|
1433
|
+
customBases: this.customBases,
|
|
1434
|
+
dedupeKey: this.dedupeKey,
|
|
1435
|
+
metadata: this.metadata
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Parse {@link customBases} into a `basisKey → cents` map; non-numeric
|
|
1440
|
+
* values are dropped. Returns `{}` on empty/invalid JSON.
|
|
1441
|
+
*/
|
|
1442
|
+
getCustomBases() {
|
|
1443
|
+
if (!this.customBases) return {};
|
|
1444
|
+
try {
|
|
1445
|
+
const parsed = JSON.parse(this.customBases);
|
|
1446
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
1447
|
+
const out = {};
|
|
1448
|
+
for (const [key, value] of Object.entries(parsed)) if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
|
|
1449
|
+
return out;
|
|
1450
|
+
} catch {
|
|
1451
|
+
return {};
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
/** Serialize and store {@link customBases}. */
|
|
1455
|
+
setCustomBases(bases) {
|
|
1456
|
+
this.customBases = JSON.stringify(bases ?? {});
|
|
1457
|
+
}
|
|
1458
|
+
/** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
|
|
1459
|
+
getMetadata() {
|
|
1460
|
+
if (!this.metadata) return {};
|
|
1461
|
+
try {
|
|
1462
|
+
const parsed = JSON.parse(this.metadata);
|
|
1463
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1464
|
+
} catch {
|
|
1465
|
+
return {};
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
/** Serialize and store {@link metadata}. */
|
|
1469
|
+
setMetadata(data) {
|
|
1470
|
+
this.metadata = JSON.stringify(data ?? {});
|
|
1471
|
+
}
|
|
1472
|
+
static coerceDate(value) {
|
|
1473
|
+
if (value == null) return null;
|
|
1474
|
+
if (value instanceof Date) return value;
|
|
1475
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
1476
|
+
const d = new Date(value);
|
|
1477
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
1478
|
+
}
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
__decorateClass([tenantId({ nullable: true })], EarningEvent.prototype, "tenantId", 2);
|
|
1483
|
+
__decorateClass([field({ required: true })], EarningEvent.prototype, "eventKind", 2);
|
|
1484
|
+
__decorateClass([field({
|
|
1485
|
+
type: "integer",
|
|
1486
|
+
nullable: true
|
|
1487
|
+
})], EarningEvent.prototype, "netAmountCents", 2);
|
|
1488
|
+
__decorateClass([field({
|
|
1489
|
+
type: "integer",
|
|
1490
|
+
nullable: true
|
|
1491
|
+
})], EarningEvent.prototype, "marginCents", 2);
|
|
1492
|
+
__decorateClass([field({ required: true })], EarningEvent.prototype, "dedupeKey", 2);
|
|
1493
|
+
EarningEvent = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
1494
|
+
conflictColumns: ["dedupe_key"],
|
|
1495
|
+
api: { include: [
|
|
1496
|
+
"create",
|
|
1497
|
+
"list",
|
|
1498
|
+
"get"
|
|
1499
|
+
] },
|
|
1500
|
+
mcp: { include: ["list", "create"] },
|
|
1501
|
+
cli: false
|
|
1502
|
+
})], EarningEvent);
|
|
1503
|
+
//#endregion
|
|
1504
|
+
//#region src/commissions/collections/EarningEventCollection.ts
|
|
1505
|
+
var EarningEventCollection = class extends SmrtCollection {
|
|
1506
|
+
static _itemClass = EarningEvent;
|
|
1507
|
+
/** Look up an event by its idempotency natural key. */
|
|
1508
|
+
async findByDedupeKey(dedupeKey) {
|
|
1509
|
+
if (!dedupeKey) return null;
|
|
1510
|
+
return (await this.list({
|
|
1511
|
+
where: { dedupeKey },
|
|
1512
|
+
limit: 1
|
|
1513
|
+
}))[0] ?? null;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Idempotent ingestion: if an event with `options.dedupeKey` already
|
|
1517
|
+
* exists, return it untouched (`created: false`) — evidence is immutable,
|
|
1518
|
+
* so a replay never updates the stored row. Otherwise create the event.
|
|
1519
|
+
*
|
|
1520
|
+
* `dedupeKey` is required — callers embed tenant/source identity in it
|
|
1521
|
+
* (e.g. `` `${tenantId}:${sourceKind}:${sourceId}:${eventKind}` ``);
|
|
1522
|
+
* an empty key would silently disable idempotency, so it throws instead.
|
|
1523
|
+
*/
|
|
1524
|
+
async getOrCreateByDedupeKey(options) {
|
|
1525
|
+
const dedupeKey = options.dedupeKey ?? "";
|
|
1526
|
+
if (!dedupeKey) throw new Error("EarningEventCollection.getOrCreateByDedupeKey requires a dedupeKey");
|
|
1527
|
+
const existing = await this.findByDedupeKey(dedupeKey);
|
|
1528
|
+
if (existing) return {
|
|
1529
|
+
event: existing,
|
|
1530
|
+
created: false
|
|
1531
|
+
};
|
|
1532
|
+
const { occurredAt, ...rest } = options;
|
|
1533
|
+
return {
|
|
1534
|
+
event: await this.create({
|
|
1535
|
+
...rest,
|
|
1536
|
+
...occurredAt !== void 0 ? { occurredAt: new Date(occurredAt) } : {}
|
|
1537
|
+
}),
|
|
1538
|
+
created: true
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
/** Events for one generic earning source, newest occurrence first. */
|
|
1542
|
+
async findBySource(sourceKind, sourceId) {
|
|
1543
|
+
return await this.list({
|
|
1544
|
+
where: {
|
|
1545
|
+
sourceKind,
|
|
1546
|
+
sourceId
|
|
1547
|
+
},
|
|
1548
|
+
orderBy: "occurred_at DESC"
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
/** Events by kind, newest occurrence first. */
|
|
1552
|
+
async findByKind(eventKind) {
|
|
1553
|
+
return await this.list({
|
|
1554
|
+
where: { eventKind },
|
|
1555
|
+
orderBy: "occurred_at DESC"
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
//#endregion
|
|
1560
|
+
//#region src/commissions/money.ts
|
|
1561
|
+
function roundCents(value) {
|
|
1562
|
+
const rounded = Math.sign(value) * Math.round(Math.abs(value));
|
|
1563
|
+
return rounded === 0 ? 0 : rounded;
|
|
1564
|
+
}
|
|
1565
|
+
function centsToAmount(cents) {
|
|
1566
|
+
return cents / 100;
|
|
1567
|
+
}
|
|
1568
|
+
function amountToCents(amount) {
|
|
1569
|
+
return roundCents(amount * 100);
|
|
1570
|
+
}
|
|
1571
|
+
function calculateCommissionAmountCents(baseCents, rate, shareFraction) {
|
|
1572
|
+
return roundCents(baseCents * rate * (shareFraction ?? 1));
|
|
1573
|
+
}
|
|
1574
|
+
//#endregion
|
|
1575
|
+
//#region src/commissions/services/CommissionBalanceService.ts
|
|
1576
|
+
var CommissionBalanceService = class CommissionBalanceService {
|
|
1577
|
+
constructor(commissions, adjustments) {
|
|
1578
|
+
this.commissions = commissions;
|
|
1579
|
+
this.adjustments = adjustments;
|
|
1580
|
+
}
|
|
1581
|
+
commissions;
|
|
1582
|
+
adjustments;
|
|
1583
|
+
static async create(classOptions = {}) {
|
|
1584
|
+
return new CommissionBalanceService(await CommissionCollection.create(classOptions), await CommissionAdjustmentCollection.create(classOptions));
|
|
1585
|
+
}
|
|
1586
|
+
/** Compute the {@link EarnerBalance} for one earner in one currency. */
|
|
1587
|
+
async getBalance(earnerId, currency) {
|
|
1588
|
+
const rows = await this.commissions.list({ where: {
|
|
1589
|
+
earnerId,
|
|
1590
|
+
currency
|
|
1591
|
+
} });
|
|
1592
|
+
const sumByStatus = (status) => rows.filter((c) => c.status === status).reduce((sum, c) => sum + c.amountCents, 0);
|
|
1593
|
+
const pendingCents = sumByStatus("pending");
|
|
1594
|
+
const earnedCents = sumByStatus("earned");
|
|
1595
|
+
const approvedCents = sumByStatus("approved");
|
|
1596
|
+
const payableCents = rows.filter((c) => c.status === "payable" && !c.payoutId).reduce((sum, c) => sum + c.amountCents, 0);
|
|
1597
|
+
const statusById = /* @__PURE__ */ new Map();
|
|
1598
|
+
for (const c of rows) if (c.id) statusById.set(c.id, c.status);
|
|
1599
|
+
const unsettled = await this.adjustments.findUnsettledByEarner(earnerId, currency);
|
|
1600
|
+
let unsettledAdjustmentCents = 0;
|
|
1601
|
+
for (const adjustment of unsettled) {
|
|
1602
|
+
const parentStatus = statusById.get(adjustment.commissionId);
|
|
1603
|
+
if (parentStatus !== void 0 && ADJUSTMENT_SETTLEABLE_COMMISSION_STATUSES.includes(parentStatus)) unsettledAdjustmentCents += adjustment.amountCents;
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
earnerId,
|
|
1607
|
+
currency,
|
|
1608
|
+
payableCents,
|
|
1609
|
+
pendingCents,
|
|
1610
|
+
earnedCents,
|
|
1611
|
+
approvedCents,
|
|
1612
|
+
unsettledAdjustmentCents,
|
|
1613
|
+
netPayableCents: payableCents + unsettledAdjustmentCents
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/commissions/services/CommissionCalculationService.ts
|
|
1619
|
+
var MS_PER_DAY = 1440 * 60 * 1e3;
|
|
1620
|
+
var CommissionCalculationService = class CommissionCalculationService {
|
|
1621
|
+
constructor(commissions, earners) {
|
|
1622
|
+
this.commissions = commissions;
|
|
1623
|
+
this.earners = earners;
|
|
1624
|
+
}
|
|
1625
|
+
commissions;
|
|
1626
|
+
earners;
|
|
1627
|
+
static async create(classOptions = {}) {
|
|
1628
|
+
return new CommissionCalculationService(await CommissionCollection.create(classOptions), await EarnerCollection.create(classOptions));
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Calculate commissions for one event × one earner × a component set.
|
|
1632
|
+
*
|
|
1633
|
+
* For each component whose `trigger` matches `event.eventKind` (or `'*'`
|
|
1634
|
+
* — non-matching components are silently filtered, not "skipped"):
|
|
1635
|
+
*
|
|
1636
|
+
* 1. **Idempotency** — if a Commission already exists for this
|
|
1637
|
+
* (event, terms, component, earner) tuple, it is returned in
|
|
1638
|
+
* `existing` and nothing else runs for the component.
|
|
1639
|
+
* 2. **Currency** — `input.currency` (when given) must equal the event's;
|
|
1640
|
+
* otherwise skip `'currency_mismatch'`.
|
|
1641
|
+
* 3. **Recurrence** — `one_time` components skip
|
|
1642
|
+
* `'occurrence_limit_reached'` once the resolver reports ≥ 1 prior
|
|
1643
|
+
* occurrence; `recurring` components honor `maxOccurrences` and
|
|
1644
|
+
* `windowMonths` (events after `anchorAt + windowMonths` skip
|
|
1645
|
+
* `'outside_recurrence_window'`).
|
|
1646
|
+
* 4. **Basis** — gross → `grossAmountCents`; net → `netAmountCents`
|
|
1647
|
+
* (skip `'net_basis_undefined'` when null — net is explicit, NEVER
|
|
1648
|
+
* derived from gross); margin → `marginCents` (skip
|
|
1649
|
+
* `'margin_basis_undefined'` when null); fixed → `fixedAmountCents`;
|
|
1650
|
+
* custom → `getCustomBases()[customBasisKey]` (skip
|
|
1651
|
+
* `'custom_basis_missing'`).
|
|
1652
|
+
* 5. **Amount** — `roundCents(base * rate * shareFraction)`; for `fixed`,
|
|
1653
|
+
* `roundCents(fixedAmountCents * shareFraction)` with `rate` recorded
|
|
1654
|
+
* as `0`. Rounding happens exactly once, on the final product.
|
|
1655
|
+
*
|
|
1656
|
+
* Every created Commission is persisted `pending`, carries the event's
|
|
1657
|
+
* tenant/currency/source, a complete {@link CommissionCalculationTrace},
|
|
1658
|
+
* `clearingEndsAt` when `clearingDays` was given, and the dedupe key
|
|
1659
|
+
* `` `${event.dedupeKey}:${termsSnapshotId || planKey + '@' + planVersion}:${componentKey}:${earnerId}:${occurrenceIndex}` ``.
|
|
1660
|
+
*/
|
|
1661
|
+
async calculateForEvent(input) {
|
|
1662
|
+
const { event } = input;
|
|
1663
|
+
if (!event.id) throw new Error("CommissionCalculationService.calculateForEvent requires a persisted event (missing id)");
|
|
1664
|
+
if (!input.earnerId) throw new Error("CommissionCalculationService.calculateForEvent requires an earnerId");
|
|
1665
|
+
const shareFraction = input.shareFraction ?? 1;
|
|
1666
|
+
if (!Number.isFinite(shareFraction) || shareFraction < 0 || shareFraction > 1) throw new Error(`CommissionCalculationService.calculateForEvent: shareFraction must be a finite number in [0, 1], got ${String(shareFraction)}`);
|
|
1667
|
+
validateCommissionPlanComponents(input.components);
|
|
1668
|
+
if (this.earners) {
|
|
1669
|
+
const earner = await this.earners.get({ id: input.earnerId });
|
|
1670
|
+
if (earner && earner.tenantId !== null && (event.tenantId ?? null) !== null && earner.tenantId !== event.tenantId) throw new Error(`CommissionCalculationService.calculateForEvent: earner '${input.earnerId}' belongs to tenant '${earner.tenantId}' but the event belongs to tenant '${event.tenantId}' \u2014 cross-tenant commissions are refused.`);
|
|
1671
|
+
}
|
|
1672
|
+
const termsRef = input.termsSnapshotId || `${input.planKey}@${input.planVersion}`;
|
|
1673
|
+
const created = [];
|
|
1674
|
+
const skipped = [];
|
|
1675
|
+
const existing = [];
|
|
1676
|
+
for (const component of input.components) {
|
|
1677
|
+
if (component.trigger !== "*" && component.trigger !== event.eventKind) continue;
|
|
1678
|
+
const priorForEvent = await this.commissions.list({
|
|
1679
|
+
where: {
|
|
1680
|
+
earningEventId: event.id,
|
|
1681
|
+
earnerId: input.earnerId,
|
|
1682
|
+
componentKey: component.key,
|
|
1683
|
+
planKey: input.planKey,
|
|
1684
|
+
planVersion: input.planVersion,
|
|
1685
|
+
termsSnapshotId: input.termsSnapshotId ?? ""
|
|
1686
|
+
},
|
|
1687
|
+
limit: 1
|
|
1688
|
+
});
|
|
1689
|
+
if (priorForEvent[0]) {
|
|
1690
|
+
existing.push(priorForEvent[0]);
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
if (input.currency !== void 0 && input.currency !== event.currency) {
|
|
1694
|
+
skipped.push({
|
|
1695
|
+
componentKey: component.key,
|
|
1696
|
+
reason: "currency_mismatch"
|
|
1697
|
+
});
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
const occurrenceCount = input.occurrenceCountResolver ? await input.occurrenceCountResolver(component.key) : 0;
|
|
1701
|
+
const recurrence = component.recurrence;
|
|
1702
|
+
if (recurrence) {
|
|
1703
|
+
if (recurrence.kind === "one_time" && occurrenceCount >= 1) {
|
|
1704
|
+
skipped.push({
|
|
1705
|
+
componentKey: component.key,
|
|
1706
|
+
reason: "occurrence_limit_reached"
|
|
1707
|
+
});
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
if (recurrence.kind === "recurring" && recurrence.maxOccurrences !== void 0 && occurrenceCount >= recurrence.maxOccurrences) {
|
|
1711
|
+
skipped.push({
|
|
1712
|
+
componentKey: component.key,
|
|
1713
|
+
reason: "occurrence_limit_reached"
|
|
1714
|
+
});
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
if (recurrence.windowMonths !== void 0 && input.anchorAt !== void 0 && event.occurredAt.getTime() > CommissionCalculationService.addMonths(input.anchorAt, recurrence.windowMonths).getTime()) {
|
|
1718
|
+
skipped.push({
|
|
1719
|
+
componentKey: component.key,
|
|
1720
|
+
reason: "outside_recurrence_window"
|
|
1721
|
+
});
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
let baseAmountCents;
|
|
1726
|
+
switch (component.basis) {
|
|
1727
|
+
case "gross":
|
|
1728
|
+
baseAmountCents = event.grossAmountCents;
|
|
1729
|
+
break;
|
|
1730
|
+
case "net":
|
|
1731
|
+
if (event.netAmountCents === null) {
|
|
1732
|
+
skipped.push({
|
|
1733
|
+
componentKey: component.key,
|
|
1734
|
+
reason: "net_basis_undefined"
|
|
1735
|
+
});
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
baseAmountCents = event.netAmountCents;
|
|
1739
|
+
break;
|
|
1740
|
+
case "margin":
|
|
1741
|
+
if (event.marginCents === null) {
|
|
1742
|
+
skipped.push({
|
|
1743
|
+
componentKey: component.key,
|
|
1744
|
+
reason: "margin_basis_undefined"
|
|
1745
|
+
});
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
baseAmountCents = event.marginCents;
|
|
1749
|
+
break;
|
|
1750
|
+
case "fixed":
|
|
1751
|
+
if (typeof component.fixedAmountCents !== "number") {
|
|
1752
|
+
skipped.push({
|
|
1753
|
+
componentKey: component.key,
|
|
1754
|
+
reason: "fixed_amount_missing"
|
|
1755
|
+
});
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
baseAmountCents = component.fixedAmountCents;
|
|
1759
|
+
break;
|
|
1760
|
+
case "custom": {
|
|
1761
|
+
const bases = event.getCustomBases();
|
|
1762
|
+
const key = component.customBasisKey ?? "";
|
|
1763
|
+
const value = key ? bases[key] : void 0;
|
|
1764
|
+
if (typeof value !== "number") {
|
|
1765
|
+
skipped.push({
|
|
1766
|
+
componentKey: component.key,
|
|
1767
|
+
reason: "custom_basis_missing"
|
|
1768
|
+
});
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
baseAmountCents = value;
|
|
1772
|
+
break;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
let rate;
|
|
1776
|
+
let amountCents;
|
|
1777
|
+
if (component.basis === "fixed") {
|
|
1778
|
+
rate = 0;
|
|
1779
|
+
amountCents = roundCents(baseAmountCents * shareFraction);
|
|
1780
|
+
} else {
|
|
1781
|
+
if (typeof component.rate !== "number") {
|
|
1782
|
+
skipped.push({
|
|
1783
|
+
componentKey: component.key,
|
|
1784
|
+
reason: "rate_missing"
|
|
1785
|
+
});
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
rate = component.rate;
|
|
1789
|
+
amountCents = calculateCommissionAmountCents(baseAmountCents, rate, shareFraction);
|
|
1790
|
+
}
|
|
1791
|
+
const occurrenceIndex = occurrenceCount;
|
|
1792
|
+
const dedupeKey = `${event.dedupeKey}:${termsRef}:${component.key}:${input.earnerId}:${occurrenceIndex}`;
|
|
1793
|
+
const priorByKey = await this.commissions.findByDedupeKey(dedupeKey);
|
|
1794
|
+
if (priorByKey) {
|
|
1795
|
+
existing.push(priorByKey);
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
const trace = {
|
|
1799
|
+
planKey: input.planKey,
|
|
1800
|
+
planVersion: input.planVersion,
|
|
1801
|
+
componentKey: component.key,
|
|
1802
|
+
basis: component.basis,
|
|
1803
|
+
baseAmountCents,
|
|
1804
|
+
rate,
|
|
1805
|
+
shareFraction,
|
|
1806
|
+
occurrenceIndex,
|
|
1807
|
+
earningEventId: event.id,
|
|
1808
|
+
roundingMode: "half_away_from_zero"
|
|
1809
|
+
};
|
|
1810
|
+
let commission;
|
|
1811
|
+
try {
|
|
1812
|
+
commission = await this.commissions.create({
|
|
1813
|
+
tenantId: event.tenantId,
|
|
1814
|
+
earnerId: input.earnerId,
|
|
1815
|
+
earningEventId: event.id,
|
|
1816
|
+
planKey: input.planKey,
|
|
1817
|
+
planVersion: input.planVersion,
|
|
1818
|
+
componentKey: component.key,
|
|
1819
|
+
termsSnapshotKind: input.termsSnapshotKind ?? "",
|
|
1820
|
+
termsSnapshotId: input.termsSnapshotId ?? "",
|
|
1821
|
+
basis: component.basis,
|
|
1822
|
+
baseAmountCents,
|
|
1823
|
+
rate,
|
|
1824
|
+
shareFraction,
|
|
1825
|
+
splitGroupId: input.splitGroupId ?? "",
|
|
1826
|
+
amountCents,
|
|
1827
|
+
currency: event.currency,
|
|
1828
|
+
status: "pending",
|
|
1829
|
+
clearingEndsAt: input.clearingDays !== void 0 ? new Date(event.occurredAt.getTime() + input.clearingDays * MS_PER_DAY) : null,
|
|
1830
|
+
sourceKind: event.sourceKind,
|
|
1831
|
+
sourceId: event.sourceId,
|
|
1832
|
+
calculationTrace: JSON.stringify(trace),
|
|
1833
|
+
dedupeKey
|
|
1834
|
+
});
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
if (error instanceof Error && error.message.includes("immutable audit rows")) {
|
|
1837
|
+
const winner = await this.commissions.list({
|
|
1838
|
+
where: { dedupeKey },
|
|
1839
|
+
limit: 1
|
|
1840
|
+
});
|
|
1841
|
+
if (winner[0]) {
|
|
1842
|
+
existing.push(winner[0]);
|
|
1843
|
+
continue;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
throw error;
|
|
1847
|
+
}
|
|
1848
|
+
created.push(commission);
|
|
1849
|
+
}
|
|
1850
|
+
return {
|
|
1851
|
+
created,
|
|
1852
|
+
skipped,
|
|
1853
|
+
existing
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Calendar-month addition (UTC). JS `setUTCMonth` semantics: day-of-month
|
|
1858
|
+
* overflow rolls into the next month (Jan 31 + 1 month → Mar 2/3), which
|
|
1859
|
+
* is acceptable for coarse recurrence windows.
|
|
1860
|
+
*/
|
|
1861
|
+
static addMonths(date, months) {
|
|
1862
|
+
const result = new Date(date.getTime());
|
|
1863
|
+
result.setUTCMonth(result.getUTCMonth() + months);
|
|
1864
|
+
return result;
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
//#endregion
|
|
1868
|
+
//#region src/commissions/services/CommissionPayoutService.ts
|
|
1869
|
+
var CommissionPayoutService = class CommissionPayoutService {
|
|
1870
|
+
constructor(deps) {
|
|
1871
|
+
this.deps = deps;
|
|
1872
|
+
}
|
|
1873
|
+
deps;
|
|
1874
|
+
static async create(classOptions = {}) {
|
|
1875
|
+
return new CommissionPayoutService({
|
|
1876
|
+
earners: await EarnerCollection.create(classOptions),
|
|
1877
|
+
commissions: await CommissionCollection.create(classOptions),
|
|
1878
|
+
adjustments: await CommissionAdjustmentCollection.create(classOptions),
|
|
1879
|
+
payouts: await CommissionPayoutCollection.create(classOptions)
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Create a settlement batch for one earner in one currency.
|
|
1884
|
+
*
|
|
1885
|
+
* Flow:
|
|
1886
|
+
* 1. **Idempotency + repair** — an existing payout with the (defaulted)
|
|
1887
|
+
* key is returned as `{ payout, created: false }`. A clean replay
|
|
1888
|
+
* touches nothing (new payable work is never swept into an existing
|
|
1889
|
+
* batch). A PENDING payout whose stored totals disagree with the rows
|
|
1890
|
+
* stamped with its id — the signature of an interrupted claim pass —
|
|
1891
|
+
* is repaired: the claim pass re-runs and the totals are reconciled
|
|
1892
|
+
* from the verified membership. Past `pending` the batch is frozen.
|
|
1893
|
+
* 2. **Gather** — payable unsettled commissions for the earner/currency,
|
|
1894
|
+
* plus unsettled adjustments whose parent commission is
|
|
1895
|
+
* earned/approved/payable/paid (same eligibility as the balance
|
|
1896
|
+
* service, so the batch settles exactly what the balance reports).
|
|
1897
|
+
* 3. **Refuse** — `netTotal <= 0` → `'nothing_payable'`;
|
|
1898
|
+
* `netTotal < threshold` (earner default, overridable) →
|
|
1899
|
+
* `'below_threshold'`. Nothing is minted or stamped on refusal.
|
|
1900
|
+
* 4. **Mint, claim, reconcile** — create the `pending` payout, then
|
|
1901
|
+
* CLAIM the gathered rows through the collections' conditional
|
|
1902
|
+
* `claimForPayout` (rows grabbed by another batch in the interim are
|
|
1903
|
+
* skipped, never double-claimed), and finally store totals computed
|
|
1904
|
+
* from the rows that were VERIFIABLY claimed — the payout's totals
|
|
1905
|
+
* are always reproducible from its member rows.
|
|
1906
|
+
*
|
|
1907
|
+
* Concurrency: claims are conditional with post-save verification, which
|
|
1908
|
+
* narrows but does not eliminate races between batches with different
|
|
1909
|
+
* keys (the collection layer exposes no cross-row transaction — the same
|
|
1910
|
+
* stance as commerce/ledgers compensation). Settlement runs are expected
|
|
1911
|
+
* to be single-writer per earner; totals are correct-by-construction from
|
|
1912
|
+
* claimed rows either way.
|
|
1913
|
+
*/
|
|
1914
|
+
async createPayoutBatch(input) {
|
|
1915
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
1916
|
+
const earner = await this.deps.earners.get({ id: input.earnerId });
|
|
1917
|
+
if (!earner) throw new Error(`CommissionPayoutService: earner '${input.earnerId}' not found`);
|
|
1918
|
+
const idempotencyKey = input.idempotencyKey ?? CommissionPayoutService.defaultIdempotencyKey(input.earnerId, input.currency, input.periodEnd ?? now);
|
|
1919
|
+
const existingPayout = await this.deps.payouts.findByIdempotencyKey(idempotencyKey);
|
|
1920
|
+
if (existingPayout) {
|
|
1921
|
+
if (existingPayout.isPending() && !await this.membershipConsistent(existingPayout)) return {
|
|
1922
|
+
...await this.claimAndReconcile(existingPayout, input),
|
|
1923
|
+
created: false
|
|
1924
|
+
};
|
|
1925
|
+
return {
|
|
1926
|
+
payout: existingPayout,
|
|
1927
|
+
created: false,
|
|
1928
|
+
settledCommissionIds: [],
|
|
1929
|
+
settledAdjustmentIds: []
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
const commissions = await this.deps.commissions.findPayableUnsettled(input.earnerId, input.currency);
|
|
1933
|
+
const eligibleAdjustments = await this.findEligibleUnsettledAdjustments(input.earnerId, input.currency);
|
|
1934
|
+
const commissionTotalCents = commissions.reduce((sum, c) => sum + c.amountCents, 0);
|
|
1935
|
+
const adjustmentTotalCents = eligibleAdjustments.reduce((sum, a) => sum + a.amountCents, 0);
|
|
1936
|
+
const netTotalCents = commissionTotalCents + adjustmentTotalCents;
|
|
1937
|
+
if (netTotalCents <= 0) return {
|
|
1938
|
+
payout: null,
|
|
1939
|
+
created: false,
|
|
1940
|
+
reason: "nothing_payable",
|
|
1941
|
+
settledCommissionIds: [],
|
|
1942
|
+
settledAdjustmentIds: []
|
|
1943
|
+
};
|
|
1944
|
+
if (netTotalCents < (input.minimumThresholdCents ?? earner.payoutThresholdCents)) return {
|
|
1945
|
+
payout: null,
|
|
1946
|
+
created: false,
|
|
1947
|
+
reason: "below_threshold",
|
|
1948
|
+
settledCommissionIds: [],
|
|
1949
|
+
settledAdjustmentIds: []
|
|
1950
|
+
};
|
|
1951
|
+
const minted = await this.deps.payouts.create({
|
|
1952
|
+
tenantId: earner.tenantId,
|
|
1953
|
+
earnerId: input.earnerId,
|
|
1954
|
+
currency: input.currency,
|
|
1955
|
+
periodStart: input.periodStart ?? null,
|
|
1956
|
+
periodEnd: input.periodEnd ?? null,
|
|
1957
|
+
payoutMethod: input.payoutMethod ?? earner.payoutMethod,
|
|
1958
|
+
status: "pending",
|
|
1959
|
+
commissionTotalCents,
|
|
1960
|
+
adjustmentTotalCents,
|
|
1961
|
+
totalAmountCents: netTotalCents,
|
|
1962
|
+
idempotencyKey
|
|
1963
|
+
});
|
|
1964
|
+
const payout = await this.deps.payouts.findByIdempotencyKey(idempotencyKey) ?? minted;
|
|
1965
|
+
return {
|
|
1966
|
+
...await this.claimAndReconcile(payout, input),
|
|
1967
|
+
created: true
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Whether a payout's stored totals are reproducible from the rows
|
|
1972
|
+
* actually stamped with its id — the invariant an interrupted claim pass
|
|
1973
|
+
* breaks. Clean replays short-circuit on this; repair runs only when it
|
|
1974
|
+
* fails.
|
|
1975
|
+
*/
|
|
1976
|
+
async membershipConsistent(payout) {
|
|
1977
|
+
const payoutId = payout.id ?? "";
|
|
1978
|
+
const members = await this.deps.commissions.findByPayout(payoutId);
|
|
1979
|
+
const memberAdjustments = await this.deps.adjustments.findByPayout(payoutId);
|
|
1980
|
+
const commissionTotalCents = members.reduce((sum, c) => sum + c.amountCents, 0);
|
|
1981
|
+
const adjustmentTotalCents = memberAdjustments.reduce((sum, a) => sum + a.amountCents, 0);
|
|
1982
|
+
return payout.commissionTotalCents === commissionTotalCents && payout.adjustmentTotalCents === adjustmentTotalCents && payout.totalAmountCents === commissionTotalCents + adjustmentTotalCents;
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Claim pass + totals reconciliation for a PENDING payout.
|
|
1986
|
+
*
|
|
1987
|
+
* The claim set is the union of rows already stamped with this payout
|
|
1988
|
+
* (an interrupted earlier pass) and the currently gathered eligible
|
|
1989
|
+
* rows. Claims go through the collections' conditional `claimForPayout`
|
|
1990
|
+
* (rows owned by another batch are skipped); totals are then recomputed
|
|
1991
|
+
* from the claimed rows and saved when they drift from what the payout
|
|
1992
|
+
* carries. In the pathological all-rows-raced-away case the payout keeps
|
|
1993
|
+
* zero totals and a note — auditable, never double-paid.
|
|
1994
|
+
*/
|
|
1995
|
+
async claimAndReconcile(payout, input) {
|
|
1996
|
+
const payoutId = payout.id ?? "";
|
|
1997
|
+
const previouslyClaimed = await this.deps.commissions.findByPayout(payoutId);
|
|
1998
|
+
const gathered = await this.deps.commissions.findPayableUnsettled(input.earnerId, input.currency);
|
|
1999
|
+
const commissionIds = [...new Set([...previouslyClaimed, ...gathered].map((c) => c.id).filter((id) => !!id))];
|
|
2000
|
+
const claimedCommissions = await this.deps.commissions.claimForPayout(commissionIds, payoutId);
|
|
2001
|
+
const previouslyClaimedAdjustments = await this.deps.adjustments.findByPayout(payoutId);
|
|
2002
|
+
const gatheredAdjustments = await this.findEligibleUnsettledAdjustments(input.earnerId, input.currency);
|
|
2003
|
+
const adjustmentIds = [...new Set([...previouslyClaimedAdjustments, ...gatheredAdjustments].map((a) => a.id).filter((id) => !!id))];
|
|
2004
|
+
const claimedAdjustments = await this.deps.adjustments.claimForPayout(adjustmentIds, payoutId);
|
|
2005
|
+
const commissionTotalCents = claimedCommissions.reduce((sum, c) => sum + c.amountCents, 0);
|
|
2006
|
+
const adjustmentTotalCents = claimedAdjustments.reduce((sum, a) => sum + a.amountCents, 0);
|
|
2007
|
+
const totalAmountCents = commissionTotalCents + adjustmentTotalCents;
|
|
2008
|
+
if (payout.commissionTotalCents !== commissionTotalCents || payout.adjustmentTotalCents !== adjustmentTotalCents || payout.totalAmountCents !== totalAmountCents) {
|
|
2009
|
+
payout.commissionTotalCents = commissionTotalCents;
|
|
2010
|
+
payout.adjustmentTotalCents = adjustmentTotalCents;
|
|
2011
|
+
payout.totalAmountCents = totalAmountCents;
|
|
2012
|
+
if (claimedCommissions.length === 0 && claimedAdjustments.length === 0) payout.notes = "no rows claimed (raced by a concurrent batch); nothing will be paid";
|
|
2013
|
+
await payout.save();
|
|
2014
|
+
}
|
|
2015
|
+
return {
|
|
2016
|
+
payout,
|
|
2017
|
+
settledCommissionIds: claimedCommissions.map((c) => c.id).filter((id) => !!id),
|
|
2018
|
+
settledAdjustmentIds: claimedAdjustments.map((a) => a.id).filter((id) => !!id)
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Complete a payout: flip the batch's settled commissions
|
|
2023
|
+
* `payable → paid` FIRST, then `payout.complete(paymentReference)`
|
|
2024
|
+
* (requires status `processing`). Ordering matters for recoverability —
|
|
2025
|
+
* if a member save fails mid-loop the payout is still `processing`, so a
|
|
2026
|
+
* retry finishes the remaining members (already-paid ones are skipped)
|
|
2027
|
+
* and then finalizes; the terminal transition never strands `payable`
|
|
2028
|
+
* members behind a `completed` payout. Adjustments carry no status —
|
|
2029
|
+
* stamping `payoutId` at batch time already settled them.
|
|
2030
|
+
*/
|
|
2031
|
+
async completePayout(payoutId, paymentReference, now = /* @__PURE__ */ new Date()) {
|
|
2032
|
+
const payout = await this.requirePayout(payoutId);
|
|
2033
|
+
if (!payout.isProcessing()) throw new Error(`CommissionPayout ${payout.id ?? "<new>"}: cannot complete from status '${payout.status}'`);
|
|
2034
|
+
if (!paymentReference) throw new Error(`CommissionPayout ${payout.id ?? "<new>"}: complete() requires a paymentReference`);
|
|
2035
|
+
const members = await this.deps.commissions.findByPayout(payoutId);
|
|
2036
|
+
for (const commission of members) if (commission.isPayable()) {
|
|
2037
|
+
commission.markPaid(now);
|
|
2038
|
+
await commission.save();
|
|
2039
|
+
}
|
|
2040
|
+
payout.complete(paymentReference, now);
|
|
2041
|
+
await payout.save();
|
|
2042
|
+
return payout;
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Fail a payout (`approved | processing → failed`). The batch's rows stay
|
|
2046
|
+
* stamped — after `resetFromFailed()` the SAME payout retries the SAME
|
|
2047
|
+
* rows; releasing the rows to a different batch would double-pay them if
|
|
2048
|
+
* the failed remittance later settled.
|
|
2049
|
+
*/
|
|
2050
|
+
async failPayout(payoutId, reason) {
|
|
2051
|
+
const payout = await this.requirePayout(payoutId);
|
|
2052
|
+
payout.fail(reason);
|
|
2053
|
+
await payout.save();
|
|
2054
|
+
return payout;
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Unsettled adjustments for the earner/currency whose parent commission
|
|
2058
|
+
* is earned/approved/payable/paid — the same eligibility rule the balance
|
|
2059
|
+
* service applies, so batches settle exactly what balances report.
|
|
2060
|
+
*/
|
|
2061
|
+
async findEligibleUnsettledAdjustments(earnerId, currency) {
|
|
2062
|
+
const unsettled = await this.deps.adjustments.findUnsettledByEarner(earnerId, currency);
|
|
2063
|
+
if (unsettled.length === 0) return unsettled;
|
|
2064
|
+
const parentIds = [...new Set(unsettled.map((a) => a.commissionId).filter(Boolean))];
|
|
2065
|
+
const parents = await this.deps.commissions.listByIds(parentIds);
|
|
2066
|
+
const parentStatusById = /* @__PURE__ */ new Map();
|
|
2067
|
+
for (const parent of parents) if (parent.id) parentStatusById.set(parent.id, parent.status);
|
|
2068
|
+
return unsettled.filter((adjustment) => {
|
|
2069
|
+
const parentStatus = parentStatusById.get(adjustment.commissionId);
|
|
2070
|
+
return parentStatus !== void 0 && ADJUSTMENT_SETTLEABLE_COMMISSION_STATUSES.includes(parentStatus);
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
async requirePayout(payoutId) {
|
|
2074
|
+
const payout = await this.deps.payouts.get({ id: payoutId });
|
|
2075
|
+
if (!payout) throw new Error(`CommissionPayoutService: payout '${payoutId}' not found`);
|
|
2076
|
+
return payout;
|
|
2077
|
+
}
|
|
2078
|
+
/** `${earnerId}:${currency}:${YYYY-MM-DD of periodEnd}` — see the input doc. */
|
|
2079
|
+
static defaultIdempotencyKey(earnerId, currency, periodEnd) {
|
|
2080
|
+
return `${earnerId}:${currency}:${periodEnd.toISOString().slice(0, 10)}`;
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
//#endregion
|
|
2084
|
+
//#region src/commissions/services/CommissionSettlementService.ts
|
|
2085
|
+
var CommissionSettlementService = class CommissionSettlementService {
|
|
2086
|
+
constructor(commissions) {
|
|
2087
|
+
this.commissions = commissions;
|
|
2088
|
+
}
|
|
2089
|
+
commissions;
|
|
2090
|
+
static async create(classOptions = {}) {
|
|
2091
|
+
return new CommissionSettlementService(await CommissionCollection.create(classOptions));
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Sweep the clearing window: every `pending` commission whose
|
|
2095
|
+
* `clearingEndsAt` is `<= now` — or whose `clearingEndsAt` is `null`
|
|
2096
|
+
* (null means NO clearing window applies, so the row is immediately
|
|
2097
|
+
* sweepable) — transitions to `earned` and is saved.
|
|
2098
|
+
*
|
|
2099
|
+
* @returns The commissions that were marked earned by this sweep.
|
|
2100
|
+
*/
|
|
2101
|
+
async sweepClearing(now = /* @__PURE__ */ new Date()) {
|
|
2102
|
+
const pending = await this.commissions.findByStatus("pending");
|
|
2103
|
+
const swept = [];
|
|
2104
|
+
for (const commission of pending) {
|
|
2105
|
+
const clearingEndsAt = commission.clearingEndsAt;
|
|
2106
|
+
if (clearingEndsAt !== null && clearingEndsAt.getTime() > now.getTime()) continue;
|
|
2107
|
+
commission.markEarned(now);
|
|
2108
|
+
await commission.save();
|
|
2109
|
+
swept.push(commission);
|
|
2110
|
+
}
|
|
2111
|
+
return swept;
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Approve `earned` commissions by id (`earned → approved`). Strict: a
|
|
2115
|
+
* missing id or a commission in any other status throws — the caller
|
|
2116
|
+
* names exact rows, so a mismatch is a bug worth surfacing, not skipping.
|
|
2117
|
+
*/
|
|
2118
|
+
async approveCommissions(ids, now = /* @__PURE__ */ new Date()) {
|
|
2119
|
+
return await this.transitionByIds(ids, (commission) => {
|
|
2120
|
+
commission.approve(now);
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Release `approved` commissions to `payable` by id. Strict — see
|
|
2125
|
+
* {@link approveCommissions}.
|
|
2126
|
+
*/
|
|
2127
|
+
async markPayable(ids, now = /* @__PURE__ */ new Date()) {
|
|
2128
|
+
return await this.transitionByIds(ids, (commission) => {
|
|
2129
|
+
commission.markPayable(now);
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Convenience chain: advance each commission from wherever it currently
|
|
2134
|
+
* sits up to `payable` (`pending → earned → approved → payable`), saving
|
|
2135
|
+
* after EACH step — the save-time guard only admits single-step edges, so
|
|
2136
|
+
* every intermediate state is persisted (each with its timestamp). Rows
|
|
2137
|
+
* already `payable` or `paid` are returned untouched (idempotent). Note
|
|
2138
|
+
* this deliberately bypasses the clearing window — it's the "operator
|
|
2139
|
+
* says pay these now" path.
|
|
2140
|
+
*/
|
|
2141
|
+
async settleUpToPayable(ids, now = /* @__PURE__ */ new Date()) {
|
|
2142
|
+
const updated = [];
|
|
2143
|
+
for (const id of ids) {
|
|
2144
|
+
const commission = await this.requireCommission(id);
|
|
2145
|
+
if (commission.isPending()) {
|
|
2146
|
+
commission.markEarned(now);
|
|
2147
|
+
await commission.save();
|
|
2148
|
+
}
|
|
2149
|
+
if (commission.isEarned()) {
|
|
2150
|
+
commission.approve(now);
|
|
2151
|
+
await commission.save();
|
|
2152
|
+
}
|
|
2153
|
+
if (commission.isApproved()) {
|
|
2154
|
+
commission.markPayable(now);
|
|
2155
|
+
await commission.save();
|
|
2156
|
+
}
|
|
2157
|
+
updated.push(commission);
|
|
2158
|
+
}
|
|
2159
|
+
return updated;
|
|
2160
|
+
}
|
|
2161
|
+
async transitionByIds(ids, transition) {
|
|
2162
|
+
const updated = [];
|
|
2163
|
+
for (const id of ids) {
|
|
2164
|
+
const commission = await this.requireCommission(id);
|
|
2165
|
+
const statusBefore = commission.status;
|
|
2166
|
+
transition(commission);
|
|
2167
|
+
if (commission.status !== statusBefore) await commission.save();
|
|
2168
|
+
updated.push(commission);
|
|
2169
|
+
}
|
|
2170
|
+
return updated;
|
|
2171
|
+
}
|
|
2172
|
+
async requireCommission(id) {
|
|
2173
|
+
const commission = await this.commissions.get({ id });
|
|
2174
|
+
if (!commission) throw new Error(`CommissionSettlementService: commission '${id}' not found`);
|
|
2175
|
+
return commission;
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
//#endregion
|
|
2179
|
+
export { CommissionAdjustment as A, EARNING_EVENT_KINDS as C, CommissionCollection as D, CommissionPayout as E, Commission as O, EARNER_STATUSES as S, CommissionPayoutCollection as T, COMMISSION_ADJUSTMENT_KINDS as _, amountToCents as a, COMMISSION_PLAN_STATUSES as b, roundCents as c, EarnerCollection as d, Earner as f, ADJUSTMENT_SETTLEABLE_COMMISSION_STATUSES as g, validateCommissionPlanComponents as h, CommissionBalanceService as i, CommissionAdjustmentCollection as k, EarningEventCollection as l, CommissionPlan as m, CommissionPayoutService as n, calculateCommissionAmountCents as o, CommissionPlanCollection as p, CommissionCalculationService as r, centsToAmount as s, CommissionSettlementService as t, EarningEvent as u, COMMISSION_BASES as v, PAYOUT_METHODS as w, COMMISSION_STATUSES as x, COMMISSION_PAYOUT_STATUSES as y };
|
|
2180
|
+
|
|
2181
|
+
//# sourceMappingURL=commissions-BCxqOKVM.js.map
|