@happyvertical/smrt-sales 0.57.2 → 0.57.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +70 -2
  2. package/agents/commissions.md +1 -1
  3. package/agents/referrals.md +1 -0
  4. package/dist/agreements/collections/AgreementCollection.d.ts +50 -0
  5. package/dist/agreements/collections/AgreementCollection.d.ts.map +1 -0
  6. package/dist/agreements/index.d.ts +2 -0
  7. package/dist/agreements/index.d.ts.map +1 -1
  8. package/dist/agreements/models/Agreement.d.ts +67 -0
  9. package/dist/agreements/models/Agreement.d.ts.map +1 -0
  10. package/dist/agreements/types.d.ts +32 -0
  11. package/dist/agreements/types.d.ts.map +1 -1
  12. package/dist/agreements.d.ts +2 -2
  13. package/dist/agreements.js +3 -3
  14. package/dist/chunks/__smrt-register__-DTUQW7wz.js +6 -0
  15. package/dist/chunks/__smrt-register__-DTUQW7wz.js.map +1 -0
  16. package/dist/chunks/{agreements-BB_tNU0U.js → agreements-jBiVmttD.js} +450 -4
  17. package/dist/chunks/agreements-jBiVmttD.js.map +1 -0
  18. package/dist/chunks/{commissions-Cs4MsxVg.js → commissions-Cip6QZ9Q.js} +350 -6
  19. package/dist/chunks/commissions-Cip6QZ9Q.js.map +1 -0
  20. package/dist/chunks/{referrals-CrFDsdq-.js → referrals-CYgrGkEo.js} +17 -3
  21. package/dist/chunks/{referrals-CrFDsdq-.js.map → referrals-CYgrGkEo.js.map} +1 -1
  22. package/dist/commissions/collections/CommissionAdjustmentOperationCollection.d.ts.map +1 -1
  23. package/dist/commissions/collections/CommissionPlanCollection.d.ts +5 -1
  24. package/dist/commissions/collections/CommissionPlanCollection.d.ts.map +1 -1
  25. package/dist/commissions/models/CommissionPlan.d.ts +20 -1
  26. package/dist/commissions/models/CommissionPlan.d.ts.map +1 -1
  27. package/dist/commissions/services/WaterfallCalculationService.d.ts +22 -0
  28. package/dist/commissions/services/WaterfallCalculationService.d.ts.map +1 -0
  29. package/dist/commissions/services/index.d.ts +1 -0
  30. package/dist/commissions/services/index.d.ts.map +1 -1
  31. package/dist/commissions/types.d.ts +100 -0
  32. package/dist/commissions/types.d.ts.map +1 -1
  33. package/dist/commissions.js +3 -3
  34. package/dist/crm.js +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +5 -5
  37. package/dist/manifest.json +1399 -514
  38. package/dist/referrals/services/ReferralQualificationService.d.ts +4 -3
  39. package/dist/referrals/services/ReferralQualificationService.d.ts.map +1 -1
  40. package/dist/referrals.js +2 -2
  41. package/dist/smrt-knowledge.json +985 -34
  42. package/package.json +6 -6
  43. package/dist/chunks/__smrt-register__-CNn8HXhK.js +0 -6
  44. package/dist/chunks/__smrt-register__-CNn8HXhK.js.map +0 -1
  45. package/dist/chunks/agreements-BB_tNU0U.js.map +0 -1
  46. package/dist/chunks/commissions-Cs4MsxVg.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, smrt } from "@happyvertical/smrt-core";
2
2
  import { TenantContextError, TenantScoped, assertTenantReadAllowed, getCurrentTenant, requireTenantId, tenantId } from "@happyvertical/smrt-tenancy";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { isDeepStrictEqual } from "node:util";
4
5
  //#region src/commissions/models/CommissionAdjustment.ts
5
6
  var __defProp$7 = Object.defineProperty;
6
7
  var __getOwnPropDesc$7 = Object.getOwnPropertyDescriptor;
@@ -265,7 +266,7 @@ var CommissionAdjustmentOperationCollection = class extends SmrtCollection {
265
266
  const inserted = await this.query(`INSERT INTO ${this.tableName} (
266
267
  id, slug, context, tenant_id, adjustment_id
267
268
  ) VALUES (?, ?, ?, ?, ?)
268
- ON CONFLICT (id) DO NOTHING
269
+ ON CONFLICT DO NOTHING
269
270
  RETURNING id`, [
270
271
  input.operationId,
271
272
  input.operationId,
@@ -1047,6 +1048,229 @@ var CommissionPayoutCollection = class extends SmrtCollection {
1047
1048
  }
1048
1049
  };
1049
1050
  //#endregion
1051
+ //#region src/commissions/services/WaterfallCalculationService.ts
1052
+ var WaterfallRecipientResolverRegistry = class {
1053
+ resolvers = /* @__PURE__ */ new Map();
1054
+ register(kind, resolver) {
1055
+ if (!kind) throw new Error("Waterfall recipient resolver kind is required");
1056
+ if (this.resolvers.has(kind)) throw new Error(`Waterfall recipient resolver '${kind}' is already registered`);
1057
+ this.resolvers.set(kind, resolver);
1058
+ }
1059
+ resolve(descriptor, event) {
1060
+ return this.resolvers.get(descriptor.kind)?.(descriptor, event) ?? null;
1061
+ }
1062
+ };
1063
+ function calculateWaterfall(plan, event, options = {}) {
1064
+ validateWaterfallPlan(plan);
1065
+ const planFingerprint = fingerprintPlan(plan);
1066
+ const calculationEvent = options.frozenInputs ? {
1067
+ grossAmountCents: options.frozenInputs.grossAmountCents,
1068
+ currency: options.frozenInputs.currency,
1069
+ facts: options.frozenInputs.facts,
1070
+ customBases: options.frozenInputs.customBases,
1071
+ applicationFeeSettledCents: options.frozenInputs.applicationFeeSettledCents
1072
+ } : event;
1073
+ validateEvent(plan, calculationEvent);
1074
+ if (options.frozenInputs && (options.frozenInputs.planKey !== plan.planKey || options.frozenInputs.planVersion !== plan.version || options.frozenInputs.planFingerprint !== planFingerprint)) throw new Error("Waterfall frozen inputs belong to a different plan version");
1075
+ const facts = options.frozenInputs?.facts ?? event.facts ?? {};
1076
+ const frozenInputs = {
1077
+ planKey: options.frozenInputs?.planKey ?? plan.planKey,
1078
+ planVersion: options.frozenInputs?.planVersion ?? plan.version,
1079
+ planFingerprint: options.frozenInputs?.planFingerprint ?? planFingerprint,
1080
+ currency: options.frozenInputs?.currency ?? event.currency,
1081
+ grossAmountCents: options.frozenInputs?.grossAmountCents ?? event.grossAmountCents,
1082
+ facts: { ...facts },
1083
+ customBases: { ...options.frozenInputs?.customBases ?? event.customBases },
1084
+ recipientByStep: Object.assign(/* @__PURE__ */ Object.create(null), Object.fromEntries(Object.entries(options.frozenInputs?.recipientByStep ?? {}).map(([key, recipient]) => [key, recipient ? freezeRecipient(recipient) : null]))),
1085
+ remainderSink: options.frozenInputs ? freezeRecipient(options.frozenInputs.remainderSink) : resolveSink(plan, calculationEvent, options),
1086
+ applicationFeeSettledCents: options.frozenInputs?.applicationFeeSettledCents ?? event.applicationFeeSettledCents ?? 0
1087
+ };
1088
+ assertWholeNonNegative(frozenInputs.applicationFeeSettledCents, "applicationFeeSettledCents");
1089
+ let remainingCents = calculationEvent.grossAmountCents;
1090
+ let redirectedToSinkCents = 0;
1091
+ let totalRecipientAllocatedCents = 0;
1092
+ let remainingFeeOffsetCents = frozenInputs.applicationFeeSettledCents;
1093
+ const steps = [];
1094
+ for (const step of plan.steps) {
1095
+ if (!conditionsMatch(step.conditions, facts)) {
1096
+ steps.push({
1097
+ key: step.key,
1098
+ baseCents: 0,
1099
+ amountCents: 0,
1100
+ recipient: null,
1101
+ redirectedToSinkCents: 0,
1102
+ applicationFeeOffsetCents: 0,
1103
+ payableCents: 0,
1104
+ skipped: "condition_not_met"
1105
+ });
1106
+ continue;
1107
+ }
1108
+ const baseCents = resolveBase(step, calculationEvent, remainingCents);
1109
+ const amountCents = resolveAmount(step, baseCents);
1110
+ if (amountCents > remainingCents) throw new Error(`Waterfall step '${step.key}' allocates ${amountCents} cents but only ${remainingCents} cents remain`);
1111
+ const recipient = resolveStepRecipient(step, calculationEvent, options, frozenInputs);
1112
+ let applicationFeeOffsetCents = 0;
1113
+ if (recipient && descriptorMatches(plan.applicationFeeOffsetRecipient, step.recipient)) {
1114
+ applicationFeeOffsetCents = Math.min(remainingFeeOffsetCents, amountCents);
1115
+ remainingFeeOffsetCents -= applicationFeeOffsetCents;
1116
+ }
1117
+ remainingCents -= amountCents;
1118
+ if (recipient) totalRecipientAllocatedCents += amountCents;
1119
+ else redirectedToSinkCents += amountCents;
1120
+ steps.push({
1121
+ key: step.key,
1122
+ baseCents,
1123
+ amountCents,
1124
+ recipient,
1125
+ redirectedToSinkCents: recipient ? 0 : amountCents,
1126
+ applicationFeeOffsetCents,
1127
+ payableCents: recipient ? amountCents - applicationFeeOffsetCents : 0
1128
+ });
1129
+ }
1130
+ if (remainingFeeOffsetCents !== 0) throw new Error("Waterfall application fee offset has no resolved configured recipient allocation");
1131
+ const totalSinkCents = remainingCents + redirectedToSinkCents;
1132
+ const closure = totalRecipientAllocatedCents + totalSinkCents - calculationEvent.grossAmountCents;
1133
+ if (closure !== 0) throw new Error(`Waterfall closure failed by ${closure} cents`);
1134
+ return { trace: {
1135
+ planKey: plan.planKey,
1136
+ planVersion: plan.version,
1137
+ currency: plan.currency,
1138
+ grossAmountCents: calculationEvent.grossAmountCents,
1139
+ steps,
1140
+ remainderSink: frozenInputs.remainderSink,
1141
+ remainderSinkCents: totalSinkCents,
1142
+ totalRecipientAllocatedCents,
1143
+ totalSinkCents,
1144
+ closureCents: 0,
1145
+ roundingMode: "half_away_from_zero",
1146
+ frozenInputs
1147
+ } };
1148
+ }
1149
+ function resolveSink(plan, event, options) {
1150
+ const sink = options.resolver?.resolve(plan.remainderSink, event) ?? null;
1151
+ if (!sink) throw new Error("Waterfall remainderSink must resolve to a recipient");
1152
+ return freezeRecipient(sink);
1153
+ }
1154
+ function resolveStepRecipient(step, event, options, frozen) {
1155
+ if (Object.hasOwn(frozen.recipientByStep, step.key)) {
1156
+ const existing = frozen.recipientByStep[step.key];
1157
+ return existing ? freezeRecipient(existing) : null;
1158
+ }
1159
+ const recipient = options.resolver?.resolve(step.recipient, event) ?? null;
1160
+ const frozenRecipient = recipient ? freezeRecipient(recipient) : null;
1161
+ Object.defineProperty(frozen.recipientByStep, step.key, {
1162
+ value: frozenRecipient,
1163
+ enumerable: true,
1164
+ writable: true,
1165
+ configurable: true
1166
+ });
1167
+ return frozenRecipient;
1168
+ }
1169
+ function freezeRecipient(recipient) {
1170
+ return {
1171
+ kind: recipient.kind,
1172
+ id: recipient.id,
1173
+ ...recipient.metadata ? { metadata: structuredClone(recipient.metadata) } : {}
1174
+ };
1175
+ }
1176
+ function resolveBase(step, event, remainderCents) {
1177
+ if (step.base === "gross") return event.grossAmountCents;
1178
+ if (step.base === "previous_remainder") return remainderCents;
1179
+ const value = event.customBases?.[step.customBaseKey ?? ""];
1180
+ if (value === void 0) throw new Error(`Waterfall step '${step.key}' requires custom base '${step.customBaseKey ?? ""}'`);
1181
+ assertWholeNonNegative(value, `custom base '${step.customBaseKey}'`);
1182
+ return value;
1183
+ }
1184
+ function resolveAmount(step, baseCents) {
1185
+ const raw = step.amount.kind === "percent" ? roundPercentCents(baseCents, step.amount.rate) : step.amount.amountCents;
1186
+ return Math.min(step.capCents ?? Number.MAX_SAFE_INTEGER, Math.max(step.floorCents ?? 0, raw));
1187
+ }
1188
+ function roundPercentCents(baseCents, rate) {
1189
+ const [numerator, denominator] = decimalFraction(rate);
1190
+ const rounded = (BigInt(baseCents) * numerator + denominator / 2n) / denominator;
1191
+ if (rounded > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Waterfall percent allocation exceeds safe integer cents");
1192
+ return Number(rounded);
1193
+ }
1194
+ function decimalFraction(value) {
1195
+ const [coefficient, exponentText] = value.toString().toLowerCase().split("e");
1196
+ const exponent = exponentText ? Number(exponentText) : 0;
1197
+ const [whole, fractional = ""] = coefficient.split(".");
1198
+ const digits = `${whole}${fractional}`;
1199
+ const scale = fractional.length - exponent;
1200
+ if (scale <= 0) return [BigInt(digits) * 10n ** BigInt(-scale), 1n];
1201
+ return [BigInt(digits), 10n ** BigInt(scale)];
1202
+ }
1203
+ function fingerprintPlan(plan) {
1204
+ return JSON.stringify({
1205
+ tenantId: plan.tenantId ?? null,
1206
+ planKey: plan.planKey,
1207
+ version: plan.version,
1208
+ currency: plan.currency,
1209
+ steps: plan.steps,
1210
+ remainderSink: plan.remainderSink,
1211
+ applicationFeeOffsetRecipient: plan.applicationFeeOffsetRecipient ?? null
1212
+ });
1213
+ }
1214
+ function conditionsMatch(conditions, facts) {
1215
+ if (!conditions) return true;
1216
+ return [
1217
+ "product",
1218
+ "zone",
1219
+ "source",
1220
+ "member"
1221
+ ].every((key) => conditions[key] === void 0 || conditions[key].includes(facts[key] ?? ""));
1222
+ }
1223
+ function descriptorMatches(a, b) {
1224
+ return a !== void 0 && isDeepStrictEqual(a, b);
1225
+ }
1226
+ function validateWaterfallPlan(plan) {
1227
+ if (!plan.planKey || !Number.isInteger(plan.version) || plan.version < 1) throw new Error("Waterfall plan requires a planKey and positive integer version");
1228
+ const keys = /* @__PURE__ */ new Set();
1229
+ for (const step of plan.steps) {
1230
+ if (typeof step.key !== "string" || !step.key || keys.has(step.key)) throw new Error(`Waterfall step keys must be non-empty strings and unique: '${step.key}'`);
1231
+ keys.add(step.key);
1232
+ if ([
1233
+ "__proto__",
1234
+ "constructor",
1235
+ "prototype"
1236
+ ].includes(step.key)) throw new Error(`Waterfall step '${step.key}' uses a reserved key`);
1237
+ if (!step.recipient?.kind) throw new Error(`Waterfall step '${step.key}' requires a recipient descriptor`);
1238
+ if (step.base === "custom" && !step.customBaseKey) throw new Error(`Waterfall step '${step.key}' requires customBaseKey for custom base`);
1239
+ if (![
1240
+ "gross",
1241
+ "previous_remainder",
1242
+ "custom"
1243
+ ].includes(step.base)) throw new Error(`Waterfall step '${step.key}' has an invalid base`);
1244
+ if (!["percent", "fixed"].includes(step.amount.kind)) throw new Error(`Waterfall step '${step.key}' has an invalid amount kind`);
1245
+ if (step.conditions !== void 0) {
1246
+ if (!step.conditions || typeof step.conditions !== "object" || Array.isArray(step.conditions)) throw new Error(`Waterfall step '${step.key}' conditions must be an object`);
1247
+ for (const [key, values] of Object.entries(step.conditions)) {
1248
+ if (![
1249
+ "product",
1250
+ "zone",
1251
+ "source",
1252
+ "member"
1253
+ ].includes(key)) throw new Error(`Waterfall step '${step.key}' has an unknown condition '${key}'`);
1254
+ if (!Array.isArray(values) || values.some((value) => typeof value !== "string" || !value)) throw new Error(`Waterfall step '${step.key}' condition '${key}' must be an array of non-empty strings`);
1255
+ }
1256
+ }
1257
+ if (step.amount.kind === "percent" && (!Number.isFinite(step.amount.rate) || step.amount.rate < 0 || step.amount.rate > 1)) throw new Error(`Waterfall step '${step.key}' percent rate must be in [0, 1]`);
1258
+ if (step.amount.kind === "fixed") assertWholeNonNegative(step.amount.amountCents, `Waterfall step '${step.key}' fixed amount`);
1259
+ if (step.floorCents !== void 0) assertWholeNonNegative(step.floorCents, `Waterfall step '${step.key}' floor`);
1260
+ if (step.capCents !== void 0) assertWholeNonNegative(step.capCents, `Waterfall step '${step.key}' cap`);
1261
+ if (step.floorCents !== void 0 && step.capCents !== void 0 && step.floorCents > step.capCents) throw new Error(`Waterfall step '${step.key}' floor cannot exceed cap`);
1262
+ }
1263
+ if (!plan.remainderSink?.kind) throw new Error("Waterfall plan requires a remainderSink descriptor");
1264
+ if (plan.applicationFeeOffsetRecipient && !plan.applicationFeeOffsetRecipient.kind) throw new Error("Waterfall application fee offset requires a recipient descriptor");
1265
+ }
1266
+ function validateEvent(plan, event) {
1267
+ if (plan.currency !== event.currency) throw new Error("Waterfall plan and event currency must match; no FX is performed");
1268
+ assertWholeNonNegative(event.grossAmountCents, "grossAmountCents");
1269
+ }
1270
+ function assertWholeNonNegative(value, label) {
1271
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a non-negative safe integer cents value`);
1272
+ }
1273
+ //#endregion
1050
1274
  //#region src/commissions/types.ts
1051
1275
  var EARNER_STATUSES = [
1052
1276
  "pending",
@@ -1174,6 +1398,8 @@ var CommissionPlan = class extends SmrtObject {
1174
1398
  status = "draft";
1175
1399
  /** When this version takes effect. Frozen once the plan activates. */
1176
1400
  effectiveFrom = null;
1401
+ /** Optional exclusive end of this version's effective period. */
1402
+ effectiveTo = null;
1177
1403
  /** ISO 4217 currency the plan's terms are denominated in. */
1178
1404
  currency = "USD";
1179
1405
  /**
@@ -1182,6 +1408,12 @@ var CommissionPlan = class extends SmrtObject {
1182
1408
  * {@link setComponents} (the setter validates).
1183
1409
  */
1184
1410
  components = "[]";
1411
+ /** Ordered v2 waterfall steps. Empty keeps the v1 component contract. */
1412
+ steps = "[]";
1413
+ /** V2 residual recipient descriptor. */
1414
+ remainderSink = "{}";
1415
+ /** V2 checkout-fee offset recipient descriptor. */
1416
+ applicationFeeOffsetRecipient = "{}";
1185
1417
  /** Additional metadata as a JSON string. */
1186
1418
  metadata = "{}";
1187
1419
  constructor(options = {}) {
@@ -1193,8 +1425,12 @@ var CommissionPlan = class extends SmrtObject {
1193
1425
  if (options.description !== void 0) this.description = options.description;
1194
1426
  if (options.status !== void 0) this.status = options.status;
1195
1427
  if (options.effectiveFrom !== void 0) this.effectiveFrom = CommissionPlan.coerceDate(options.effectiveFrom);
1428
+ if (options.effectiveTo !== void 0) this.effectiveTo = CommissionPlan.coerceDate(options.effectiveTo);
1196
1429
  if (options.currency !== void 0) this.currency = options.currency;
1197
1430
  if (options.components !== void 0) this.components = options.components;
1431
+ if (options.steps !== void 0) this.steps = options.steps;
1432
+ if (options.remainderSink !== void 0) this.remainderSink = options.remainderSink;
1433
+ if (options.applicationFeeOffsetRecipient !== void 0) this.applicationFeeOffsetRecipient = options.applicationFeeOffsetRecipient;
1198
1434
  if (options.metadata !== void 0) this.metadata = options.metadata;
1199
1435
  }
1200
1436
  /**
@@ -1207,6 +1443,7 @@ var CommissionPlan = class extends SmrtObject {
1207
1443
  async initialize() {
1208
1444
  await super.initialize();
1209
1445
  this.effectiveFrom = CommissionPlan.coerceDate(this.effectiveFrom);
1446
+ this.effectiveTo = CommissionPlan.coerceDate(this.effectiveTo);
1210
1447
  if (await this.isSaved()) {
1211
1448
  loadedPlanStatus.set(this, this.status);
1212
1449
  if (this.status !== "draft") frozenPlanSnapshot.set(this, this.serializeFrozenSnapshot());
@@ -1237,6 +1474,45 @@ var CommissionPlan = class extends SmrtObject {
1237
1474
  validateCommissionPlanComponents(components);
1238
1475
  this.components = JSON.stringify(components);
1239
1476
  }
1477
+ getSteps() {
1478
+ try {
1479
+ const parsed = JSON.parse(this.steps);
1480
+ return Array.isArray(parsed) ? parsed : [];
1481
+ } catch {
1482
+ return [];
1483
+ }
1484
+ }
1485
+ setSteps(steps) {
1486
+ this.steps = JSON.stringify(steps);
1487
+ }
1488
+ getRemainderSink() {
1489
+ try {
1490
+ const parsed = JSON.parse(this.remainderSink);
1491
+ return parsed && typeof parsed === "object" ? parsed : null;
1492
+ } catch {
1493
+ return null;
1494
+ }
1495
+ }
1496
+ setRemainderSink(sink) {
1497
+ this.remainderSink = JSON.stringify(sink);
1498
+ }
1499
+ /** Return v2 terms when this plan has waterfall steps; v1 plans remain v1. */
1500
+ getWaterfallPlan() {
1501
+ const steps = this.getSteps();
1502
+ const remainderSink = this.getRemainderSink();
1503
+ if (steps.length === 0) return null;
1504
+ if (!remainderSink) throw new Error("CommissionPlan waterfall steps require a remainder sink");
1505
+ const applicationFeeOffsetRecipient = this.getRecipientDescriptor(this.applicationFeeOffsetRecipient);
1506
+ return {
1507
+ planKey: this.planKey,
1508
+ version: this.version,
1509
+ tenantId: this.tenantId,
1510
+ currency: this.currency,
1511
+ steps,
1512
+ remainderSink,
1513
+ ...applicationFeeOffsetRecipient ? { applicationFeeOffsetRecipient } : {}
1514
+ };
1515
+ }
1240
1516
  /** Parse {@link metadata}; returns `{}` on empty/invalid JSON. */
1241
1517
  getMetadata() {
1242
1518
  if (!this.metadata) return {};
@@ -1258,6 +1534,8 @@ var CommissionPlan = class extends SmrtObject {
1258
1534
  activate() {
1259
1535
  if (this.status !== "draft") throw new Error(`CommissionPlan ${this.planKey}@${this.version}: cannot activate from status '${this.status}'`);
1260
1536
  validateCommissionPlanComponents(this.getComponents());
1537
+ this.validateEffectiveWindow();
1538
+ this.validateWaterfallTerms();
1261
1539
  this.status = "active";
1262
1540
  }
1263
1541
  /** Transition `active → superseded` (a newer version took over). */
@@ -1290,7 +1568,11 @@ var CommissionPlan = class extends SmrtObject {
1290
1568
  this.assertStatusTransition(prior);
1291
1569
  this.assertFrozenIdentityUnchanged();
1292
1570
  await this.assertNaturalKeyNotTaken();
1293
- if (this.status === "active") validateCommissionPlanComponents(this.getComponents());
1571
+ if (this.status === "active") {
1572
+ validateCommissionPlanComponents(this.getComponents());
1573
+ this.validateEffectiveWindow();
1574
+ this.validateWaterfallTerms();
1575
+ }
1294
1576
  const result = await super.save();
1295
1577
  loadedPlanStatus.set(this, this.status);
1296
1578
  if (this.status !== "draft" && !frozenPlanSnapshot.has(this)) frozenPlanSnapshot.set(this, this.serializeFrozenSnapshot());
@@ -1342,9 +1624,63 @@ var CommissionPlan = class extends SmrtObject {
1342
1624
  version: this.version,
1343
1625
  currency: this.currency,
1344
1626
  components: this.components,
1345
- effectiveFrom: this.effectiveFrom ? this.effectiveFrom.toISOString() : null
1627
+ effectiveFrom: this.effectiveFrom ? this.effectiveFrom.toISOString() : null,
1628
+ effectiveTo: this.effectiveTo ? this.effectiveTo.toISOString() : null,
1629
+ steps: this.steps,
1630
+ remainderSink: this.remainderSink,
1631
+ applicationFeeOffsetRecipient: this.applicationFeeOffsetRecipient
1346
1632
  });
1347
1633
  }
1634
+ validateWaterfallTerms() {
1635
+ let steps;
1636
+ try {
1637
+ steps = JSON.parse(this.steps);
1638
+ } catch {
1639
+ throw new Error("CommissionPlan waterfall steps must be valid JSON");
1640
+ }
1641
+ if (!Array.isArray(steps)) throw new Error("CommissionPlan waterfall steps must be an array");
1642
+ if (steps.length === 0) return;
1643
+ if (this.getComponents().length > 0) throw new Error("CommissionPlan cannot combine v1 components with v2 waterfall steps");
1644
+ const remainderSink = this.parseRequiredDescriptor(this.remainderSink, "remainder sink");
1645
+ const applicationFeeOffsetRecipient = this.parseOptionalDescriptor(this.applicationFeeOffsetRecipient, "application fee offset recipient");
1646
+ validateWaterfallPlan({
1647
+ planKey: this.planKey,
1648
+ version: this.version,
1649
+ tenantId: this.tenantId,
1650
+ currency: this.currency,
1651
+ steps,
1652
+ remainderSink,
1653
+ ...applicationFeeOffsetRecipient ? { applicationFeeOffsetRecipient } : {}
1654
+ });
1655
+ }
1656
+ parseRequiredDescriptor(serialized, label) {
1657
+ const descriptor = this.parseOptionalDescriptor(serialized, label);
1658
+ if (!descriptor?.kind) throw new Error(`CommissionPlan waterfall ${label} is required`);
1659
+ return descriptor;
1660
+ }
1661
+ parseOptionalDescriptor(serialized, label) {
1662
+ let parsed;
1663
+ try {
1664
+ parsed = JSON.parse(serialized);
1665
+ } catch {
1666
+ throw new Error(`CommissionPlan waterfall ${label} must be valid JSON`);
1667
+ }
1668
+ if (parsed === null) return null;
1669
+ if (typeof parsed !== "object" || Array.isArray(parsed) || Object.getPrototypeOf(parsed) !== Object.prototype) throw new Error(`CommissionPlan waterfall ${label} must be an object`);
1670
+ return Object.keys(parsed).length === 0 ? null : parsed;
1671
+ }
1672
+ validateEffectiveWindow() {
1673
+ if (this.effectiveFrom && this.effectiveTo && this.effectiveTo <= this.effectiveFrom) throw new Error("CommissionPlan effectiveTo must be later than effectiveFrom");
1674
+ }
1675
+ getRecipientDescriptor(serialized) {
1676
+ try {
1677
+ const parsed = JSON.parse(serialized);
1678
+ if (!parsed || typeof parsed !== "object") return null;
1679
+ return Object.keys(parsed).length === 0 ? null : parsed;
1680
+ } catch {
1681
+ return null;
1682
+ }
1683
+ }
1348
1684
  static coerceDate(value) {
1349
1685
  if (value == null) return null;
1350
1686
  if (value instanceof Date) return value;
@@ -1405,17 +1741,19 @@ var CommissionPlanCollection = class extends SmrtCollection {
1405
1741
  status: "active"
1406
1742
  },
1407
1743
  orderBy: "version DESC"
1408
- })).find((plan) => plan.effectiveFrom === null || plan.effectiveFrom <= at) ?? null;
1744
+ })).find((plan) => (plan.effectiveFrom === null || plan.effectiveFrom <= at) && (plan.effectiveTo === null || plan.effectiveTo > at)) ?? null;
1409
1745
  const atIso = at.toISOString();
1410
1746
  if (tenantId === null) return (await this.query(`SELECT * FROM ${this.tableName}
1411
1747
  WHERE tenant_id IS NULL
1412
1748
  AND plan_key = ?
1413
1749
  AND status = ?
1414
1750
  AND (effective_from IS NULL OR effective_from <= ?)
1751
+ AND (effective_to IS NULL OR effective_to > ?)
1415
1752
  ORDER BY version DESC
1416
1753
  LIMIT 1`, [
1417
1754
  planKey,
1418
1755
  "active",
1756
+ atIso,
1419
1757
  atIso
1420
1758
  ], { allowRawOnTenantScoped: true }))[0] ?? null;
1421
1759
  assertTenantReadAllowed(tenantId, "CommissionPlanCollection.latestActiveByKey");
@@ -1424,12 +1762,14 @@ var CommissionPlanCollection = class extends SmrtCollection {
1424
1762
  AND plan_key = ?
1425
1763
  AND status = ?
1426
1764
  AND (effective_from IS NULL OR effective_from <= ?)
1765
+ AND (effective_to IS NULL OR effective_to > ?)
1427
1766
  ORDER BY CASE WHEN tenant_id = ? THEN 0 ELSE 1 END, version DESC
1428
1767
  LIMIT 1`, [
1429
1768
  tenantId,
1430
1769
  planKey,
1431
1770
  "active",
1432
1771
  atIso,
1772
+ atIso,
1433
1773
  tenantId
1434
1774
  ], { allowRawOnTenantScoped: true }))[0] ?? null;
1435
1775
  }
@@ -1456,7 +1796,11 @@ var CommissionPlanCollection = class extends SmrtCollection {
1456
1796
  description: changes.description ?? latest.description,
1457
1797
  currency: changes.currency ?? latest.currency,
1458
1798
  effectiveFrom: changes.effectiveFrom !== void 0 ? changes.effectiveFrom : latest.effectiveFrom,
1799
+ effectiveTo: changes.effectiveTo !== void 0 ? changes.effectiveTo : null,
1459
1800
  components: changes.components !== void 0 ? JSON.stringify(changes.components) : latest.components,
1801
+ steps: changes.steps !== void 0 ? JSON.stringify(changes.steps) : latest.steps,
1802
+ remainderSink: changes.remainderSink !== void 0 ? JSON.stringify(changes.remainderSink) : latest.remainderSink,
1803
+ applicationFeeOffsetRecipient: changes.applicationFeeOffsetRecipient !== void 0 ? JSON.stringify(changes.applicationFeeOffsetRecipient) : latest.applicationFeeOffsetRecipient,
1460
1804
  metadata: changes.metadata !== void 0 ? JSON.stringify(changes.metadata) : latest.metadata
1461
1805
  });
1462
1806
  }
@@ -3616,6 +3960,6 @@ var EarnerAttributionService = class EarnerAttributionService {
3616
3960
  }
3617
3961
  };
3618
3962
  //#endregion
3619
- export { EARNING_EVENT_KINDS as A, COMMISSION_ADJUSTMENT_KINDS as C, COMMISSION_STATUSES as D, COMMISSION_PLAN_STATUSES as E, Commission as F, CommissionAdjustmentOperationCollection as I, CommissionAdjustmentOperation as L, CommissionPayoutCollection as M, CommissionPayout as N, EARNER_SOURCE_ATTRIBUTION_STATUSES as O, CommissionCollection as P, CommissionAdjustmentCollection as R, ADJUSTMENT_SETTLEABLE_COMMISSION_STATUSES as S, COMMISSION_PAYOUT_STATUSES as T, EarnerCollection as _, CommissionBalanceService as a, CommissionPlan as b, CommissionAdjustmentValidationError as c, centsToAmount as d, roundCents as f, EarnerSourceAttribution as g, EarnerSourceAttributionCollection as h, CommissionCalculationService as i, PAYOUT_METHODS as j, EARNER_STATUSES as k, amountToCents as l, EarningEvent as m, CommissionSettlementService as n, CommissionAdjustmentReplayConflictError as o, EarningEventCollection as p, CommissionPayoutService as r, CommissionAdjustmentService as s, EarnerAttributionService as t, calculateCommissionAmountCents as u, Earner as v, COMMISSION_BASES as w, validateCommissionPlanComponents as x, CommissionPlanCollection as y, CommissionAdjustment as z };
3963
+ export { EARNING_EVENT_KINDS as A, CommissionAdjustmentOperation as B, COMMISSION_ADJUSTMENT_KINDS as C, COMMISSION_STATUSES as D, COMMISSION_PLAN_STATUSES as E, CommissionPayoutCollection as F, CommissionAdjustment as H, CommissionPayout as I, CommissionCollection as L, WaterfallRecipientResolverRegistry as M, calculateWaterfall as N, EARNER_SOURCE_ATTRIBUTION_STATUSES as O, validateWaterfallPlan as P, Commission as R, ADJUSTMENT_SETTLEABLE_COMMISSION_STATUSES as S, COMMISSION_PAYOUT_STATUSES as T, CommissionAdjustmentCollection as V, EarnerCollection as _, CommissionBalanceService as a, CommissionPlan as b, CommissionAdjustmentValidationError as c, centsToAmount as d, roundCents as f, EarnerSourceAttribution as g, EarnerSourceAttributionCollection as h, CommissionCalculationService as i, PAYOUT_METHODS as j, EARNER_STATUSES as k, amountToCents as l, EarningEvent as m, CommissionSettlementService as n, CommissionAdjustmentReplayConflictError as o, EarningEventCollection as p, CommissionPayoutService as r, CommissionAdjustmentService as s, EarnerAttributionService as t, calculateCommissionAmountCents as u, Earner as v, COMMISSION_BASES as w, validateCommissionPlanComponents as x, CommissionPlanCollection as y, CommissionAdjustmentOperationCollection as z };
3620
3964
 
3621
- //# sourceMappingURL=commissions-Cs4MsxVg.js.map
3965
+ //# sourceMappingURL=commissions-Cip6QZ9Q.js.map