@fiado/type-kit 3.491.0 → 3.492.0

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 (67) hide show
  1. package/_test_/unit/loanCredit/installmentPaidEvent.test.ts +17 -0
  2. package/_test_/unit/retailCommission/commissionPayout.test.ts +104 -0
  3. package/_test_/unit/retailCommission/commissionRent.test.ts +33 -0
  4. package/_test_/unit/retailCommission/commissionRules.test.ts +249 -0
  5. package/_test_/unit/retailCommission/commissionSimulation.test.ts +43 -0
  6. package/_test_/unit/retailCommission/myCommissions.test.ts +29 -0
  7. package/_test_/unit/retailWizard/ventaSagaMessages.test.ts +108 -11
  8. package/bin/loanCredit/dtos/InstallmentPaidEvent.d.ts +18 -0
  9. package/bin/loanCredit/dtos/InstallmentPaidEvent.js +5 -0
  10. package/bin/loanCredit/index.d.ts +1 -0
  11. package/bin/loanCredit/index.js +1 -0
  12. package/bin/retailCommission/dtos/CommissionAccrual.d.ts +36 -0
  13. package/bin/retailCommission/dtos/CommissionApi.d.ts +241 -2
  14. package/bin/retailCommission/dtos/CommissionCalcTrace.d.ts +10 -0
  15. package/bin/retailCommission/dtos/CommissionCouponAccrual.d.ts +31 -0
  16. package/bin/retailCommission/dtos/CommissionCouponAccrual.js +2 -0
  17. package/bin/retailCommission/dtos/CommissionPayout.d.ts +84 -0
  18. package/bin/retailCommission/dtos/CommissionRule.d.ts +119 -0
  19. package/bin/retailCommission/dtos/CommissionRule.js +2 -0
  20. package/bin/retailCommission/enums/CommissionAggregateByEnum.d.ts +14 -0
  21. package/bin/retailCommission/enums/CommissionAggregateByEnum.js +18 -0
  22. package/bin/retailCommission/enums/CommissionBonusModeEnum.d.ts +10 -0
  23. package/bin/retailCommission/enums/CommissionBonusModeEnum.js +14 -0
  24. package/bin/retailCommission/enums/CommissionCalcStepEnum.d.ts +3 -1
  25. package/bin/retailCommission/enums/CommissionCalcStepEnum.js +2 -0
  26. package/bin/retailCommission/enums/CommissionMetricEnum.d.ts +14 -0
  27. package/bin/retailCommission/enums/CommissionMetricEnum.js +18 -0
  28. package/bin/retailCommission/enums/CommissionOperatorEnum.d.ts +15 -0
  29. package/bin/retailCommission/enums/CommissionOperatorEnum.js +19 -0
  30. package/bin/retailCommission/enums/CommissionRuleStatusEnum.d.ts +16 -0
  31. package/bin/retailCommission/enums/CommissionRuleStatusEnum.js +20 -0
  32. package/bin/retailCommission/enums/CommissionSeriesByEnum.d.ts +14 -0
  33. package/bin/retailCommission/enums/CommissionSeriesByEnum.js +18 -0
  34. package/bin/retailCommission/enums/CommissionSimulationSkipEnum.d.ts +14 -0
  35. package/bin/retailCommission/enums/CommissionSimulationSkipEnum.js +18 -0
  36. package/bin/retailCommission/enums/CommissionWindowEnum.d.ts +12 -0
  37. package/bin/retailCommission/enums/CommissionWindowEnum.js +16 -0
  38. package/bin/retailCommission/index.d.ts +10 -0
  39. package/bin/retailCommission/index.js +10 -0
  40. package/bin/retailWizard/enums/RetailCommissionInboundMessageTypeEnum.d.ts +5 -0
  41. package/bin/retailWizard/enums/RetailCommissionInboundMessageTypeEnum.js +9 -0
  42. package/bin/retailWizard/events/RetailCommissionInboundMessage.d.ts +27 -1
  43. package/bin/retailWizard/events/RetailCommissionInboundMessage.js +70 -1
  44. package/bin/retailWizard/index.d.ts +1 -0
  45. package/bin/retailWizard/index.js +1 -0
  46. package/package.json +1 -1
  47. package/src/loanCredit/dtos/InstallmentPaidEvent.ts +20 -0
  48. package/src/loanCredit/index.ts +1 -0
  49. package/src/retailCommission/dtos/CommissionAccrual.ts +38 -0
  50. package/src/retailCommission/dtos/CommissionApi.ts +276 -2
  51. package/src/retailCommission/dtos/CommissionCalcTrace.ts +7 -0
  52. package/src/retailCommission/dtos/CommissionCouponAccrual.ts +33 -0
  53. package/src/retailCommission/dtos/CommissionPayout.ts +91 -0
  54. package/src/retailCommission/dtos/CommissionRule.ts +132 -0
  55. package/src/retailCommission/enums/CommissionAggregateByEnum.ts +14 -0
  56. package/src/retailCommission/enums/CommissionBonusModeEnum.ts +10 -0
  57. package/src/retailCommission/enums/CommissionCalcStepEnum.ts +2 -0
  58. package/src/retailCommission/enums/CommissionMetricEnum.ts +14 -0
  59. package/src/retailCommission/enums/CommissionOperatorEnum.ts +15 -0
  60. package/src/retailCommission/enums/CommissionRuleStatusEnum.ts +16 -0
  61. package/src/retailCommission/enums/CommissionSeriesByEnum.ts +14 -0
  62. package/src/retailCommission/enums/CommissionSimulationSkipEnum.ts +14 -0
  63. package/src/retailCommission/enums/CommissionWindowEnum.ts +12 -0
  64. package/src/retailCommission/index.ts +10 -0
  65. package/src/retailWizard/enums/RetailCommissionInboundMessageTypeEnum.ts +5 -0
  66. package/src/retailWizard/events/RetailCommissionInboundMessage.ts +75 -2
  67. package/src/retailWizard/index.ts +1 -0
@@ -0,0 +1,17 @@
1
+ import 'reflect-metadata';
2
+ import * as LoanCredit from '../../../src/loanCredit';
3
+
4
+ describe('loanCredit — cuota pagada', () => {
5
+ it('el evento lleva la cuota, lo pagado y el instante del pago', () => {
6
+ const payload: LoanCredit.InstallmentPaidV1Payload = {
7
+ creditId: 'CR-1',
8
+ installmentNumber: 3,
9
+ paidCents: 10_404,
10
+ paidAt: '2026-10-05T18:00:00.000Z',
11
+ installmentStatus: LoanCredit.LoanInstallmentStatusEnum.PAID,
12
+ paymentId: '01JPAY',
13
+ };
14
+ expect(LoanCredit.INSTALLMENT_PAID_EVENT_TYPE).toBe('InstallmentPaidV1');
15
+ expect(payload.installmentNumber).toBe(3);
16
+ });
17
+ });
@@ -0,0 +1,104 @@
1
+ import 'reflect-metadata';
2
+ import * as RetailCommission from '../../../src/retailCommission';
3
+
4
+ // Caso V-01: $915 de bonos ×1.5, umbral $200, sin ajustes → neto $1,573.
5
+ const breakdown: RetailCommission.CommissionSellerBreakdown = {
6
+ sellerId: 'V-01',
7
+ salesCount: 5,
8
+ unitsCount: 8,
9
+ placementCents: 1_000_000,
10
+ subtotalBonusCents: 91_500,
11
+ multiplierBps: 15_000,
12
+ multiplierBeforeCapBps: 15_000,
13
+ multipliers: [{ ruleId: 'RC-05', name: 'Aceleración calidad NPI', factorBps: 15_000 }],
14
+ thresholdBonusCents: 20_000,
15
+ thresholds: [{ ruleId: 'RC-07', name: 'Mix de productos', amountCents: 20_000 }],
16
+ portfolioRentCents: 0,
17
+ releasedCents: 157_250,
18
+ adjustmentsCents: 0,
19
+ clawbackCents: 0,
20
+ netCents: 157_250,
21
+ byRule: [{ ruleId: 'RC-01', name: 'Base semanal · ventas 1-5', unitsCount: 5, bonusCents: 40_000, multiplierShareCents: 20_000 }],
22
+ byStore: [{ storeId: 'ST-1', salesCount: 5, unitsCount: 8, placementCents: 1_000_000, paidCents: 137_250 }],
23
+ bySku: [{ sku: 'PHONE-A', unitsCount: 1, placementCents: 350_000, paidCents: 12_000 }],
24
+ };
25
+
26
+ describe('retailCommission — desglose del corte de un vendedor', () => {
27
+ it('liberado = subtotal × multiplicador + umbral + renta, y neto = liberado + ajustes', () => {
28
+ const liberado =
29
+ Math.round((breakdown.subtotalBonusCents * breakdown.multiplierBps) / 10_000) +
30
+ breakdown.thresholdBonusCents +
31
+ breakdown.portfolioRentCents;
32
+ expect(liberado).toBe(breakdown.releasedCents);
33
+ expect(breakdown.releasedCents + breakdown.adjustmentsCents).toBe(breakdown.netCents);
34
+ });
35
+
36
+ it('el corte de un vendedor puede traer su desglose, y uno viejo sigue siendo válido sin él', () => {
37
+ const legacy: RetailCommission.CommissionPayoutSeller = {
38
+ retailerId: 'RET-1',
39
+ periodId: '2026-09-28',
40
+ sellerId: 'V-01',
41
+ accruedCount: 5,
42
+ baseAmountCents: 91_500,
43
+ settledAmountCents: null,
44
+ quotaMet: null,
45
+ quotaFactorBps: null,
46
+ };
47
+ const withBreakdown: RetailCommission.CommissionPayoutSeller = { ...legacy, settledAmountCents: 157_250, breakdown };
48
+ expect(legacy.breakdown).toBeUndefined();
49
+ expect(withBreakdown.breakdown?.netCents).toBe(157_250);
50
+ });
51
+ });
52
+
53
+ describe('retailCommission — corte vivo, agregados y series', () => {
54
+ it('expone por qué se agrega y por qué se arma una serie', () => {
55
+ expect(Object.values(RetailCommission.CommissionAggregateByEnum)).toEqual(['RULE', 'STORE', 'SKU', 'SELLER']);
56
+ expect(Object.values(RetailCommission.CommissionSeriesByEnum)).toEqual(['SELLER', 'RULE', 'STORE', 'SKU']);
57
+ });
58
+
59
+ it('el corte vivo trae el desglose de cada vendedor y sus ventas sin datos del cliente', () => {
60
+ const live: RetailCommission.GetLivePayoutResponse = {
61
+ periodId: '2026-09-28',
62
+ periodStart: '2026-09-28',
63
+ periodEnd: '2026-10-04',
64
+ status: RetailCommission.CommissionPayoutStatusEnum.OPEN,
65
+ sellers: [breakdown],
66
+ };
67
+ const seller: RetailCommission.GetLiveSellerResponse = {
68
+ periodId: '2026-09-28',
69
+ seller: breakdown,
70
+ sales: [
71
+ {
72
+ saleId: 'S1',
73
+ soldAt: '2026-09-28T18:00:00.000Z',
74
+ storeId: 'ST-1',
75
+ status: RetailCommission.CommissionAccrualStatusEnum.ACCRUED,
76
+ planSnapshotId: 'SNAP-1',
77
+ lines: [],
78
+ appliedBonuses: [],
79
+ subtotalBonusCents: 8000,
80
+ },
81
+ ],
82
+ };
83
+ expect(live.sellers).toHaveLength(1);
84
+ expect(seller.sales[0]?.subtotalBonusCents).toBe(8000);
85
+ });
86
+
87
+ it('agregados y series salen de los cortes cerrados', () => {
88
+ const aggregates: RetailCommission.GetPayoutAggregatesResponse = {
89
+ by: RetailCommission.CommissionAggregateByEnum.RULE,
90
+ from: '2026-09-07',
91
+ to: '2026-09-28',
92
+ periodIds: ['2026-09-07', '2026-09-14', '2026-09-21', '2026-09-28'],
93
+ rows: [{ key: 'RC-01', name: 'Base semanal · ventas 1-5', sellersCount: 12, salesCount: 0, unitsCount: 140, placementCents: 0, paidCents: 1_120_000 }],
94
+ totalPaidCents: 1_120_000,
95
+ };
96
+ const series: RetailCommission.GetPayoutSeriesResponse = {
97
+ by: RetailCommission.CommissionSeriesByEnum.SELLER,
98
+ id: 'V-01',
99
+ points: [{ periodId: '2026-09-28', salesCount: 5, unitsCount: 8, placementCents: 1_000_000, paidCents: 157_250 }],
100
+ };
101
+ expect(aggregates.rows[0]?.paidCents).toBe(aggregates.totalPaidCents);
102
+ expect(series.points).toHaveLength(1);
103
+ });
104
+ });
@@ -0,0 +1,33 @@
1
+ import 'reflect-metadata';
2
+ import * as RetailCommission from '../../../src/retailCommission';
3
+
4
+ const { CommissionAccrualStatusEnum, CommissionBonusModeEnum } = RetailCommission;
5
+
6
+ describe('retailCommission — renta de cartera', () => {
7
+ it('una cuota pagada devenga un bono por cada regla de renta que le aplica', () => {
8
+ const coupon: RetailCommission.CommissionCouponAccrual = {
9
+ sellerId: 'V-01',
10
+ retailerId: 'RET-1',
11
+ periodId: '2026-10-05',
12
+ creditId: 'CR-1',
13
+ saleId: 'S-1',
14
+ installmentNumber: 3,
15
+ couponAmountCents: 10_404,
16
+ paidAt: '2026-10-05T18:00:00.000Z',
17
+ planSnapshotId: 'SNAP-1',
18
+ bonuses: [{ ruleId: 'RC-13', name: 'Renta gama alta', mode: CommissionBonusModeEnum.PERCENT, amountCents: 104 }],
19
+ subtotalCents: 104,
20
+ status: CommissionAccrualStatusEnum.ACCRUED,
21
+ createdAt: '2026-10-05T18:00:05.000Z',
22
+ };
23
+ expect(coupon.subtotalCents).toBe(coupon.bonuses[0]?.amountCents);
24
+ });
25
+
26
+ it('el desglose puede traer la renta por regla y el NPI; uno anterior a la renta sigue siendo válido', () => {
27
+ const rent: RetailCommission.CommissionAppliedRent = { ruleId: 'RC-13', name: 'Renta gama alta', couponsCount: 2, amountCents: 208 };
28
+ const partial: Partial<RetailCommission.CommissionSellerBreakdown> = { rentByRule: [rent], npiM30Bps: 250, npiSample: 8 };
29
+ const legacy: Partial<RetailCommission.CommissionSellerBreakdown> = {};
30
+ expect(partial.rentByRule?.[0]?.couponsCount).toBe(2);
31
+ expect(legacy.npiM30Bps).toBeUndefined();
32
+ });
33
+ });
@@ -0,0 +1,249 @@
1
+ import 'reflect-metadata';
2
+ import * as RetailCommission from '../../../src/retailCommission';
3
+ import * as RetailCatalog from '../../../src/retailCatalog';
4
+ import * as RetailWizard from '../../../src/retailWizard';
5
+
6
+ describe('retailCommission — enums de las reglas', () => {
7
+ it('expone los estados de una regla', () => {
8
+ expect(Object.values(RetailCommission.CommissionRuleStatusEnum)).toEqual([
9
+ 'DRAFT', 'SCHEDULED', 'ACTIVE', 'PAUSED', 'EXPIRED',
10
+ ]);
11
+ });
12
+
13
+ it('expone los modos del bono', () => {
14
+ expect(Object.values(RetailCommission.CommissionBonusModeEnum)).toEqual(['FIXED', 'PERCENT']);
15
+ });
16
+
17
+ it('expone las métricas de condición', () => {
18
+ expect(Object.values(RetailCommission.CommissionMetricEnum)).toEqual([
19
+ 'POSICION_VENTA_EN_PERIODO', 'COLOCACION_PERIODO', 'NPI_M30_VENDEDOR', 'SKUS_DISTINTOS_PERIODO',
20
+ ]);
21
+ });
22
+
23
+ it('expone los operadores de condición', () => {
24
+ expect(Object.values(RetailCommission.CommissionOperatorEnum)).toEqual(['GTE', 'LTE', 'LT', 'GT', 'BETWEEN', 'IN']);
25
+ });
26
+
27
+ it('expone las ventanas del corte', () => {
28
+ expect(Object.values(RetailCommission.CommissionWindowEnum)).toEqual(['SEMANA_OPERATIVA', 'ROLLING_7D', 'MES']);
29
+ });
30
+
31
+ it('agrega el paso del bono por venta a la traza sin quitar los existentes', () => {
32
+ expect(Object.values(RetailCommission.CommissionCalcStepEnum)).toEqual([
33
+ 'MECHANIC', 'EXCEPTION', 'ADDON', 'QUOTA', 'ADJUSTMENT', 'NO_CATEGORY', 'BONUS_PER_SALE',
34
+ ]);
35
+ });
36
+ });
37
+
38
+ describe('retailCommission — regla, políticas y snapshot', () => {
39
+ const { CommissionBonusModeEnum, CommissionMetricEnum, CommissionOperatorEnum, CommissionRuleStatusEnum, CommissionWindowEnum } =
40
+ RetailCommission;
41
+
42
+ // Base semanal: $80 por cada una de las primeras 5 piezas de la semana.
43
+ const base: RetailCommission.CommissionRule = {
44
+ ruleId: '01JRULE0000000000000000001',
45
+ retailerId: 'RET-1',
46
+ name: 'Base semanal · ventas 1-5',
47
+ description: null,
48
+ status: CommissionRuleStatusEnum.ACTIVE,
49
+ levers: {
50
+ bonoPorVenta: { mode: CommissionBonusModeEnum.FIXED, value: 8000 },
51
+ bonoUmbral: null,
52
+ multiplicador: null,
53
+ bonoPorCuponPagado: null,
54
+ },
55
+ scope: { tipos: [], gamas: [], skus: [], tiendas: [] },
56
+ condition: {
57
+ metric: CommissionMetricEnum.POSICION_VENTA_EN_PERIODO,
58
+ operator: CommissionOperatorEnum.BETWEEN,
59
+ value: [1, 5],
60
+ minSample: null,
61
+ },
62
+ effectiveFrom: '2026-09-28',
63
+ effectiveTo: null,
64
+ priority: 100,
65
+ replacesOthers: false,
66
+ version: 1,
67
+ publishedVersion: 1,
68
+ publishedBy: 'admin-1',
69
+ publishedAt: '2026-09-28T15:00:00.000Z',
70
+ createdBy: 'admin-1',
71
+ updatedBy: 'admin-1',
72
+ createdAt: '2026-09-28T14:00:00.000Z',
73
+ updatedAt: '2026-09-28T15:00:00.000Z',
74
+ };
75
+
76
+ it('una regla puede llevar las cuatro palancas y un alcance con los enums del catálogo', () => {
77
+ const npi: RetailCommission.CommissionRule = {
78
+ ...base,
79
+ levers: {
80
+ bonoPorVenta: null,
81
+ bonoUmbral: { amountCents: 20000 },
82
+ multiplicador: { factorBps: 15000 },
83
+ bonoPorCuponPagado: { mode: CommissionBonusModeEnum.PERCENT, value: 100 },
84
+ },
85
+ scope: {
86
+ tipos: [RetailCatalog.ProductCategoryEnum.PHONE],
87
+ gamas: [RetailCatalog.ProductTierEnum.HIGH],
88
+ skus: ['SKU-1'],
89
+ tiendas: ['ST-1'],
90
+ },
91
+ condition: { metric: CommissionMetricEnum.NPI_M30_VENDEDOR, operator: CommissionOperatorEnum.LT, value: 300, minSample: 5 },
92
+ replacesOthers: true,
93
+ };
94
+ expect(npi.levers.multiplicador?.factorBps).toBe(15000);
95
+ expect(npi.condition?.minSample).toBe(5);
96
+ });
97
+
98
+ it('el snapshot congela las reglas publicadas y las políticas', () => {
99
+ const snapshot: RetailCommission.CommissionRuleSetSnapshot = {
100
+ retailerId: 'RET-1',
101
+ snapshotId: '01JSNAP000000000000000001',
102
+ rules: [base],
103
+ policies: {
104
+ clawback: { cancelacionDias: 7, devolucionDias: 7, npiM30Dias: 30, editedBy: 'admin-1', editedAt: '2026-09-28T15:00:00.000Z' },
105
+ commissions: {
106
+ multiplicadorMaxBps: 25000,
107
+ ventanaDefault: CommissionWindowEnum.SEMANA_OPERATIVA,
108
+ editedBy: 'admin-1',
109
+ editedAt: '2026-09-28T15:00:00.000Z',
110
+ },
111
+ },
112
+ changeNote: 'Publica Base semanal · ventas 1-5',
113
+ publishedBy: 'admin-1',
114
+ publishedAt: '2026-09-28T15:00:00.000Z',
115
+ };
116
+ expect(snapshot.rules).toHaveLength(1);
117
+ });
118
+
119
+ it('las políticas pueden faltar mientras la cadena no las guarda', () => {
120
+ const policies: RetailCommission.CommissionPolicies = { clawback: null, commissions: null };
121
+ expect(policies.clawback).toBeNull();
122
+ });
123
+ });
124
+
125
+ describe('retailCommission — devengo con reglas', () => {
126
+ const { CommissionBonusModeEnum, CommissionCalcStepEnum } = RetailCommission;
127
+
128
+ const phone: RetailCommission.CommissionSaleLine = {
129
+ lineId: 'L1',
130
+ sku: 'SKU-PHONE',
131
+ qty: 1,
132
+ netCents: 450000,
133
+ category: RetailCatalog.ProductCategoryEnum.PHONE,
134
+ tier: RetailCatalog.ProductTierEnum.HIGH,
135
+ brand: 'Samsung',
136
+ paymentType: RetailWizard.LinePaymentTypeEnum.CREDIT,
137
+ };
138
+ const gift: RetailCommission.CommissionSaleLine = {
139
+ ...phone,
140
+ lineId: 'L2',
141
+ netCents: 0,
142
+ role: RetailWizard.WizardLineRoleEnum.GIFT,
143
+ };
144
+
145
+ const sale: RetailCommission.CommissionSaleInput = {
146
+ saleId: 'SALE-1',
147
+ sellerId: 'SELLER-1',
148
+ retailerId: 'RET-1',
149
+ storeId: 'ST-1',
150
+ sku: 'SKU-PHONE',
151
+ brand: 'Samsung',
152
+ equipmentPriceCents: 450000,
153
+ amountCents: 450000,
154
+ financier: RetailWizard.SaleFinancierEnum.SUREKEEP,
155
+ paymentType: 'CREDIT',
156
+ soldAt: '2026-09-30T18:00:00.000Z',
157
+ lines: [phone, gift],
158
+ };
159
+
160
+ it('la venta lleva sus líneas, y una sin líneas sigue siendo válida', () => {
161
+ const legacy: RetailCommission.CommissionSaleInput = {
162
+ saleId: sale.saleId,
163
+ sellerId: sale.sellerId,
164
+ retailerId: sale.retailerId,
165
+ storeId: sale.storeId,
166
+ sku: sale.sku,
167
+ brand: sale.brand,
168
+ equipmentPriceCents: sale.equipmentPriceCents,
169
+ amountCents: sale.amountCents,
170
+ financier: sale.financier,
171
+ paymentType: sale.paymentType,
172
+ soldAt: sale.soldAt,
173
+ };
174
+ expect(sale.lines).toHaveLength(2);
175
+ expect(legacy.lines).toBeUndefined();
176
+ });
177
+
178
+ it('el bono aplicado dice qué regla, qué pieza y en qué posición', () => {
179
+ const bonus: RetailCommission.CommissionAppliedBonus = {
180
+ ruleId: '01JRULE0000000000000000001',
181
+ name: 'Base semanal · ventas 1-5',
182
+ lineId: 'L1',
183
+ sku: 'SKU-PHONE',
184
+ position: 1,
185
+ mode: CommissionBonusModeEnum.FIXED,
186
+ amountCents: 8000,
187
+ };
188
+ const trace: RetailCommission.CommissionCalcTraceEntry = {
189
+ step: CommissionCalcStepEnum.BONUS_PER_SALE,
190
+ label: bonus.name,
191
+ outputCents: bonus.amountCents,
192
+ detail: { ruleId: bonus.ruleId, lineId: bonus.lineId, position: bonus.position },
193
+ };
194
+ expect(trace.outputCents).toBe(8000);
195
+ });
196
+
197
+ it('el devengo puede traer los bonos aplicados y su subtotal', () => {
198
+ const accrual: Pick<RetailCommission.CommissionAccrual, 'appliedBonuses' | 'subtotalBonusCents'> = {
199
+ appliedBonuses: [],
200
+ subtotalBonusCents: 0,
201
+ };
202
+ expect(accrual.subtotalBonusCents).toBe(0);
203
+ });
204
+ });
205
+
206
+ describe('retailCommission — contratos de reglas y políticas', () => {
207
+ const { CommissionBonusModeEnum, CommissionWindowEnum } = RetailCommission;
208
+
209
+ const input: RetailCommission.CommissionRuleInput = {
210
+ name: 'Bono Accesorios',
211
+ description: null,
212
+ levers: {
213
+ bonoPorVenta: { mode: CommissionBonusModeEnum.FIXED, value: 1000 },
214
+ bonoUmbral: null,
215
+ multiplicador: null,
216
+ bonoPorCuponPagado: null,
217
+ },
218
+ scope: { tipos: [RetailCatalog.ProductCategoryEnum.ACCESSORY], gamas: [], skus: [], tiendas: [] },
219
+ condition: null,
220
+ effectiveFrom: '2026-10-01',
221
+ effectiveTo: null,
222
+ priority: 100,
223
+ replacesOthers: false,
224
+ };
225
+
226
+ it('crear y editar comparten el cuerpo; editar exige la versión que se cargó', () => {
227
+ const create: RetailCommission.CreateRuleRequest = input;
228
+ const update: RetailCommission.UpdateRuleRequest = { ...input, expectedVersion: 2 };
229
+ const action: RetailCommission.RuleActionRequest = { expectedVersion: 3 };
230
+ expect([create.name, update.expectedVersion, action.expectedVersion]).toEqual(['Bono Accesorios', 2, 3]);
231
+ });
232
+
233
+ it('las políticas se guardan juntas', () => {
234
+ const request: RetailCommission.UpdatePoliciesRequest = {
235
+ clawback: { cancelacionDias: 7, devolucionDias: 7, npiM30Dias: 30 },
236
+ commissions: { multiplicadorMaxBps: 25000, ventanaDefault: CommissionWindowEnum.SEMANA_OPERATIVA },
237
+ };
238
+ expect(request.commissions.multiplicadorMaxBps).toBe(25000);
239
+ });
240
+
241
+ it('el plan vigente con reglas puede no existir todavía', () => {
242
+ const response: RetailCommission.GetRuleSetResponse = { active: null };
243
+ const history: RetailCommission.RuleHistoryResponse = {
244
+ events: [{ at: '2026-10-01T15:00:00.000Z', action: 'PUBLISH', performedBy: 'admin-1', version: 1 }],
245
+ };
246
+ expect(response.active).toBeNull();
247
+ expect(history.events[0]?.version).toBe(1);
248
+ });
249
+ });
@@ -0,0 +1,43 @@
1
+ import 'reflect-metadata';
2
+ import * as RetailCommission from '../../../src/retailCommission';
3
+ import * as RetailCatalog from '../../../src/retailCatalog';
4
+
5
+ describe('retailCommission — simulador de reglas', () => {
6
+ it('expone por qué una regla no paga en la simulación', () => {
7
+ expect(Object.values(RetailCommission.CommissionSimulationSkipEnum)).toEqual([
8
+ 'NOT_IN_EFFECT', 'OUT_OF_SCOPE', 'CONDITION_NOT_MET', 'NO_DATA',
9
+ ]);
10
+ });
11
+
12
+ it('la simulación lleva un vendedor modelo y un producto, y devuelve venta siguiente, progresión y corte', () => {
13
+ const request: RetailCommission.SimulateRulesRequest = {
14
+ ruleIds: [],
15
+ profile: { unitsInWeek: 7, placementCents: 2_800_000, distinctSkus: 2 },
16
+ product: { sku: 'PHONE-A', category: RetailCatalog.ProductCategoryEnum.PHONE, tier: RetailCatalog.ProductTierEnum.MID, netCents: 350_000 },
17
+ storeId: null,
18
+ date: null,
19
+ };
20
+ const response: RetailCommission.SimulateRulesResponse = {
21
+ date: '2026-10-01',
22
+ nextSale: {
23
+ position: 8,
24
+ subtotalBonusCents: 15_000,
25
+ rules: [
26
+ { ruleId: 'RC-02', name: 'Extendido', amountCents: 15_000, skipped: null },
27
+ { ruleId: 'RC-24', name: 'Accesorios', amountCents: 0, skipped: RetailCommission.CommissionSimulationSkipEnum.OUT_OF_SCOPE },
28
+ ],
29
+ },
30
+ progression: [{ position: 1, subtotalBonusCents: 8_000, cumulativeBonusCents: 8_000 }],
31
+ week: {
32
+ subtotalBonusCents: 70_000,
33
+ multiplierBps: 15_000,
34
+ multiplierBeforeCapBps: 15_000,
35
+ multipliers: [],
36
+ thresholdBonusCents: 0,
37
+ thresholds: [],
38
+ releasedCents: 105_000,
39
+ },
40
+ };
41
+ expect(request.profile.unitsInWeek + 1).toBe(response.nextSale.position);
42
+ });
43
+ });
@@ -0,0 +1,29 @@
1
+ import 'reflect-metadata';
2
+ import * as RetailCommission from '../../../src/retailCommission';
3
+
4
+ describe('retailCommission — mis comisiones', () => {
5
+ it('el vendedor ve su semana, mes, año, sus últimos cortes y las reglas vigentes', () => {
6
+ const mine: RetailCommission.GetMyCommissionsResponse = {
7
+ seller: { sellerId: 'V-01', name: 'Ana López', storeId: 'ST-1' },
8
+ period: { periodId: '2026-10-05', periodStart: '2026-10-05', periodEnd: '2026-10-11' },
9
+ week: null,
10
+ month: { periodsCount: 1, salesCount: 7, unitsCount: 9, netCents: 157_250 },
11
+ year: { periodsCount: 20, salesCount: 140, unitsCount: 180, netCents: 2_400_000 },
12
+ history: [{ periodId: '2026-09-28', salesCount: 7, unitsCount: 9, placementCents: 1_000_000, paidCents: 157_250 }],
13
+ rules: [
14
+ {
15
+ ruleId: 'RC-07',
16
+ name: 'Mix de productos',
17
+ description: null,
18
+ levers: { bonoPorVenta: null, bonoUmbral: { amountCents: 20_000 }, multiplicador: null, bonoPorCuponPagado: null },
19
+ scope: { tipos: [], gamas: [], skus: [], tiendas: [] },
20
+ condition: null,
21
+ effectiveFrom: '2026-09-01',
22
+ effectiveTo: null,
23
+ },
24
+ ],
25
+ multiplierCapBps: 25_000,
26
+ };
27
+ expect(mine.history[0]?.paidCents).toBe(mine.month.netCents);
28
+ });
29
+ });
@@ -2,12 +2,16 @@ import 'reflect-metadata';
2
2
  import { plainToInstance, type ClassConstructor } from 'class-transformer';
3
3
  import { validateSync, type ValidationError } from 'class-validator';
4
4
  import {
5
+ ExternalProviderEnum,
6
+ LinePaymentTypeEnum,
5
7
  RetailCommissionInboundMessage,
8
+ RetailCommissionInboundMessageTypeEnum,
6
9
  RetailPromotionInboundMessage,
7
10
  RetailPromotionInboundMessageTypeEnum,
11
+ SaleTypeEnum,
8
12
  WizardLineRoleEnum,
9
13
  } from '../../../src/retailWizard';
10
- import { ItemTypeEnum } from '../../../src/retailCatalog';
14
+ import { ItemTypeEnum, ProductCategoryEnum, ProductTierEnum } from '../../../src/retailCatalog';
11
15
 
12
16
  function paths(errors: ValidationError[], prefix = ''): string[] {
13
17
  return errors.flatMap((e) => {
@@ -133,33 +137,126 @@ describe('RetailPromotionInboundMessage', () => {
133
137
  });
134
138
  });
135
139
 
140
+ describe('RetailCommissionInboundMessageTypeEnum', () => {
141
+ it('distingue venta y cancelación', () => {
142
+ expect(Object.values(RetailCommissionInboundMessageTypeEnum)).toEqual(['SALE_COMPLETED', 'SALE_CANCELLED']);
143
+ });
144
+ });
145
+
136
146
  describe('RetailCommissionInboundMessage', () => {
147
+ const phone = {
148
+ lineId: 'L1',
149
+ sku: 'PHONE-1',
150
+ qty: 1,
151
+ netCents: 299900,
152
+ itemType: ItemTypeEnum.PRODUCT,
153
+ category: ProductCategoryEnum.PHONE,
154
+ tier: ProductTierEnum.HIGH,
155
+ brand: 'Samsung',
156
+ paymentType: LinePaymentTypeEnum.CREDIT,
157
+ };
158
+ const service = {
159
+ lineId: 'L2',
160
+ sku: 'SVC-1',
161
+ qty: 1,
162
+ netCents: 9900,
163
+ itemType: ItemTypeEnum.SERVICE,
164
+ category: null,
165
+ tier: null,
166
+ brand: null,
167
+ paymentType: LinePaymentTypeEnum.CASH,
168
+ };
169
+ const gift = {
170
+ ...service,
171
+ lineId: 'L3',
172
+ sku: 'CASE-1',
173
+ netCents: 0,
174
+ itemType: ItemTypeEnum.PRODUCT,
175
+ category: ProductCategoryEnum.ACCESSORY,
176
+ role: WizardLineRoleEnum.GIFT,
177
+ };
137
178
  const ok = {
179
+ type: RetailCommissionInboundMessageTypeEnum.SALE_COMPLETED,
138
180
  tenantId: 't',
139
181
  retailerId: 'ret',
140
182
  storeId: 'st',
141
183
  saleId: 'sale-1',
142
184
  sessionId: 'ses-1',
185
+ sellerRetailUserId: 'seller-1',
186
+ saleType: SaleTypeEnum.CREDIT,
187
+ externalProvider: null,
188
+ creditId: 'cred-1',
143
189
  soldAt: '2026-09-28T20:00:00.000Z',
144
- lines: [
145
- { sku: 'PHONE-1', qty: 1, netCents: 299900, itemType: ItemTypeEnum.PRODUCT },
146
- { sku: 'SVC-1', qty: 1, netCents: 9900, itemType: ItemTypeEnum.SERVICE },
147
- ],
190
+ occurredAt: '2026-09-28T20:00:00.000Z',
191
+ lines: [phone, service, gift],
148
192
  };
149
193
 
150
- it('acepta la venta con sus líneas', () => expect(errorsOf(RetailCommissionInboundMessage, ok)).toEqual([]));
194
+ it('acepta la venta a crédito con sus líneas', () => expect(errorsOf(RetailCommissionInboundMessage, ok)).toEqual([]));
151
195
 
152
- it('exige la identidad de la venta', () => {
153
- const { saleId: _s, soldAt: _so, ...rest } = ok;
154
- expect(errorsOf(RetailCommissionInboundMessage, rest)).toEqual(expect.arrayContaining(['saleId', 'soldAt']));
196
+ it('acepta una derivada a financiera externa sin crédito', () =>
197
+ expect(
198
+ errorsOf(RetailCommissionInboundMessage, {
199
+ ...ok,
200
+ saleType: SaleTypeEnum.EXTERNAL_REFERRAL,
201
+ externalProvider: ExternalProviderEnum.PAYJOY,
202
+ creditId: null,
203
+ }),
204
+ ).toEqual([]));
205
+
206
+ it('acepta una cancelación con su motivo', () =>
207
+ expect(
208
+ errorsOf(RetailCommissionInboundMessage, {
209
+ ...ok,
210
+ type: RetailCommissionInboundMessageTypeEnum.SALE_CANCELLED,
211
+ occurredAt: '2026-09-29T15:00:00.000Z',
212
+ cancelReason: 'el cliente se arrepintió',
213
+ }),
214
+ ).toEqual([]));
215
+
216
+ it('la cancelación sin motivo también vale', () => {
217
+ const cancelled = { ...ok, type: RetailCommissionInboundMessageTypeEnum.SALE_CANCELLED };
218
+ expect(errorsOf(RetailCommissionInboundMessage, cancelled)).toEqual([]);
219
+ expect(errorsOf(RetailCommissionInboundMessage, { ...cancelled, cancelReason: '' })).toEqual([]);
220
+ });
221
+
222
+ it('exige el tipo de mensaje, el vendedor, el tipo de venta y la identidad', () => {
223
+ const { type: _t, sellerRetailUserId: _s, saleType: _st, saleId: _id, soldAt: _so, occurredAt: _o, ...rest } = ok;
224
+ expect(errorsOf(RetailCommissionInboundMessage, rest)).toEqual(
225
+ expect.arrayContaining(['type', 'sellerRetailUserId', 'saleType', 'saleId', 'soldAt', 'occurredAt']),
226
+ );
155
227
  });
156
228
 
157
229
  it('rechaza sin líneas', () => expect(errorsOf(RetailCommissionInboundMessage, { ...ok, lines: [] })).toContain('lines'));
158
230
 
159
231
  it('valida cada línea', () =>
160
232
  expect(
161
- errorsOf(RetailCommissionInboundMessage, { ...ok, lines: [{ sku: 'X', qty: 0, netCents: -1, itemType: 'KIT' }] }),
162
- ).toEqual(expect.arrayContaining(['lines.0.qty', 'lines.0.netCents', 'lines.0.itemType'])));
233
+ errorsOf(RetailCommissionInboundMessage, {
234
+ ...ok,
235
+ lines: [
236
+ {
237
+ lineId: '',
238
+ sku: 'X',
239
+ qty: 0,
240
+ netCents: -1,
241
+ itemType: 'KIT',
242
+ category: 'CELULAR',
243
+ tier: 'ORO',
244
+ brand: null,
245
+ paymentType: 'TARJETA',
246
+ },
247
+ ],
248
+ }),
249
+ ).toEqual(
250
+ expect.arrayContaining([
251
+ 'lines.0.lineId',
252
+ 'lines.0.qty',
253
+ 'lines.0.netCents',
254
+ 'lines.0.itemType',
255
+ 'lines.0.category',
256
+ 'lines.0.tier',
257
+ 'lines.0.paymentType',
258
+ ]),
259
+ ));
163
260
  });
164
261
 
165
262
  describe('RetailPromotionInboundMessage — tipos nullables y ids no vacíos', () => {
@@ -0,0 +1,18 @@
1
+ import type { LoanInstallmentStatusEnum } from '../enums/LoanInstallmentStatusEnum';
2
+ /** `eventType` del outbox cuando una cuota queda pagada. */
3
+ export declare const INSTALLMENT_PAID_EVENT_TYPE = "InstallmentPaidV1";
4
+ /**
5
+ * Payload de `InstallmentPaidV1`: una cuota que pasó a `PAID` o `PAID_IN_ADVANCE` por cualquier
6
+ * camino (cargo de la cuota, revisión, adelanto, liquidación o recuperación). Se escribe al outbox en
7
+ * la misma transacción que la cuota. Sin PII.
8
+ */
9
+ export interface InstallmentPaidV1Payload {
10
+ creditId: string;
11
+ installmentNumber: number;
12
+ /** Lo pagado de la cuota en total (capital + interés + IVA), en centavos. */
13
+ paidCents: number;
14
+ /** Instante del pago (ISO): decide el corte de la renta. */
15
+ paidAt: string;
16
+ installmentStatus: LoanInstallmentStatusEnum;
17
+ paymentId: string;
18
+ }
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INSTALLMENT_PAID_EVENT_TYPE = void 0;
4
+ /** `eventType` del outbox cuando una cuota queda pagada. */
5
+ exports.INSTALLMENT_PAID_EVENT_TYPE = 'InstallmentPaidV1';
@@ -6,6 +6,7 @@ export * from './dtos/LoanBorrower';
6
6
  export * from './dtos/LoanBeneficiary';
7
7
  export * from './dtos/LoanCredit';
8
8
  export * from './dtos/LoanInstallment';
9
+ export * from './dtos/InstallmentPaidEvent';
9
10
  export * from './dtos/LoanContract';
10
11
  export * from './dtos/ActivationChecklist';
11
12
  export * from './dtos/AppliedPromotionSnapshot';