@happyvertical/smrt-sales 0.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/AGENTS.md +87 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/__smrt-register__.d.ts +2 -0
  6. package/dist/__smrt-register__.d.ts.map +1 -0
  7. package/dist/chunks/__smrt-register__-kWdT7LOt.js +6 -0
  8. package/dist/chunks/__smrt-register__-kWdT7LOt.js.map +1 -0
  9. package/dist/chunks/commissions-BCxqOKVM.js +2181 -0
  10. package/dist/chunks/commissions-BCxqOKVM.js.map +1 -0
  11. package/dist/chunks/crm-DwEz7E2r.js +1428 -0
  12. package/dist/chunks/crm-DwEz7E2r.js.map +1 -0
  13. package/dist/chunks/referrals-BY47p70T.js +2880 -0
  14. package/dist/chunks/referrals-BY47p70T.js.map +1 -0
  15. package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts +26 -0
  16. package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts.map +1 -0
  17. package/dist/commissions/collections/CommissionCollection.d.ts +43 -0
  18. package/dist/commissions/collections/CommissionCollection.d.ts.map +1 -0
  19. package/dist/commissions/collections/CommissionPayoutCollection.d.ts +19 -0
  20. package/dist/commissions/collections/CommissionPayoutCollection.d.ts.map +1 -0
  21. package/dist/commissions/collections/CommissionPlanCollection.d.ts +46 -0
  22. package/dist/commissions/collections/CommissionPlanCollection.d.ts.map +1 -0
  23. package/dist/commissions/collections/EarnerCollection.d.ts +14 -0
  24. package/dist/commissions/collections/EarnerCollection.d.ts.map +1 -0
  25. package/dist/commissions/collections/EarningEventCollection.d.ts +27 -0
  26. package/dist/commissions/collections/EarningEventCollection.d.ts.map +1 -0
  27. package/dist/commissions/collections/index.d.ts +11 -0
  28. package/dist/commissions/collections/index.d.ts.map +1 -0
  29. package/dist/commissions/index.d.ts +20 -0
  30. package/dist/commissions/index.d.ts.map +1 -0
  31. package/dist/commissions/models/Commission.d.ts +150 -0
  32. package/dist/commissions/models/Commission.d.ts.map +1 -0
  33. package/dist/commissions/models/CommissionAdjustment.d.ts +62 -0
  34. package/dist/commissions/models/CommissionAdjustment.d.ts.map +1 -0
  35. package/dist/commissions/models/CommissionPayout.d.ts +119 -0
  36. package/dist/commissions/models/CommissionPayout.d.ts.map +1 -0
  37. package/dist/commissions/models/CommissionPlan.d.ts +117 -0
  38. package/dist/commissions/models/CommissionPlan.d.ts.map +1 -0
  39. package/dist/commissions/models/Earner.d.ts +49 -0
  40. package/dist/commissions/models/Earner.d.ts.map +1 -0
  41. package/dist/commissions/models/EarningEvent.d.ts +89 -0
  42. package/dist/commissions/models/EarningEvent.d.ts.map +1 -0
  43. package/dist/commissions/models/index.d.ts +11 -0
  44. package/dist/commissions/models/index.d.ts.map +1 -0
  45. package/dist/commissions/money.d.ts +42 -0
  46. package/dist/commissions/money.d.ts.map +1 -0
  47. package/dist/commissions/services/CommissionBalanceService.d.ts +14 -0
  48. package/dist/commissions/services/CommissionBalanceService.d.ts.map +1 -0
  49. package/dist/commissions/services/CommissionCalculationService.d.ts +142 -0
  50. package/dist/commissions/services/CommissionCalculationService.d.ts.map +1 -0
  51. package/dist/commissions/services/CommissionPayoutService.d.ts +134 -0
  52. package/dist/commissions/services/CommissionPayoutService.d.ts.map +1 -0
  53. package/dist/commissions/services/CommissionSettlementService.d.ts +42 -0
  54. package/dist/commissions/services/CommissionSettlementService.d.ts.map +1 -0
  55. package/dist/commissions/services/index.d.ts +9 -0
  56. package/dist/commissions/services/index.d.ts.map +1 -0
  57. package/dist/commissions/types.d.ts +243 -0
  58. package/dist/commissions/types.d.ts.map +1 -0
  59. package/dist/commissions.d.ts +2 -0
  60. package/dist/commissions.d.ts.map +1 -0
  61. package/dist/commissions.js +3 -0
  62. package/dist/crm/collections/LeadCollection.d.ts +79 -0
  63. package/dist/crm/collections/LeadCollection.d.ts.map +1 -0
  64. package/dist/crm/collections/OpportunityCollection.d.ts +42 -0
  65. package/dist/crm/collections/OpportunityCollection.d.ts.map +1 -0
  66. package/dist/crm/collections/OpportunityConversionCollection.d.ts +26 -0
  67. package/dist/crm/collections/OpportunityConversionCollection.d.ts.map +1 -0
  68. package/dist/crm/collections/PipelineDefinitionCollection.d.ts +46 -0
  69. package/dist/crm/collections/PipelineDefinitionCollection.d.ts.map +1 -0
  70. package/dist/crm/collections/PipelineStageCollection.d.ts +22 -0
  71. package/dist/crm/collections/PipelineStageCollection.d.ts.map +1 -0
  72. package/dist/crm/collections/SalesActivityCollection.d.ts +24 -0
  73. package/dist/crm/collections/SalesActivityCollection.d.ts.map +1 -0
  74. package/dist/crm/collections/SalesRepresentativeCollection.d.ts +19 -0
  75. package/dist/crm/collections/SalesRepresentativeCollection.d.ts.map +1 -0
  76. package/dist/crm/index.d.ts +27 -0
  77. package/dist/crm/index.d.ts.map +1 -0
  78. package/dist/crm/models/Lead.d.ts +135 -0
  79. package/dist/crm/models/Lead.d.ts.map +1 -0
  80. package/dist/crm/models/Opportunity.d.ts +111 -0
  81. package/dist/crm/models/Opportunity.d.ts.map +1 -0
  82. package/dist/crm/models/OpportunityConversion.d.ts +66 -0
  83. package/dist/crm/models/OpportunityConversion.d.ts.map +1 -0
  84. package/dist/crm/models/PipelineDefinition.d.ts +58 -0
  85. package/dist/crm/models/PipelineDefinition.d.ts.map +1 -0
  86. package/dist/crm/models/PipelineStage.d.ts +66 -0
  87. package/dist/crm/models/PipelineStage.d.ts.map +1 -0
  88. package/dist/crm/models/SalesActivity.d.ts +76 -0
  89. package/dist/crm/models/SalesActivity.d.ts.map +1 -0
  90. package/dist/crm/models/SalesRepresentative.d.ts +60 -0
  91. package/dist/crm/models/SalesRepresentative.d.ts.map +1 -0
  92. package/dist/crm/types.d.ts +228 -0
  93. package/dist/crm/types.d.ts.map +1 -0
  94. package/dist/crm.d.ts +2 -0
  95. package/dist/crm.d.ts.map +1 -0
  96. package/dist/crm.js +3 -0
  97. package/dist/index.d.ts +4 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +5 -0
  100. package/dist/manifest.json +9340 -0
  101. package/dist/referrals/collections/AttributionExceptionCollection.d.ts +17 -0
  102. package/dist/referrals/collections/AttributionExceptionCollection.d.ts.map +1 -0
  103. package/dist/referrals/collections/AttributionPolicyCollection.d.ts +50 -0
  104. package/dist/referrals/collections/AttributionPolicyCollection.d.ts.map +1 -0
  105. package/dist/referrals/collections/ReferralAgreementCollection.d.ts +48 -0
  106. package/dist/referrals/collections/ReferralAgreementCollection.d.ts.map +1 -0
  107. package/dist/referrals/collections/ReferralCollection.d.ts +25 -0
  108. package/dist/referrals/collections/ReferralCollection.d.ts.map +1 -0
  109. package/dist/referrals/collections/ReferralLinkCollection.d.ts +96 -0
  110. package/dist/referrals/collections/ReferralLinkCollection.d.ts.map +1 -0
  111. package/dist/referrals/collections/ReferralProgramCollection.d.ts +18 -0
  112. package/dist/referrals/collections/ReferralProgramCollection.d.ts.map +1 -0
  113. package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts +13 -0
  114. package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts.map +1 -0
  115. package/dist/referrals/collections/ReferralTouchCollection.d.ts +30 -0
  116. package/dist/referrals/collections/ReferralTouchCollection.d.ts.map +1 -0
  117. package/dist/referrals/collections/ReferrerCollection.d.ts +17 -0
  118. package/dist/referrals/collections/ReferrerCollection.d.ts.map +1 -0
  119. package/dist/referrals/index.d.ts +38 -0
  120. package/dist/referrals/index.d.ts.map +1 -0
  121. package/dist/referrals/models/AttributionException.d.ts +85 -0
  122. package/dist/referrals/models/AttributionException.d.ts.map +1 -0
  123. package/dist/referrals/models/AttributionPolicy.d.ts +128 -0
  124. package/dist/referrals/models/AttributionPolicy.d.ts.map +1 -0
  125. package/dist/referrals/models/Referral.d.ts +112 -0
  126. package/dist/referrals/models/Referral.d.ts.map +1 -0
  127. package/dist/referrals/models/ReferralAgreement.d.ts +125 -0
  128. package/dist/referrals/models/ReferralAgreement.d.ts.map +1 -0
  129. package/dist/referrals/models/ReferralLink.d.ts +71 -0
  130. package/dist/referrals/models/ReferralLink.d.ts.map +1 -0
  131. package/dist/referrals/models/ReferralProgram.d.ts +57 -0
  132. package/dist/referrals/models/ReferralProgram.d.ts.map +1 -0
  133. package/dist/referrals/models/ReferralTermSnapshot.d.ts +61 -0
  134. package/dist/referrals/models/ReferralTermSnapshot.d.ts.map +1 -0
  135. package/dist/referrals/models/ReferralTouch.d.ts +55 -0
  136. package/dist/referrals/models/ReferralTouch.d.ts.map +1 -0
  137. package/dist/referrals/models/Referrer.d.ts +69 -0
  138. package/dist/referrals/models/Referrer.d.ts.map +1 -0
  139. package/dist/referrals/services/AttributionService.d.ts +248 -0
  140. package/dist/referrals/services/AttributionService.d.ts.map +1 -0
  141. package/dist/referrals/services/ReferralCommissionService.d.ts +91 -0
  142. package/dist/referrals/services/ReferralCommissionService.d.ts.map +1 -0
  143. package/dist/referrals/services/ReferralQualificationService.d.ts +135 -0
  144. package/dist/referrals/services/ReferralQualificationService.d.ts.map +1 -0
  145. package/dist/referrals/types.d.ts +226 -0
  146. package/dist/referrals/types.d.ts.map +1 -0
  147. package/dist/referrals.d.ts +2 -0
  148. package/dist/referrals.d.ts.map +1 -0
  149. package/dist/referrals.js +3 -0
  150. package/dist/smrt-knowledge.json +5501 -0
  151. package/dist/svelte/__tests__/format.test.js +68 -0
  152. package/dist/svelte/__tests__/types.test.js +349 -0
  153. package/dist/svelte/components/AttributionConflictQueue.svelte +361 -0
  154. package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts +15 -0
  155. package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts.map +1 -0
  156. package/dist/svelte/components/CommissionBreakdown.svelte +251 -0
  157. package/dist/svelte/components/CommissionBreakdown.svelte.d.ts +11 -0
  158. package/dist/svelte/components/CommissionBreakdown.svelte.d.ts.map +1 -0
  159. package/dist/svelte/components/CommissionExpenseSummary.svelte +186 -0
  160. package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts +13 -0
  161. package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts.map +1 -0
  162. package/dist/svelte/components/ExecutedAgreementsList.svelte +136 -0
  163. package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts +11 -0
  164. package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts.map +1 -0
  165. package/dist/svelte/components/LeadList.svelte +224 -0
  166. package/dist/svelte/components/LeadList.svelte.d.ts +19 -0
  167. package/dist/svelte/components/LeadList.svelte.d.ts.map +1 -0
  168. package/dist/svelte/components/OpportunityBoard.svelte +230 -0
  169. package/dist/svelte/components/OpportunityBoard.svelte.d.ts +17 -0
  170. package/dist/svelte/components/OpportunityBoard.svelte.d.ts.map +1 -0
  171. package/dist/svelte/components/OpportunityDetail.svelte +472 -0
  172. package/dist/svelte/components/OpportunityDetail.svelte.d.ts +27 -0
  173. package/dist/svelte/components/OpportunityDetail.svelte.d.ts.map +1 -0
  174. package/dist/svelte/components/PayoutBatchReview.svelte +285 -0
  175. package/dist/svelte/components/PayoutBatchReview.svelte.d.ts +21 -0
  176. package/dist/svelte/components/PayoutBatchReview.svelte.d.ts.map +1 -0
  177. package/dist/svelte/components/PayoutHistoryList.svelte +245 -0
  178. package/dist/svelte/components/PayoutHistoryList.svelte.d.ts +11 -0
  179. package/dist/svelte/components/PayoutHistoryList.svelte.d.ts.map +1 -0
  180. package/dist/svelte/components/ReferralLinkManager.svelte +216 -0
  181. package/dist/svelte/components/ReferralLinkManager.svelte.d.ts +17 -0
  182. package/dist/svelte/components/ReferralLinkManager.svelte.d.ts.map +1 -0
  183. package/dist/svelte/components/ReferralStatusList.svelte +118 -0
  184. package/dist/svelte/components/ReferralStatusList.svelte.d.ts +11 -0
  185. package/dist/svelte/components/ReferralStatusList.svelte.d.ts.map +1 -0
  186. package/dist/svelte/components/ReferrerEarningsSummary.svelte +106 -0
  187. package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts +11 -0
  188. package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts.map +1 -0
  189. package/dist/svelte/components/SalesDashboard.svelte +192 -0
  190. package/dist/svelte/components/SalesDashboard.svelte.d.ts +29 -0
  191. package/dist/svelte/components/SalesDashboard.svelte.d.ts.map +1 -0
  192. package/dist/svelte/format.d.ts +46 -0
  193. package/dist/svelte/format.d.ts.map +1 -0
  194. package/dist/svelte/format.js +75 -0
  195. package/dist/svelte/index.d.ts +50 -0
  196. package/dist/svelte/index.d.ts.map +1 -0
  197. package/dist/svelte/index.js +37 -0
  198. package/dist/svelte/types.d.ts +422 -0
  199. package/dist/svelte/types.d.ts.map +1 -0
  200. package/dist/svelte/types.js +376 -0
  201. package/package.json +89 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Unit tests for the render-time formatting helpers of the svelte module.
3
+ * Locales are passed explicitly so expectations stay machine-independent.
4
+ */
5
+ import { describe, expect, it } from 'vitest';
6
+ import { EMPTY_VALUE_PLACEHOLDER, formatCents, formatDate, formatPercent, } from '../format.js';
7
+ describe('formatCents', () => {
8
+ it('formats integer cents as major currency units', () => {
9
+ expect(formatCents(123456, 'USD', 'en-US')).toBe('$1,234.56');
10
+ });
11
+ it('formats zero', () => {
12
+ expect(formatCents(0, 'USD', 'en-US')).toBe('$0.00');
13
+ });
14
+ it('formats negative cents (clawbacks) as negative amounts', () => {
15
+ expect(formatCents(-4550, 'USD', 'en-US')).toBe('-$45.50');
16
+ });
17
+ it('handles large values with grouping', () => {
18
+ expect(formatCents(123456789012, 'USD', 'en-US')).toBe('$1,234,567,890.12');
19
+ });
20
+ it('rounds accidental float inputs to whole cents (half away from zero)', () => {
21
+ expect(formatCents(100.5, 'USD', 'en-US')).toBe('$1.01');
22
+ expect(formatCents(-100.5, 'USD', 'en-US')).toBe('-$1.01');
23
+ });
24
+ it('respects the currency code', () => {
25
+ expect(formatCents(9900, 'EUR', 'en-US')).toBe('€99.00');
26
+ });
27
+ it('falls back to "<major> <code>" for unknown currency codes', () => {
28
+ expect(formatCents(12345, 'NOT_A_CODE', 'en-US')).toBe('123.45 NOT_A_CODE');
29
+ });
30
+ it('renders the placeholder for non-finite input', () => {
31
+ expect(formatCents(Number.NaN, 'USD', 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
32
+ });
33
+ });
34
+ describe('formatDate', () => {
35
+ it('formats Date instances as medium dates', () => {
36
+ expect(formatDate(new Date(2026, 5, 15), 'en-US')).toBe('Jun 15, 2026');
37
+ });
38
+ it('parses ISO strings (local, no timezone suffix)', () => {
39
+ expect(formatDate('2026-03-05T12:00:00', 'en-US')).toBe('Mar 5, 2026');
40
+ });
41
+ it('renders the placeholder for null, undefined, and empty input', () => {
42
+ expect(formatDate(null, 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
43
+ expect(formatDate(undefined, 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
44
+ expect(formatDate('', 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
45
+ });
46
+ it('renders the placeholder for unparseable input', () => {
47
+ expect(formatDate('not-a-date', 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
48
+ expect(formatDate(new Date('not-a-date'), 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
49
+ });
50
+ });
51
+ describe('formatPercent', () => {
52
+ it('formats simple fractions', () => {
53
+ expect(formatPercent(0.5, 'en-US')).toBe('50%');
54
+ expect(formatPercent(1, 'en-US')).toBe('100%');
55
+ expect(formatPercent(0, 'en-US')).toBe('0%');
56
+ });
57
+ it('keeps at most one decimal', () => {
58
+ expect(formatPercent(0.333, 'en-US')).toBe('33.3%');
59
+ expect(formatPercent(0.3334, 'en-US')).toBe('33.3%');
60
+ });
61
+ it('formats negative fractions', () => {
62
+ expect(formatPercent(-0.25, 'en-US')).toBe('-25%');
63
+ });
64
+ it('renders the placeholder for non-finite input', () => {
65
+ expect(formatPercent(Number.NaN, 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
66
+ expect(formatPercent(Number.POSITIVE_INFINITY, 'en-US')).toBe(EMPTY_VALUE_PLACEHOLDER);
67
+ });
68
+ });
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Unit tests for the pure view-model helpers of the svelte module: dashboard
3
+ * math, board grouping, award validation, payout gating/timelines, and the
4
+ * expense reconciliation totals.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import { adjacentStageIds, buildShareUrl, canQualifyLead, equalSplitAwards, formatCommissionFormula, formatPlanRef, groupOpportunitiesByStage, isHttpUrl, isOverdue, openOpportunityCount, openPipelineTotals, payoutActionsFor, payoutStatusTimeline, pipelineValueByStage, sumExpenseRowsByCurrency, uniqueCandidateReferrerIds, validateAwards, winRate, } from '../types.js';
8
+ const stages = [
9
+ { id: 's1', name: 'New' },
10
+ { id: 's2', name: 'Proposal' },
11
+ { id: 's3', name: 'Closed Won', isWon: true },
12
+ ];
13
+ function opportunity(overrides) {
14
+ return {
15
+ name: `Opportunity ${overrides.id}`,
16
+ stageId: 's1',
17
+ expectedValueCents: 100_00,
18
+ currency: 'USD',
19
+ probability: 0.5,
20
+ status: 'open',
21
+ ...overrides,
22
+ };
23
+ }
24
+ describe('dashboard math', () => {
25
+ const opportunities = [
26
+ opportunity({ id: 'o1', stageId: 's1', expectedValueCents: 10_000 }),
27
+ opportunity({ id: 'o2', stageId: 's2', expectedValueCents: 25_000 }),
28
+ opportunity({
29
+ id: 'o3',
30
+ stageId: 's2',
31
+ expectedValueCents: 5_000,
32
+ currency: 'EUR',
33
+ }),
34
+ opportunity({
35
+ id: 'o4',
36
+ stageId: 's3',
37
+ status: 'won',
38
+ expectedValueCents: 99_999,
39
+ }),
40
+ opportunity({ id: 'o5', stageId: 's1', status: 'lost' }),
41
+ ];
42
+ it('counts only open opportunities', () => {
43
+ expect(openOpportunityCount(opportunities)).toBe(3);
44
+ });
45
+ it('totals open pipeline value per currency, never mixing currencies', () => {
46
+ expect(openPipelineTotals(opportunities)).toEqual([
47
+ { currency: 'EUR', amountCents: 5_000 },
48
+ { currency: 'USD', amountCents: 35_000 },
49
+ ]);
50
+ });
51
+ it('breaks open pipeline value down by stage in stage order', () => {
52
+ const byStage = pipelineValueByStage(stages, opportunities);
53
+ expect(byStage.map((s) => s.stageId)).toEqual(['s1', 's2', 's3']);
54
+ expect(byStage[0]).toEqual({
55
+ stageId: 's1',
56
+ stageName: 'New',
57
+ openCount: 1,
58
+ totals: [{ currency: 'USD', amountCents: 10_000 }],
59
+ });
60
+ expect(byStage[1].openCount).toBe(2);
61
+ expect(byStage[1].totals).toEqual([
62
+ { currency: 'EUR', amountCents: 5_000 },
63
+ { currency: 'USD', amountCents: 25_000 },
64
+ ]);
65
+ // Terminal stage: its opportunity is won, no longer open.
66
+ expect(byStage[2].openCount).toBe(0);
67
+ expect(byStage[2].totals).toEqual([]);
68
+ });
69
+ it('computes win rate over closed opportunities only', () => {
70
+ expect(winRate(opportunities)).toBe(0.5);
71
+ });
72
+ it('returns null win rate when nothing has closed', () => {
73
+ expect(winRate([opportunity({ id: 'o1' })])).toBeNull();
74
+ });
75
+ });
76
+ describe('board helpers', () => {
77
+ it('groups opportunities into the given stage order', () => {
78
+ const columns = groupOpportunitiesByStage(stages, [
79
+ opportunity({ id: 'o1', stageId: 's2' }),
80
+ opportunity({ id: 'o2', stageId: 's1' }),
81
+ opportunity({ id: 'o3', stageId: 'unknown-stage' }),
82
+ ]);
83
+ expect(columns.map((c) => c.stage.id)).toEqual(['s1', 's2', 's3']);
84
+ expect(columns[0].opportunities.map((o) => o.id)).toEqual(['o2']);
85
+ expect(columns[1].opportunities.map((o) => o.id)).toEqual(['o1']);
86
+ expect(columns[2].opportunities).toEqual([]);
87
+ });
88
+ it('finds adjacent stages for next/prev movement', () => {
89
+ expect(adjacentStageIds(stages, 's1')).toEqual({
90
+ prevStageId: null,
91
+ nextStageId: 's2',
92
+ });
93
+ expect(adjacentStageIds(stages, 's2')).toEqual({
94
+ prevStageId: 's1',
95
+ nextStageId: 's3',
96
+ });
97
+ expect(adjacentStageIds(stages, 's3')).toEqual({
98
+ prevStageId: 's2',
99
+ nextStageId: null,
100
+ });
101
+ expect(adjacentStageIds(stages, 'missing')).toEqual({
102
+ prevStageId: null,
103
+ nextStageId: null,
104
+ });
105
+ });
106
+ });
107
+ describe('lead helpers', () => {
108
+ it('allows qualification from new and working only', () => {
109
+ expect(canQualifyLead('new')).toBe(true);
110
+ expect(canQualifyLead('working')).toBe(true);
111
+ expect(canQualifyLead('qualified')).toBe(false);
112
+ expect(canQualifyLead('disqualified')).toBe(false);
113
+ expect(canQualifyLead('merged')).toBe(false);
114
+ });
115
+ it('detects overdue next actions relative to an explicit clock', () => {
116
+ const now = new Date(2026, 5, 15, 12, 0, 0);
117
+ expect(isOverdue(new Date(2026, 5, 14), now)).toBe(true);
118
+ expect(isOverdue(new Date(2026, 5, 16), now)).toBe(false);
119
+ expect(isOverdue(null, now)).toBe(false);
120
+ expect(isOverdue(undefined, now)).toBe(false);
121
+ expect(isOverdue('not-a-date', now)).toBe(false);
122
+ });
123
+ });
124
+ describe('referral link helpers', () => {
125
+ it('builds share URLs, normalising trailing slashes and encoding the code', () => {
126
+ expect(buildShareUrl('https://example.com/r', 'AB12CD')).toBe('https://example.com/r/AB12CD');
127
+ expect(buildShareUrl('https://example.com/r///', 'AB12CD')).toBe('https://example.com/r/AB12CD');
128
+ expect(buildShareUrl('https://example.com/r', 'A B')).toBe('https://example.com/r/A%20B');
129
+ });
130
+ it('accepts only http(s) URLs for the create form', () => {
131
+ expect(isHttpUrl('https://example.com/landing')).toBe(true);
132
+ expect(isHttpUrl('http://example.com')).toBe(true);
133
+ expect(isHttpUrl(' https://example.com ')).toBe(true);
134
+ expect(isHttpUrl('ftp://example.com')).toBe(false);
135
+ expect(isHttpUrl('example.com')).toBe(false);
136
+ expect(isHttpUrl('')).toBe(false);
137
+ });
138
+ });
139
+ describe('award validation', () => {
140
+ it('accepts a single full-credit award', () => {
141
+ const result = validateAwards([{ referrerId: 'r1', creditFraction: 1 }]);
142
+ expect(result.valid).toBe(true);
143
+ expect(result.totalFraction).toBe(1);
144
+ });
145
+ it('accepts fractions summing to 1 within the service tolerance', () => {
146
+ const result = validateAwards([
147
+ { referrerId: 'r1', creditFraction: 0.3333 },
148
+ { referrerId: 'r2', creditFraction: 0.3333 },
149
+ { referrerId: 'r3', creditFraction: 0.3334 },
150
+ ]);
151
+ expect(result.valid).toBe(true);
152
+ });
153
+ it('rejects an empty award set', () => {
154
+ const result = validateAwards([]);
155
+ expect(result.valid).toBe(false);
156
+ expect(result.message).toBeDefined();
157
+ });
158
+ it('rejects duplicate referrers', () => {
159
+ const result = validateAwards([
160
+ { referrerId: 'r1', creditFraction: 0.5 },
161
+ { referrerId: 'r1', creditFraction: 0.5 },
162
+ ]);
163
+ expect(result.valid).toBe(false);
164
+ });
165
+ it('rejects out-of-range fractions', () => {
166
+ expect(validateAwards([{ referrerId: 'r1', creditFraction: 0 }]).valid).toBe(false);
167
+ expect(validateAwards([{ referrerId: 'r1', creditFraction: 1.2 }]).valid).toBe(false);
168
+ expect(validateAwards([{ referrerId: 'r1', creditFraction: Number.NaN }]).valid).toBe(false);
169
+ });
170
+ it('rejects sums away from 1.0', () => {
171
+ const result = validateAwards([
172
+ { referrerId: 'r1', creditFraction: 0.5 },
173
+ { referrerId: 'r2', creditFraction: 0.4 },
174
+ ]);
175
+ expect(result.valid).toBe(false);
176
+ expect(result.totalFraction).toBeCloseTo(0.9);
177
+ });
178
+ });
179
+ describe('equal split seeding', () => {
180
+ it('gives a single referrer full credit', () => {
181
+ expect(equalSplitAwards(['r1'])).toEqual([
182
+ { referrerId: 'r1', creditFraction: 1 },
183
+ ]);
184
+ });
185
+ it('splits across three referrers with the last taking the remainder', () => {
186
+ const awards = equalSplitAwards(['r1', 'r2', 'r3']);
187
+ expect(awards.map((a) => a.creditFraction)).toEqual([
188
+ 0.3333, 0.3333, 0.3334,
189
+ ]);
190
+ expect(validateAwards(awards).valid).toBe(true);
191
+ });
192
+ it('returns an empty set for no referrers', () => {
193
+ expect(equalSplitAwards([])).toEqual([]);
194
+ });
195
+ it('deduplicates candidate referrers in first-seen order', () => {
196
+ const candidates = [
197
+ {
198
+ touchId: 't1',
199
+ referrerId: 'r2',
200
+ kind: 'click',
201
+ occurredAt: '2026-01-01',
202
+ },
203
+ {
204
+ touchId: 't2',
205
+ referrerId: 'r1',
206
+ kind: 'code_entry',
207
+ occurredAt: '2026-01-02',
208
+ },
209
+ {
210
+ touchId: 't3',
211
+ referrerId: 'r2',
212
+ kind: 'click',
213
+ occurredAt: '2026-01-03',
214
+ },
215
+ ];
216
+ expect(uniqueCandidateReferrerIds(candidates)).toEqual(['r2', 'r1']);
217
+ });
218
+ });
219
+ describe('commission formula', () => {
220
+ it('renders base × rate × share = amount for rate-based components', () => {
221
+ expect(formatCommissionFormula({
222
+ basis: 'gross',
223
+ baseAmountCents: 100_000,
224
+ rate: 0.1,
225
+ shareFraction: 0.5,
226
+ amountCents: 5_000,
227
+ currency: 'USD',
228
+ }, 'en-US')).toBe('$1,000.00 × 10% × 50% = $50.00');
229
+ });
230
+ it('renders fixed-basis components without the rate factor', () => {
231
+ expect(formatCommissionFormula({
232
+ basis: 'fixed',
233
+ baseAmountCents: 2_500,
234
+ rate: 0,
235
+ shareFraction: 1,
236
+ amountCents: 2_500,
237
+ currency: 'USD',
238
+ }, 'en-US')).toBe('$25.00 (fixed) × 100% = $25.00');
239
+ });
240
+ it('formats plan references as key@vN', () => {
241
+ expect(formatPlanRef('partner-standard', 3)).toBe('partner-standard@v3');
242
+ });
243
+ });
244
+ describe('payout timeline and gating', () => {
245
+ it('marks the happy path steps done/current/upcoming', () => {
246
+ expect(payoutStatusTimeline('processing')).toEqual([
247
+ { status: 'pending', state: 'done' },
248
+ { status: 'approved', state: 'done' },
249
+ { status: 'processing', state: 'current' },
250
+ { status: 'completed', state: 'upcoming' },
251
+ ]);
252
+ });
253
+ it('starts at pending', () => {
254
+ const steps = payoutStatusTimeline('pending');
255
+ expect(steps[0]).toEqual({ status: 'pending', state: 'current' });
256
+ expect(steps.at(-1)).toEqual({ status: 'completed', state: 'upcoming' });
257
+ });
258
+ it('replaces the terminal step with failed for failed batches', () => {
259
+ const steps = payoutStatusTimeline('failed');
260
+ expect(steps.map((s) => s.status)).toEqual([
261
+ 'pending',
262
+ 'approved',
263
+ 'processing',
264
+ 'failed',
265
+ ]);
266
+ expect(steps.at(-1)?.state).toBe('current');
267
+ });
268
+ it('gates operator actions by batch status', () => {
269
+ expect(payoutActionsFor('pending')).toEqual({
270
+ canApprove: true,
271
+ canMarkProcessing: false,
272
+ canComplete: false,
273
+ canFail: false,
274
+ });
275
+ expect(payoutActionsFor('approved')).toEqual({
276
+ canApprove: false,
277
+ canMarkProcessing: true,
278
+ canComplete: false,
279
+ canFail: true,
280
+ });
281
+ expect(payoutActionsFor('processing')).toEqual({
282
+ canApprove: false,
283
+ canMarkProcessing: false,
284
+ canComplete: true,
285
+ canFail: true,
286
+ });
287
+ expect(payoutActionsFor('completed')).toEqual({
288
+ canApprove: false,
289
+ canMarkProcessing: false,
290
+ canComplete: false,
291
+ canFail: false,
292
+ });
293
+ expect(payoutActionsFor('failed')).toEqual({
294
+ canApprove: false,
295
+ canMarkProcessing: false,
296
+ canComplete: false,
297
+ canFail: false,
298
+ });
299
+ });
300
+ });
301
+ describe('expense reconciliation totals', () => {
302
+ const rows = [
303
+ {
304
+ id: '2026-06:USD:a',
305
+ label: 'Program A',
306
+ currency: 'USD',
307
+ commissionExpenseCents: 100_000,
308
+ adjustmentCents: -5_000,
309
+ payoutCents: 60_000,
310
+ },
311
+ {
312
+ id: '2026-06:USD:b',
313
+ label: 'Program B',
314
+ currency: 'USD',
315
+ commissionExpenseCents: 50_000,
316
+ adjustmentCents: 0,
317
+ payoutCents: 50_000,
318
+ },
319
+ {
320
+ id: '2026-06:EUR',
321
+ label: 'Program C',
322
+ currency: 'EUR',
323
+ commissionExpenseCents: 20_000,
324
+ adjustmentCents: 1_000,
325
+ payoutCents: 0,
326
+ },
327
+ ];
328
+ it('sums per currency and derives the net accrual', () => {
329
+ expect(sumExpenseRowsByCurrency(rows)).toEqual([
330
+ {
331
+ currency: 'EUR',
332
+ commissionExpenseCents: 20_000,
333
+ adjustmentCents: 1_000,
334
+ payoutCents: 0,
335
+ netAccruedCents: 21_000,
336
+ },
337
+ {
338
+ currency: 'USD',
339
+ commissionExpenseCents: 150_000,
340
+ adjustmentCents: -5_000,
341
+ payoutCents: 110_000,
342
+ netAccruedCents: 35_000,
343
+ },
344
+ ]);
345
+ });
346
+ it('handles an empty row set', () => {
347
+ expect(sumExpenseRowsByCurrency([])).toEqual([]);
348
+ });
349
+ });