@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/MIGRATION.md +34 -1
  3. package/README.md +17 -12
  4. package/dist/modules/cpq/api/product-attributes/route.js +28 -53
  5. package/dist/modules/cpq/api/product-attributes/route.js.map +2 -2
  6. package/dist/modules/cpq/api/product-charges/route.js +12 -2
  7. package/dist/modules/cpq/api/product-charges/route.js.map +2 -2
  8. package/dist/modules/cpq/api/product-offerings/route.js +2 -0
  9. package/dist/modules/cpq/api/product-offerings/route.js.map +2 -2
  10. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +128 -59
  11. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
  12. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +11 -11
  13. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
  14. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +55 -1
  15. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
  16. package/dist/modules/cpq/cli.js +58 -4
  17. package/dist/modules/cpq/cli.js.map +3 -3
  18. package/dist/modules/cpq/data/charge-pricing.js +12 -0
  19. package/dist/modules/cpq/data/charge-pricing.js.map +7 -0
  20. package/dist/modules/cpq/data/entities.js +3 -0
  21. package/dist/modules/cpq/data/entities.js.map +2 -2
  22. package/dist/modules/cpq/data/validators.js +36 -15
  23. package/dist/modules/cpq/data/validators.js.map +2 -2
  24. package/dist/modules/cpq/events.js +7 -0
  25. package/dist/modules/cpq/events.js.map +2 -2
  26. package/dist/modules/cpq/lib/seeds/demo.js +103 -0
  27. package/dist/modules/cpq/lib/seeds/demo.js.map +7 -0
  28. package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js +28 -0
  29. package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js.map +7 -0
  30. package/dist/modules/cpq/services/cpqOrderService.js +130 -76
  31. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  32. package/dist/modules/cpq/services/cpqPricingService.js +53 -29
  33. package/dist/modules/cpq/services/cpqPricingService.js.map +3 -3
  34. package/dist/modules/cpq/services/types.js.map +2 -2
  35. package/package.json +11 -9
  36. package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +34 -4
  37. package/src/modules/cpq/__tests__/package-isolation.test.ts +8 -3
  38. package/src/modules/cpq/api/product-attributes/__tests__/serializeAttribute.test.ts +62 -0
  39. package/src/modules/cpq/api/product-attributes/route.ts +34 -52
  40. package/src/modules/cpq/api/product-charges/route.ts +23 -2
  41. package/src/modules/cpq/api/product-offerings/route.ts +2 -0
  42. package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +151 -62
  43. package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -22
  44. package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +73 -1
  45. package/src/modules/cpq/cli.ts +83 -4
  46. package/src/modules/cpq/data/__tests__/charge-pricing.test.ts +49 -0
  47. package/src/modules/cpq/data/__tests__/validators.test.ts +60 -5
  48. package/src/modules/cpq/data/charge-pricing.ts +48 -0
  49. package/src/modules/cpq/data/entities.ts +10 -0
  50. package/src/modules/cpq/data/validators.ts +56 -26
  51. package/src/modules/cpq/events.ts +11 -3
  52. package/src/modules/cpq/lib/seeds/__tests__/demo.test.ts +78 -0
  53. package/src/modules/cpq/lib/seeds/demo.ts +145 -0
  54. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +16 -0
  55. package/src/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.ts +57 -0
  56. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +172 -1
  57. package/src/modules/cpq/services/cpqOrderService.ts +214 -103
  58. package/src/modules/cpq/services/cpqPricingService.ts +82 -34
  59. 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 arcEvents: Array<() => Promise<void>> = []
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
- arcEvents.push(
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 ARC events AFTER the activation transaction commits.
395
- for (const fire of arcEvents) {
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(sourceLog, merge.mergedSubscription.id, eventBaseEnvelope),
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<void> {
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
- async function emitArcEvent(
1128
- eventId:
1129
- | 'cpq.subscription.amended'
1130
- | 'cpq.subscription.renewed'
1131
- | 'cpq.subscription.cancelled',
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
- const sub = result.subscription
1142
- await emitCpqEvent(eventId as never, {
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
- performedByUserId: null,
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
- mergedFromSubscriptionIds: string[],
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
- timestamp: new Date().toISOString(),
1318
+ organizationId: envelope.organizationId,
1319
+ mergedSubscriptionId: merge.mergedSubscription.id,
1320
+ sourceSubscriptionIds,
1173
1321
  sourceQuoteId: envelope.sourceQuoteId,
1174
1322
  sourceOrderId: envelope.sourceOrderId,
1175
- performedByUserId: null,
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
- newMergeSubId: string,
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
- timestamp: new Date().toISOString(),
1334
+ organizationId: envelope.organizationId,
1335
+ subscriptionId: sourceLog.subscriptionId,
1336
+ effectiveDate,
1197
1337
  sourceQuoteId: envelope.sourceQuoteId,
1198
1338
  sourceOrderId: envelope.sourceOrderId,
1199
- performedByUserId: null,
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
- }