@dainamite/cpq 0.2.2 → 0.4.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/CHANGELOG.md +62 -0
- package/MIGRATION.md +34 -1
- package/README.md +17 -12
- package/dist/modules/cpq/api/product-attributes/route.js +28 -53
- package/dist/modules/cpq/api/product-attributes/route.js.map +2 -2
- package/dist/modules/cpq/api/product-charges/route.js +12 -2
- package/dist/modules/cpq/api/product-charges/route.js.map +2 -2
- package/dist/modules/cpq/api/product-offerings/route.js +2 -0
- package/dist/modules/cpq/api/product-offerings/route.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +128 -59
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +11 -11
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +55 -1
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
- package/dist/modules/cpq/cli.js +58 -4
- package/dist/modules/cpq/cli.js.map +3 -3
- package/dist/modules/cpq/data/charge-pricing.js +12 -0
- package/dist/modules/cpq/data/charge-pricing.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +3 -0
- package/dist/modules/cpq/data/entities.js.map +2 -2
- package/dist/modules/cpq/data/validators.js +36 -15
- package/dist/modules/cpq/data/validators.js.map +2 -2
- package/dist/modules/cpq/events.js +7 -0
- package/dist/modules/cpq/events.js.map +2 -2
- package/dist/modules/cpq/lib/seeds/demo.js +103 -0
- package/dist/modules/cpq/lib/seeds/demo.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js +28 -0
- package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +130 -76
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqPricingService.js +53 -29
- package/dist/modules/cpq/services/cpqPricingService.js.map +3 -3
- package/dist/modules/cpq/services/types.js.map +2 -2
- package/package.json +11 -9
- package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +34 -4
- package/src/modules/cpq/__tests__/package-isolation.test.ts +8 -3
- package/src/modules/cpq/api/product-attributes/__tests__/serializeAttribute.test.ts +62 -0
- package/src/modules/cpq/api/product-attributes/route.ts +34 -52
- package/src/modules/cpq/api/product-charges/route.ts +23 -2
- package/src/modules/cpq/api/product-offerings/route.ts +2 -0
- package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +151 -62
- package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -22
- package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +73 -1
- package/src/modules/cpq/cli.ts +83 -4
- package/src/modules/cpq/data/__tests__/charge-pricing.test.ts +49 -0
- package/src/modules/cpq/data/__tests__/validators.test.ts +60 -5
- package/src/modules/cpq/data/charge-pricing.ts +48 -0
- package/src/modules/cpq/data/entities.ts +10 -0
- package/src/modules/cpq/data/validators.ts +56 -26
- package/src/modules/cpq/events.ts +11 -3
- package/src/modules/cpq/lib/seeds/__tests__/demo.test.ts +78 -0
- package/src/modules/cpq/lib/seeds/demo.ts +145 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.ts +57 -0
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +172 -1
- package/src/modules/cpq/services/cpqOrderService.ts +214 -103
- package/src/modules/cpq/services/cpqPricingService.ts +82 -34
- package/src/modules/cpq/services/types.ts +4 -1
|
@@ -42,7 +42,7 @@ interface ChargeStub {
|
|
|
42
42
|
name: string
|
|
43
43
|
description?: string | null
|
|
44
44
|
chargeType: 'nrc' | 'mrc' | 'usage'
|
|
45
|
-
pricingMethod: 'flat' | 'fixed' | 'tiered' | 'per_unit'
|
|
45
|
+
pricingMethod: 'flat' | 'fixed' | 'tiered' | 'per_unit' | 'table'
|
|
46
46
|
pricingTableId?: string | null
|
|
47
47
|
priceColumnKey?: string | null
|
|
48
48
|
fixedPrice?: string | null
|
|
@@ -630,6 +630,177 @@ describe('DefaultCpqPricingService.calculateCharge — tiered pricing', () => {
|
|
|
630
630
|
expect(result.breakdown?.tiers).toHaveLength(2)
|
|
631
631
|
expect(result.breakdown?.tiers[1].quantity).toBe(15)
|
|
632
632
|
})
|
|
633
|
+
|
|
634
|
+
it('restricts tier allocation to the dimension-matched slice (tier lookup)', async () => {
|
|
635
|
+
em.findOne.mockResolvedValueOnce({ id: 'table-1', dimensions: [{ key: 'tier' }] })
|
|
636
|
+
em.find.mockResolvedValueOnce([
|
|
637
|
+
// starter tiers — must be ignored when tier = enterprise
|
|
638
|
+
{ tierNumber: 1, rangeFrom: 0, rangeTo: 10, prices: { monthly: 19 }, currencyCode: 'EUR', dimensionValues: { tier: 'starter' } },
|
|
639
|
+
{ tierNumber: 2, rangeFrom: 10, rangeTo: null, prices: { monthly: 17 }, currencyCode: 'EUR', dimensionValues: { tier: 'starter' } },
|
|
640
|
+
// enterprise tiers
|
|
641
|
+
{ tierNumber: 1, rangeFrom: 0, rangeTo: 100, prices: { monthly: 89 }, currencyCode: 'EUR', dimensionValues: { tier: 'enterprise' } },
|
|
642
|
+
{ tierNumber: 2, rangeFrom: 100, rangeTo: 500, prices: { monthly: 82 }, currencyCode: 'EUR', dimensionValues: { tier: 'enterprise' } },
|
|
643
|
+
{ tierNumber: 3, rangeFrom: 500, rangeTo: null, prices: { monthly: 76 }, currencyCode: 'EUR', dimensionValues: { tier: 'enterprise' } },
|
|
644
|
+
])
|
|
645
|
+
|
|
646
|
+
const result = await service.calculateCharge({
|
|
647
|
+
charge: makeCharge({
|
|
648
|
+
code: 'mrc',
|
|
649
|
+
name: 'Subscription Fee',
|
|
650
|
+
chargeType: 'mrc',
|
|
651
|
+
pricingMethod: 'tiered',
|
|
652
|
+
pricingTableId: 'table-1',
|
|
653
|
+
priceColumnKey: 'monthly',
|
|
654
|
+
quantityAttributeCode: 'seats',
|
|
655
|
+
}) as unknown as never,
|
|
656
|
+
configuration: { tier: 'enterprise', seats: 200 },
|
|
657
|
+
tenantId: SCOPE.tenantId,
|
|
658
|
+
organizationId: SCOPE.organizationId,
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
// enterprise slice only: 100 @ 89 + 100 @ 82 = 8900 + 8200 = 17100.
|
|
662
|
+
// Without dimension filtering the starter rows would pollute the allocation.
|
|
663
|
+
expect(result.totalPrice).toBe(17100)
|
|
664
|
+
expect(result.quantity).toBe(200)
|
|
665
|
+
expect(result.currencyCode).toBe('EUR')
|
|
666
|
+
expect(result.breakdown?.tiers).toHaveLength(2)
|
|
667
|
+
expect(result.breakdown?.tiers[0].pricePerUnit).toBe(89)
|
|
668
|
+
expect(result.breakdown?.tiers[1].pricePerUnit).toBe(82)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('returns 0 when the configured dimension value matches no entry', async () => {
|
|
672
|
+
em.findOne.mockResolvedValueOnce({ id: 'table-1', dimensions: [{ key: 'tier' }] })
|
|
673
|
+
em.find.mockResolvedValueOnce([
|
|
674
|
+
{ tierNumber: 1, rangeFrom: 0, rangeTo: 100, prices: { monthly: 89 }, currencyCode: 'EUR', dimensionValues: { tier: 'enterprise' } },
|
|
675
|
+
])
|
|
676
|
+
|
|
677
|
+
const result = await service.calculateCharge({
|
|
678
|
+
charge: makeCharge({
|
|
679
|
+
code: 'mrc',
|
|
680
|
+
name: 'Subscription Fee',
|
|
681
|
+
chargeType: 'mrc',
|
|
682
|
+
pricingMethod: 'tiered',
|
|
683
|
+
pricingTableId: 'table-1',
|
|
684
|
+
priceColumnKey: 'monthly',
|
|
685
|
+
quantityAttributeCode: 'seats',
|
|
686
|
+
}) as unknown as never,
|
|
687
|
+
configuration: { tier: 'pro', seats: 50 },
|
|
688
|
+
tenantId: SCOPE.tenantId,
|
|
689
|
+
organizationId: SCOPE.organizationId,
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
expect(result.totalPrice).toBe(0)
|
|
693
|
+
// No dimension row matched → unpriced, falls back to the base shape
|
|
694
|
+
// (quantity 1, no tier breakdown), consistent with flat / per_unit.
|
|
695
|
+
expect(result.quantity).toBe(1)
|
|
696
|
+
expect(result.breakdown?.tiers ?? []).toHaveLength(0)
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it('requires every dimension to match (multi-dimension tiered lookup)', async () => {
|
|
700
|
+
em.findOne.mockResolvedValueOnce({ id: 'table-1', dimensions: [{ key: 'tier' }, { key: 'region' }] })
|
|
701
|
+
em.find.mockResolvedValueOnce([
|
|
702
|
+
// right tier, wrong region — must be excluded
|
|
703
|
+
{ tierNumber: 1, rangeFrom: 0, rangeTo: null, prices: { monthly: 99 }, currencyCode: 'EUR', dimensionValues: { tier: 'enterprise', region: 'us' } },
|
|
704
|
+
// both dimensions match — used
|
|
705
|
+
{ tierNumber: 1, rangeFrom: 0, rangeTo: 100, prices: { monthly: 80 }, currencyCode: 'EUR', dimensionValues: { tier: 'enterprise', region: 'eu' } },
|
|
706
|
+
{ tierNumber: 2, rangeFrom: 100, rangeTo: null, prices: { monthly: 70 }, currencyCode: 'EUR', dimensionValues: { tier: 'enterprise', region: 'eu' } },
|
|
707
|
+
])
|
|
708
|
+
|
|
709
|
+
const result = await service.calculateCharge({
|
|
710
|
+
charge: makeCharge({
|
|
711
|
+
code: 'mrc',
|
|
712
|
+
name: 'Subscription Fee',
|
|
713
|
+
chargeType: 'mrc',
|
|
714
|
+
pricingMethod: 'tiered',
|
|
715
|
+
pricingTableId: 'table-1',
|
|
716
|
+
priceColumnKey: 'monthly',
|
|
717
|
+
quantityAttributeCode: 'seats',
|
|
718
|
+
}) as unknown as never,
|
|
719
|
+
configuration: { tier: 'enterprise', region: 'eu', seats: 150 },
|
|
720
|
+
tenantId: SCOPE.tenantId,
|
|
721
|
+
organizationId: SCOPE.organizationId,
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
// eu slice only: 100 @ 80 + 50 @ 70 = 11500. The tier-matching-but-region-
|
|
725
|
+
// mismatching row (@99) must not leak in.
|
|
726
|
+
expect(result.totalPrice).toBe(11500)
|
|
727
|
+
expect(result.breakdown?.tiers).toHaveLength(2)
|
|
728
|
+
expect(result.breakdown?.tiers[0].pricePerUnit).toBe(80)
|
|
729
|
+
expect(result.breakdown?.tiers[1].pricePerUnit).toBe(70)
|
|
730
|
+
})
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
describe('DefaultCpqPricingService.calculateCharge — charge model × pricing source (XD-297)', () => {
|
|
734
|
+
let em: MockEm
|
|
735
|
+
let service: DefaultCpqPricingService
|
|
736
|
+
|
|
737
|
+
beforeEach(() => {
|
|
738
|
+
em = createMockEm()
|
|
739
|
+
service = new DefaultCpqPricingService(em as unknown as never)
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it('per_unit + fixed price → quantity × fixedPrice (no table)', async () => {
|
|
743
|
+
const result = await service.calculateCharge({
|
|
744
|
+
charge: {
|
|
745
|
+
...makeCharge({ code: 'seat', name: 'Seat', chargeType: 'mrc', pricingMethod: 'fixed', fixedPrice: '5', quantityAttributeCode: 'seats' }),
|
|
746
|
+
chargeModel: 'per_unit',
|
|
747
|
+
} as unknown as never,
|
|
748
|
+
configuration: { seats: 20 },
|
|
749
|
+
tenantId: SCOPE.tenantId,
|
|
750
|
+
organizationId: SCOPE.organizationId,
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
expect(em.findOne).not.toHaveBeenCalled()
|
|
754
|
+
expect(result.unitPrice).toBe(5)
|
|
755
|
+
expect(result.quantity).toBe(20)
|
|
756
|
+
expect(result.totalPrice).toBe(100)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it('volume + table → whole quantity at the rate of the tier containing it', async () => {
|
|
760
|
+
em.findOne.mockResolvedValueOnce({ id: 'table-1', dimensions: [] })
|
|
761
|
+
em.find.mockResolvedValueOnce([
|
|
762
|
+
{ tierNumber: 1, rangeFrom: 1, rangeTo: 100, prices: { rate: 10 }, currencyCode: 'USD', dimensionValues: {} },
|
|
763
|
+
{ tierNumber: 2, rangeFrom: 101, rangeTo: 500, prices: { rate: 8 }, currencyCode: 'USD', dimensionValues: {} },
|
|
764
|
+
{ tierNumber: 3, rangeFrom: 501, rangeTo: null, prices: { rate: 6 }, currencyCode: 'USD', dimensionValues: {} },
|
|
765
|
+
])
|
|
766
|
+
|
|
767
|
+
const result = await service.calculateCharge({
|
|
768
|
+
charge: {
|
|
769
|
+
...makeCharge({ code: 'vol', name: 'Vol', chargeType: 'mrc', pricingMethod: 'table', pricingTableId: 'table-1', priceColumnKey: 'rate', quantityAttributeCode: 'units' }),
|
|
770
|
+
chargeModel: 'volume',
|
|
771
|
+
} as unknown as never,
|
|
772
|
+
configuration: { units: 150 },
|
|
773
|
+
tenantId: SCOPE.tenantId,
|
|
774
|
+
organizationId: SCOPE.organizationId,
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
// 150 falls in tier 2 (101–500) @ 8 → ALL 150 units billed at 8 = 1200
|
|
778
|
+
// (tiered would be 100@10 + 50@8 = 1400 — confirms volume ≠ tiered).
|
|
779
|
+
expect(result.unitPrice).toBe(8)
|
|
780
|
+
expect(result.quantity).toBe(150)
|
|
781
|
+
expect(result.totalPrice).toBe(1200)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it('flat + table (new split shape) reads the single matched price', async () => {
|
|
785
|
+
em.findOne.mockResolvedValueOnce({ id: 'table-1', dimensions: [{ key: 'region' }] })
|
|
786
|
+
em.find.mockResolvedValueOnce([
|
|
787
|
+
{ dimensionValues: { region: 'eu' }, prices: { list: 42 }, currencyCode: 'EUR', rangeFrom: null, rangeTo: null, tierNumber: null },
|
|
788
|
+
])
|
|
789
|
+
|
|
790
|
+
const result = await service.calculateCharge({
|
|
791
|
+
charge: {
|
|
792
|
+
...makeCharge({ code: 'plat', name: 'Platform', chargeType: 'mrc', pricingMethod: 'table', pricingTableId: 'table-1', priceColumnKey: 'list' }),
|
|
793
|
+
chargeModel: 'flat',
|
|
794
|
+
} as unknown as never,
|
|
795
|
+
configuration: { region: 'eu' },
|
|
796
|
+
tenantId: SCOPE.tenantId,
|
|
797
|
+
organizationId: SCOPE.organizationId,
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
expect(result.unitPrice).toBe(42)
|
|
801
|
+
expect(result.quantity).toBe(1)
|
|
802
|
+
expect(result.totalPrice).toBe(42)
|
|
803
|
+
})
|
|
633
804
|
})
|
|
634
805
|
|
|
635
806
|
describe('DefaultCpqPricingService — price rules application', () => {
|
|
@@ -374,12 +374,15 @@ export class DefaultCpqOrderService {
|
|
|
374
374
|
: null
|
|
375
375
|
|
|
376
376
|
const quoteType = sourceQuote?.quoteType ?? 'new'
|
|
377
|
-
const
|
|
377
|
+
const postCommitEvents: Array<() => Promise<void>> = []
|
|
378
378
|
|
|
379
379
|
if (quoteType === 'new') {
|
|
380
|
-
await this.createInventoryFromOrder(orderConfig, orderLines, scope)
|
|
380
|
+
const inventory = await this.createInventoryFromOrder(orderConfig, orderLines, scope)
|
|
381
|
+
if (inventory) {
|
|
382
|
+
postCommitEvents.push(() => emitActivatedEvent(orderConfig, inventory, scope))
|
|
383
|
+
}
|
|
381
384
|
} else {
|
|
382
|
-
|
|
385
|
+
postCommitEvents.push(
|
|
383
386
|
...(await this.applyArcOrder(orderConfig, sourceQuote!, orderLines, scope)),
|
|
384
387
|
)
|
|
385
388
|
}
|
|
@@ -391,8 +394,8 @@ export class DefaultCpqOrderService {
|
|
|
391
394
|
|
|
392
395
|
await em.flush()
|
|
393
396
|
|
|
394
|
-
// Emit
|
|
395
|
-
for (const fire of
|
|
397
|
+
// Emit lifecycle events AFTER the activation transaction commits.
|
|
398
|
+
for (const fire of postCommitEvents) {
|
|
396
399
|
try {
|
|
397
400
|
await fire()
|
|
398
401
|
} catch (err) {
|
|
@@ -490,9 +493,7 @@ export class DefaultCpqOrderService {
|
|
|
490
493
|
},
|
|
491
494
|
scope,
|
|
492
495
|
)
|
|
493
|
-
events.push(() =>
|
|
494
|
-
emitArcEvent('cpq.subscription.amended', result, eventBaseEnvelope),
|
|
495
|
-
)
|
|
496
|
+
events.push(() => emitAmendedEvent(result, eventBaseEnvelope))
|
|
496
497
|
}
|
|
497
498
|
} else if (quoteType === 'cancel') {
|
|
498
499
|
for (const target of targets) {
|
|
@@ -509,15 +510,7 @@ export class DefaultCpqOrderService {
|
|
|
509
510
|
},
|
|
510
511
|
scope,
|
|
511
512
|
)
|
|
512
|
-
events.push(() =>
|
|
513
|
-
emitArcEvent('cpq.subscription.cancelled', result, {
|
|
514
|
-
...eventBaseEnvelope,
|
|
515
|
-
etfAmount: sourceQuote.arcEtfAmount ?? null,
|
|
516
|
-
etfCurrency: sourceQuote.arcEtfCurrency ?? null,
|
|
517
|
-
reasonCode: sourceQuote.arcReasonCode ?? null,
|
|
518
|
-
reasonText: sourceQuote.arcReasonText ?? null,
|
|
519
|
-
}),
|
|
520
|
-
)
|
|
513
|
+
events.push(() => emitCancelledEvent(result, eventBaseEnvelope))
|
|
521
514
|
}
|
|
522
515
|
} else if (quoteType === 'renew') {
|
|
523
516
|
const standalone = targets.filter((t) => t.mergeAction === 'standalone')
|
|
@@ -547,15 +540,7 @@ export class DefaultCpqOrderService {
|
|
|
547
540
|
},
|
|
548
541
|
scope,
|
|
549
542
|
)
|
|
550
|
-
events.push(() =>
|
|
551
|
-
emitArcEvent('cpq.subscription.renewed', result, {
|
|
552
|
-
...eventBaseEnvelope,
|
|
553
|
-
term: {
|
|
554
|
-
newTermStart: target.newTermStart!.toISOString(),
|
|
555
|
-
newTermEnd: target.newTermEnd!.toISOString(),
|
|
556
|
-
},
|
|
557
|
-
}),
|
|
558
|
-
)
|
|
543
|
+
events.push(() => emitRenewedEvent(result, eventBaseEnvelope))
|
|
559
544
|
}
|
|
560
545
|
} else if (absorb.length >= 2 && standalone.length === 0) {
|
|
561
546
|
if (!sourceQuote.arcMergeNewTermStart || !sourceQuote.arcMergeNewTermEnd) {
|
|
@@ -593,7 +578,11 @@ export class DefaultCpqOrderService {
|
|
|
593
578
|
)
|
|
594
579
|
for (const sourceLog of merge.sourceChangeLogs) {
|
|
595
580
|
events.push(() =>
|
|
596
|
-
emitSupersededEvent(
|
|
581
|
+
emitSupersededEvent(
|
|
582
|
+
sourceLog,
|
|
583
|
+
iso10(merge.mergedSubscription.currentTermStart),
|
|
584
|
+
eventBaseEnvelope,
|
|
585
|
+
),
|
|
597
586
|
)
|
|
598
587
|
}
|
|
599
588
|
} else {
|
|
@@ -615,8 +604,8 @@ export class DefaultCpqOrderService {
|
|
|
615
604
|
orderConfig: CpqOrderConfiguration,
|
|
616
605
|
orderLines: CpqOrderLineConfiguration[],
|
|
617
606
|
scope: TenantScope,
|
|
618
|
-
): Promise<
|
|
619
|
-
if (orderLines.length === 0) return
|
|
607
|
+
): Promise<ActivatedInventory | null> {
|
|
608
|
+
if (orderLines.length === 0) return null
|
|
620
609
|
|
|
621
610
|
const specIds = [...new Set(orderLines.map((l) => l.specId).filter(Boolean))] as string[]
|
|
622
611
|
const specs = specIds.length > 0
|
|
@@ -641,7 +630,7 @@ export class DefaultCpqOrderService {
|
|
|
641
630
|
const hasSubscriptionContent = subscriptionLines.some(
|
|
642
631
|
(l) => Number(l.mrcTotal) > 0 || Number(l.nrcTotal) > 0,
|
|
643
632
|
)
|
|
644
|
-
if (!hasSubscriptionContent && assetLines.length === 0) return
|
|
633
|
+
if (!hasSubscriptionContent && assetLines.length === 0) return null
|
|
645
634
|
|
|
646
635
|
const subscriptionName = await this.buildSubscriptionName(orderConfig, orderLines, scope)
|
|
647
636
|
|
|
@@ -730,6 +719,22 @@ export class DefaultCpqOrderService {
|
|
|
730
719
|
scope,
|
|
731
720
|
)
|
|
732
721
|
}
|
|
722
|
+
|
|
723
|
+
// Pair each subscription line with its created subscription item —
|
|
724
|
+
// input for the `cpq.subscription.activated` billing-onboarding event.
|
|
725
|
+
const eventItems = subscriptionLines
|
|
726
|
+
.slice(0, allSubItems.length)
|
|
727
|
+
.map((line, i) => ({
|
|
728
|
+
subscriptionItemId: allSubItems[i].id,
|
|
729
|
+
productName: this.getLineName(line),
|
|
730
|
+
mrcTotal: Number(line.mrcTotal),
|
|
731
|
+
nrcTotal: Number(line.nrcTotal),
|
|
732
|
+
}))
|
|
733
|
+
// Only lines with a billable charge — a zero-value line yields
|
|
734
|
+
// no Billing Item anyway; emitting it would just make the
|
|
735
|
+
// connector create a needless shell account.
|
|
736
|
+
.filter((it) => it.mrcTotal > 0 || it.nrcTotal > 0)
|
|
737
|
+
return { subscriptionId: subscription.id, items: eventItems }
|
|
733
738
|
}
|
|
734
739
|
|
|
735
740
|
// ─── Get / List ─────────────────────────────────────────────
|
|
@@ -1124,107 +1129,213 @@ interface ArcEventEnvelope {
|
|
|
1124
1129
|
sourceOrderId: string
|
|
1125
1130
|
}
|
|
1126
1131
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
+
// ─── Subscription-activated event (new-sale billing onboarding) ──
|
|
1133
|
+
|
|
1134
|
+
type ActivatedInventory = {
|
|
1135
|
+
subscriptionId: string
|
|
1136
|
+
items: Array<{
|
|
1137
|
+
subscriptionItemId: string
|
|
1138
|
+
productName: string
|
|
1139
|
+
/** Line totals — CPQ pricing has already applied line quantity. */
|
|
1140
|
+
mrcTotal: number
|
|
1141
|
+
nrcTotal: number
|
|
1142
|
+
}>
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Emit `cpq.subscription.activated` after a new-sale order activation.
|
|
1147
|
+
*
|
|
1148
|
+
* Payload matches the `@dainamite/cpq-billing-connector`
|
|
1149
|
+
* `cpq-subscription-activated` subscriber contract: one CPQ charge per
|
|
1150
|
+
* non-zero MRC / NRC total on each subscription item. The connector
|
|
1151
|
+
* get-or-creates the Billing Account (keyed on `customerId`) and turns
|
|
1152
|
+
* each charge into a Billing Item.
|
|
1153
|
+
*/
|
|
1154
|
+
async function emitActivatedEvent(
|
|
1155
|
+
orderConfig: CpqOrderConfiguration,
|
|
1156
|
+
inventory: ActivatedInventory,
|
|
1157
|
+
scope: TenantScope,
|
|
1158
|
+
): Promise<void> {
|
|
1159
|
+
const items = inventory.items.map((it) => ({
|
|
1160
|
+
subscriptionItemId: it.subscriptionItemId,
|
|
1161
|
+
productName: it.productName,
|
|
1162
|
+
// `mrcTotal` / `nrcTotal` are line totals — CPQ pricing already
|
|
1163
|
+
// applied the line quantity. Quantity is therefore 1 here so
|
|
1164
|
+
// `unitPrice × quantity` stays internally consistent no matter
|
|
1165
|
+
// how the connector treats the multiplier.
|
|
1166
|
+
quantity: 1,
|
|
1167
|
+
charges: [
|
|
1168
|
+
...(it.mrcTotal > 0
|
|
1169
|
+
? [{ type: 'recurring', unitPrice: it.mrcTotal, description: 'Recurring charge' }]
|
|
1170
|
+
: []),
|
|
1171
|
+
...(it.nrcTotal > 0
|
|
1172
|
+
? [{ type: 'one_time', amount: it.nrcTotal, description: 'One-time charge' }]
|
|
1173
|
+
: []),
|
|
1174
|
+
],
|
|
1175
|
+
}))
|
|
1176
|
+
await emitCpqEvent('cpq.subscription.activated' as never, {
|
|
1177
|
+
subscriptionId: inventory.subscriptionId,
|
|
1178
|
+
customerId: orderConfig.customerId,
|
|
1179
|
+
organizationId: scope.organizationId,
|
|
1180
|
+
tenantId: scope.tenantId,
|
|
1181
|
+
currencyCode: orderConfig.currencyCode,
|
|
1182
|
+
timestamp: new Date().toISOString(),
|
|
1183
|
+
activationDate: new Date().toISOString().slice(0, 10),
|
|
1184
|
+
sourceQuoteId: orderConfig.sourceQuoteId ?? null,
|
|
1185
|
+
sourceOrderId: orderConfig.id,
|
|
1186
|
+
items,
|
|
1187
|
+
} as never)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Coerce a Date or `date`-column string to a `YYYY-MM-DD` string. */
|
|
1191
|
+
function iso10(value: Date | string | null | undefined): string | null {
|
|
1192
|
+
if (!value) return null
|
|
1193
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
|
1194
|
+
return String(value).slice(0, 10)
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
type ArcChargeItem = {
|
|
1198
|
+
subscriptionItemId: string
|
|
1199
|
+
productName: string
|
|
1200
|
+
quantity: number
|
|
1201
|
+
charges: Array<Record<string, unknown>>
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Build the connector's `addedItems` from a change log's `add` line
|
|
1206
|
+
* changes. `mrcAmount` / `nrcAmount` are line totals (CPQ pricing has
|
|
1207
|
+
* already applied quantity) → emit `quantity: 1` with the totals as the
|
|
1208
|
+
* charge rate, exactly as `emitActivatedEvent`.
|
|
1209
|
+
*/
|
|
1210
|
+
function buildArcAddedItems(changeLog: ApplyArcResult['changeLog']): ArcChargeItem[] {
|
|
1211
|
+
const after = changeLog.afterSnapshot as
|
|
1212
|
+
| { items?: Array<{ id?: string; name?: string }> }
|
|
1213
|
+
| null
|
|
1214
|
+
const nameById = new Map(
|
|
1215
|
+
(after?.items ?? []).map((i) => [String(i.id), i.name ?? '']),
|
|
1216
|
+
)
|
|
1217
|
+
const summary = (changeLog.lineChanges ?? []) as Array<{
|
|
1218
|
+
action?: string
|
|
1219
|
+
itemId?: string
|
|
1220
|
+
mrcAmount?: unknown
|
|
1221
|
+
nrcAmount?: unknown
|
|
1222
|
+
}>
|
|
1223
|
+
const out: ArcChargeItem[] = []
|
|
1224
|
+
for (const change of summary) {
|
|
1225
|
+
if (change.action !== 'add' || typeof change.itemId !== 'string') continue
|
|
1226
|
+
const mrc = Number(change.mrcAmount)
|
|
1227
|
+
const nrc = Number(change.nrcAmount)
|
|
1228
|
+
out.push({
|
|
1229
|
+
subscriptionItemId: change.itemId,
|
|
1230
|
+
productName: nameById.get(change.itemId) || 'Configured item',
|
|
1231
|
+
quantity: 1,
|
|
1232
|
+
charges: [
|
|
1233
|
+
...(mrc > 0
|
|
1234
|
+
? [{ type: 'recurring', unitPrice: mrc, description: 'Recurring charge' }]
|
|
1235
|
+
: []),
|
|
1236
|
+
...(nrc > 0
|
|
1237
|
+
? [{ type: 'one_time', amount: nrc, description: 'One-time charge' }]
|
|
1238
|
+
: []),
|
|
1239
|
+
],
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
return out
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Subscription-item ids removed by a change log's `cancel` line changes. */
|
|
1246
|
+
function buildArcRemovedItemIds(changeLog: ApplyArcResult['changeLog']): string[] {
|
|
1247
|
+
const summary = (changeLog.lineChanges ?? []) as Array<{
|
|
1248
|
+
action?: string
|
|
1249
|
+
itemId?: string
|
|
1250
|
+
}>
|
|
1251
|
+
return summary
|
|
1252
|
+
.filter((c) => c.action === 'cancel' && typeof c.itemId === 'string')
|
|
1253
|
+
.map((c) => c.itemId as string)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async function emitAmendedEvent(
|
|
1132
1257
|
result: ApplyArcResult,
|
|
1133
|
-
envelope: ArcEventEnvelope
|
|
1134
|
-
term?: { newTermStart: string; newTermEnd: string }
|
|
1135
|
-
etfAmount?: string | null
|
|
1136
|
-
etfCurrency?: string | null
|
|
1137
|
-
reasonCode?: string | null
|
|
1138
|
-
reasonText?: string | null
|
|
1139
|
-
},
|
|
1258
|
+
envelope: ArcEventEnvelope,
|
|
1140
1259
|
): Promise<void> {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
subscriptionId: sub.id,
|
|
1144
|
-
customerId: sub.customerId,
|
|
1260
|
+
await emitCpqEvent('cpq.subscription.amended' as never, {
|
|
1261
|
+
tenantId: envelope.tenantId,
|
|
1145
1262
|
organizationId: envelope.organizationId,
|
|
1263
|
+
subscriptionId: result.subscription.id,
|
|
1264
|
+
customerId: result.subscription.customerId,
|
|
1265
|
+
// No operator-chosen amend date today — the activation moment is
|
|
1266
|
+
// the effective date. The billing connector computes the mid-cycle
|
|
1267
|
+
// proration from this date + the account's billing cycle.
|
|
1268
|
+
effectiveDate: iso10(result.changeLog.effectiveAt),
|
|
1269
|
+
addedItems: buildArcAddedItems(result.changeLog),
|
|
1270
|
+
removedSubscriptionItemIds: buildArcRemovedItemIds(result.changeLog),
|
|
1271
|
+
sourceQuoteId: envelope.sourceQuoteId,
|
|
1272
|
+
sourceOrderId: envelope.sourceOrderId,
|
|
1273
|
+
timestamp: new Date().toISOString(),
|
|
1274
|
+
} as never)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
async function emitRenewedEvent(
|
|
1278
|
+
result: ApplyArcResult,
|
|
1279
|
+
envelope: ArcEventEnvelope,
|
|
1280
|
+
): Promise<void> {
|
|
1281
|
+
await emitCpqEvent('cpq.subscription.renewed' as never, {
|
|
1146
1282
|
tenantId: envelope.tenantId,
|
|
1283
|
+
organizationId: envelope.organizationId,
|
|
1284
|
+
subscriptionId: result.subscription.id,
|
|
1285
|
+
customerId: result.subscription.customerId,
|
|
1286
|
+
newTermStart: iso10(result.subscription.currentTermStart),
|
|
1287
|
+
newTermEnd: iso10(result.subscription.currentTermEnd),
|
|
1288
|
+
addedItems: buildArcAddedItems(result.changeLog),
|
|
1289
|
+
sourceQuoteId: envelope.sourceQuoteId,
|
|
1290
|
+
sourceOrderId: envelope.sourceOrderId,
|
|
1147
1291
|
timestamp: new Date().toISOString(),
|
|
1292
|
+
} as never)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
async function emitCancelledEvent(
|
|
1296
|
+
result: ApplyArcResult,
|
|
1297
|
+
envelope: ArcEventEnvelope,
|
|
1298
|
+
): Promise<void> {
|
|
1299
|
+
await emitCpqEvent('cpq.subscription.cancelled' as never, {
|
|
1300
|
+
tenantId: envelope.tenantId,
|
|
1301
|
+
organizationId: envelope.organizationId,
|
|
1302
|
+
subscriptionId: result.subscription.id,
|
|
1303
|
+
customerId: result.subscription.customerId,
|
|
1304
|
+
effectiveDate: iso10(result.changeLog.effectiveAt),
|
|
1148
1305
|
sourceQuoteId: envelope.sourceQuoteId,
|
|
1149
1306
|
sourceOrderId: envelope.sourceOrderId,
|
|
1150
|
-
|
|
1151
|
-
changeLogId: result.changeLog.id,
|
|
1152
|
-
proration: buildProrationPayload(sub, result.changeLog.beforeSnapshot ?? null),
|
|
1153
|
-
...(envelope.term ? { term: envelope.term } : {}),
|
|
1154
|
-
...(envelope.etfAmount !== undefined ? { etfAmount: envelope.etfAmount } : {}),
|
|
1155
|
-
...(envelope.etfCurrency !== undefined ? { etfCurrency: envelope.etfCurrency } : {}),
|
|
1156
|
-
...(envelope.reasonCode !== undefined ? { reasonCode: envelope.reasonCode } : {}),
|
|
1157
|
-
...(envelope.reasonText !== undefined ? { reasonText: envelope.reasonText } : {}),
|
|
1307
|
+
timestamp: new Date().toISOString(),
|
|
1158
1308
|
} as never)
|
|
1159
1309
|
}
|
|
1160
1310
|
|
|
1161
1311
|
async function emitMergedEvent(
|
|
1162
1312
|
merge: ApplyMergeRenewalResult,
|
|
1163
1313
|
envelope: ArcEventEnvelope,
|
|
1164
|
-
|
|
1314
|
+
sourceSubscriptionIds: string[],
|
|
1165
1315
|
): Promise<void> {
|
|
1166
|
-
const m = merge.mergedSubscription
|
|
1167
1316
|
await emitCpqEvent('cpq.subscription.merged' as never, {
|
|
1168
|
-
subscriptionId: m.id,
|
|
1169
|
-
customerId: m.customerId,
|
|
1170
|
-
organizationId: envelope.organizationId,
|
|
1171
1317
|
tenantId: envelope.tenantId,
|
|
1172
|
-
|
|
1318
|
+
organizationId: envelope.organizationId,
|
|
1319
|
+
mergedSubscriptionId: merge.mergedSubscription.id,
|
|
1320
|
+
sourceSubscriptionIds,
|
|
1173
1321
|
sourceQuoteId: envelope.sourceQuoteId,
|
|
1174
1322
|
sourceOrderId: envelope.sourceOrderId,
|
|
1175
|
-
|
|
1176
|
-
changeLogId: merge.mergeResultChangeLog.id,
|
|
1177
|
-
mergedFromSubscriptionIds,
|
|
1178
|
-
term: {
|
|
1179
|
-
newTermStart: m.currentTermStart?.toISOString() ?? null,
|
|
1180
|
-
newTermEnd: m.currentTermEnd?.toISOString() ?? null,
|
|
1181
|
-
newTermMonths: m.termMonths ?? null,
|
|
1182
|
-
},
|
|
1183
|
-
proration: buildProrationPayload(m, null),
|
|
1323
|
+
timestamp: new Date().toISOString(),
|
|
1184
1324
|
} as never)
|
|
1185
1325
|
}
|
|
1186
1326
|
|
|
1187
1327
|
async function emitSupersededEvent(
|
|
1188
1328
|
sourceLog: { id: string; subscriptionId: string },
|
|
1189
|
-
|
|
1329
|
+
effectiveDate: string | null,
|
|
1190
1330
|
envelope: ArcEventEnvelope,
|
|
1191
1331
|
): Promise<void> {
|
|
1192
1332
|
await emitCpqEvent('cpq.subscription.superseded' as never, {
|
|
1193
|
-
subscriptionId: sourceLog.subscriptionId,
|
|
1194
|
-
organizationId: envelope.organizationId,
|
|
1195
1333
|
tenantId: envelope.tenantId,
|
|
1196
|
-
|
|
1334
|
+
organizationId: envelope.organizationId,
|
|
1335
|
+
subscriptionId: sourceLog.subscriptionId,
|
|
1336
|
+
effectiveDate,
|
|
1197
1337
|
sourceQuoteId: envelope.sourceQuoteId,
|
|
1198
1338
|
sourceOrderId: envelope.sourceOrderId,
|
|
1199
|
-
|
|
1200
|
-
changeLogId: sourceLog.id,
|
|
1201
|
-
mergedIntoSubscriptionId: newMergeSubId,
|
|
1339
|
+
timestamp: new Date().toISOString(),
|
|
1202
1340
|
} as never)
|
|
1203
1341
|
}
|
|
1204
|
-
|
|
1205
|
-
/**
|
|
1206
|
-
* Compute proration payload sent on every ARC subscription event.
|
|
1207
|
-
*
|
|
1208
|
-
* Currently a placeholder. Proration math against the live billing cycle
|
|
1209
|
-
* lives in the billing module — CPQ's contract is to surface the *inputs*
|
|
1210
|
-
* (old/new MRC, cycle bounds) on the event payload so billing can compute
|
|
1211
|
-
* the delta. Until billing integration exists, we expose the essentials and
|
|
1212
|
-
* leave precise cycle math to follow-up work.
|
|
1213
|
-
*/
|
|
1214
|
-
function buildProrationPayload(
|
|
1215
|
-
sub: CpqInventorySubscription,
|
|
1216
|
-
beforeSnapshot: Record<string, unknown> | null,
|
|
1217
|
-
): Record<string, unknown> {
|
|
1218
|
-
const oldMrc = beforeSnapshot
|
|
1219
|
-
? Number((beforeSnapshot.mrcAmount as string | undefined) ?? 0)
|
|
1220
|
-
: 0
|
|
1221
|
-
return {
|
|
1222
|
-
oldMrcAmount: oldMrc,
|
|
1223
|
-
newMrcAmount: Number(sub.mrcAmount),
|
|
1224
|
-
currency: sub.currencyCode,
|
|
1225
|
-
billingCycleStart: null,
|
|
1226
|
-
billingCycleEnd: null,
|
|
1227
|
-
daysElapsedInCycle: null,
|
|
1228
|
-
daysRemainingInCycle: null,
|
|
1229
|
-
}
|
|
1230
|
-
}
|