@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.
- package/_test_/unit/loanCredit/installmentPaidEvent.test.ts +17 -0
- package/_test_/unit/retailCommission/commissionPayout.test.ts +104 -0
- package/_test_/unit/retailCommission/commissionRent.test.ts +33 -0
- package/_test_/unit/retailCommission/commissionRules.test.ts +249 -0
- package/_test_/unit/retailCommission/commissionSimulation.test.ts +43 -0
- package/_test_/unit/retailCommission/myCommissions.test.ts +29 -0
- package/_test_/unit/retailWizard/ventaSagaMessages.test.ts +108 -11
- package/bin/loanCredit/dtos/InstallmentPaidEvent.d.ts +18 -0
- package/bin/loanCredit/dtos/InstallmentPaidEvent.js +5 -0
- package/bin/loanCredit/index.d.ts +1 -0
- package/bin/loanCredit/index.js +1 -0
- package/bin/retailCommission/dtos/CommissionAccrual.d.ts +36 -0
- package/bin/retailCommission/dtos/CommissionApi.d.ts +241 -2
- package/bin/retailCommission/dtos/CommissionCalcTrace.d.ts +10 -0
- package/bin/retailCommission/dtos/CommissionCouponAccrual.d.ts +31 -0
- package/bin/retailCommission/dtos/CommissionCouponAccrual.js +2 -0
- package/bin/retailCommission/dtos/CommissionPayout.d.ts +84 -0
- package/bin/retailCommission/dtos/CommissionRule.d.ts +119 -0
- package/bin/retailCommission/dtos/CommissionRule.js +2 -0
- package/bin/retailCommission/enums/CommissionAggregateByEnum.d.ts +14 -0
- package/bin/retailCommission/enums/CommissionAggregateByEnum.js +18 -0
- package/bin/retailCommission/enums/CommissionBonusModeEnum.d.ts +10 -0
- package/bin/retailCommission/enums/CommissionBonusModeEnum.js +14 -0
- package/bin/retailCommission/enums/CommissionCalcStepEnum.d.ts +3 -1
- package/bin/retailCommission/enums/CommissionCalcStepEnum.js +2 -0
- package/bin/retailCommission/enums/CommissionMetricEnum.d.ts +14 -0
- package/bin/retailCommission/enums/CommissionMetricEnum.js +18 -0
- package/bin/retailCommission/enums/CommissionOperatorEnum.d.ts +15 -0
- package/bin/retailCommission/enums/CommissionOperatorEnum.js +19 -0
- package/bin/retailCommission/enums/CommissionRuleStatusEnum.d.ts +16 -0
- package/bin/retailCommission/enums/CommissionRuleStatusEnum.js +20 -0
- package/bin/retailCommission/enums/CommissionSeriesByEnum.d.ts +14 -0
- package/bin/retailCommission/enums/CommissionSeriesByEnum.js +18 -0
- package/bin/retailCommission/enums/CommissionSimulationSkipEnum.d.ts +14 -0
- package/bin/retailCommission/enums/CommissionSimulationSkipEnum.js +18 -0
- package/bin/retailCommission/enums/CommissionWindowEnum.d.ts +12 -0
- package/bin/retailCommission/enums/CommissionWindowEnum.js +16 -0
- package/bin/retailCommission/index.d.ts +10 -0
- package/bin/retailCommission/index.js +10 -0
- package/bin/retailWizard/enums/RetailCommissionInboundMessageTypeEnum.d.ts +5 -0
- package/bin/retailWizard/enums/RetailCommissionInboundMessageTypeEnum.js +9 -0
- package/bin/retailWizard/events/RetailCommissionInboundMessage.d.ts +27 -1
- package/bin/retailWizard/events/RetailCommissionInboundMessage.js +70 -1
- package/bin/retailWizard/index.d.ts +1 -0
- package/bin/retailWizard/index.js +1 -0
- package/package.json +1 -1
- package/src/loanCredit/dtos/InstallmentPaidEvent.ts +20 -0
- package/src/loanCredit/index.ts +1 -0
- package/src/retailCommission/dtos/CommissionAccrual.ts +38 -0
- package/src/retailCommission/dtos/CommissionApi.ts +276 -2
- package/src/retailCommission/dtos/CommissionCalcTrace.ts +7 -0
- package/src/retailCommission/dtos/CommissionCouponAccrual.ts +33 -0
- package/src/retailCommission/dtos/CommissionPayout.ts +91 -0
- package/src/retailCommission/dtos/CommissionRule.ts +132 -0
- package/src/retailCommission/enums/CommissionAggregateByEnum.ts +14 -0
- package/src/retailCommission/enums/CommissionBonusModeEnum.ts +10 -0
- package/src/retailCommission/enums/CommissionCalcStepEnum.ts +2 -0
- package/src/retailCommission/enums/CommissionMetricEnum.ts +14 -0
- package/src/retailCommission/enums/CommissionOperatorEnum.ts +15 -0
- package/src/retailCommission/enums/CommissionRuleStatusEnum.ts +16 -0
- package/src/retailCommission/enums/CommissionSeriesByEnum.ts +14 -0
- package/src/retailCommission/enums/CommissionSimulationSkipEnum.ts +14 -0
- package/src/retailCommission/enums/CommissionWindowEnum.ts +12 -0
- package/src/retailCommission/index.ts +10 -0
- package/src/retailWizard/enums/RetailCommissionInboundMessageTypeEnum.ts +5 -0
- package/src/retailWizard/events/RetailCommissionInboundMessage.ts +75 -2
- 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
|
-
|
|
145
|
-
|
|
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('
|
|
153
|
-
|
|
154
|
-
|
|
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, {
|
|
162
|
-
|
|
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
|
+
}
|
|
@@ -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';
|