@dainamite/cpq-billing-connector 0.1.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 (49) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +52 -0
  4. package/dist/modules/cpq_billing_connector/acl.js +7 -0
  5. package/dist/modules/cpq_billing_connector/acl.js.map +7 -0
  6. package/dist/modules/cpq_billing_connector/ce.js +7 -0
  7. package/dist/modules/cpq_billing_connector/ce.js.map +7 -0
  8. package/dist/modules/cpq_billing_connector/di.js +6 -0
  9. package/dist/modules/cpq_billing_connector/di.js.map +7 -0
  10. package/dist/modules/cpq_billing_connector/index.js +20 -0
  11. package/dist/modules/cpq_billing_connector/index.js.map +7 -0
  12. package/dist/modules/cpq_billing_connector/lib/billingApiClient.js +116 -0
  13. package/dist/modules/cpq_billing_connector/lib/billingApiClient.js.map +7 -0
  14. package/dist/modules/cpq_billing_connector/lib/chargeMapper.js +49 -0
  15. package/dist/modules/cpq_billing_connector/lib/chargeMapper.js.map +7 -0
  16. package/dist/modules/cpq_billing_connector/lib/prorationHelper.js +19 -0
  17. package/dist/modules/cpq_billing_connector/lib/prorationHelper.js.map +7 -0
  18. package/dist/modules/cpq_billing_connector/setup.js +7 -0
  19. package/dist/modules/cpq_billing_connector/setup.js.map +7 -0
  20. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.js +78 -0
  21. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.js.map +7 -0
  22. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.js +112 -0
  23. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.js.map +7 -0
  24. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.js +39 -0
  25. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.js.map +7 -0
  26. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-merged.js +32 -0
  27. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-merged.js.map +7 -0
  28. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-renewed.js +53 -0
  29. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-renewed.js.map +7 -0
  30. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-superseded.js +39 -0
  31. package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-superseded.js.map +7 -0
  32. package/package.json +119 -0
  33. package/src/modules/cpq_billing_connector/__tests__/lib/chargeMapper.test.ts +158 -0
  34. package/src/modules/cpq_billing_connector/__tests__/lib/prorationHelper.test.ts +93 -0
  35. package/src/modules/cpq_billing_connector/__tests__/package-isolation.test.ts +184 -0
  36. package/src/modules/cpq_billing_connector/acl.ts +12 -0
  37. package/src/modules/cpq_billing_connector/ce.ts +6 -0
  38. package/src/modules/cpq_billing_connector/di.ts +12 -0
  39. package/src/modules/cpq_billing_connector/index.ts +20 -0
  40. package/src/modules/cpq_billing_connector/lib/billingApiClient.ts +215 -0
  41. package/src/modules/cpq_billing_connector/lib/chargeMapper.ts +120 -0
  42. package/src/modules/cpq_billing_connector/lib/prorationHelper.ts +74 -0
  43. package/src/modules/cpq_billing_connector/setup.ts +11 -0
  44. package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.ts +134 -0
  45. package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.ts +169 -0
  46. package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.ts +64 -0
  47. package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-merged.ts +56 -0
  48. package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-renewed.ts +92 -0
  49. package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-superseded.ts +66 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Proration math owned by the connector.
3
+ *
4
+ * Per spec: "Connectors computing proration values should follow the
5
+ * same convention for consistency on the draft" — i.e. half-up 2dp,
6
+ * matching billing's `roundHalfUp2dp` policy. Duplicated here (vs
7
+ * imported from `@dainamite/billing`) on purpose so the connector
8
+ * doesn't reach into billing's internals via a non-peer-dep path.
9
+ *
10
+ * The formula is the linear-day proration documented in the spec's
11
+ * "CPQ-side payload contracts" section:
12
+ * `prorationAmount = unitPrice × quantity × daysRemaining / daysInPeriod`
13
+ *
14
+ * For mid-cycle additions (e.g. customer buys an add-on on 2026-05-15
15
+ * for a monthly cycle anchored on the 1st), `daysRemaining` is the
16
+ * number of full days from the effective date through the cycle end
17
+ * (inclusive), and `daysInPeriod` is the cycle's calendar-day length.
18
+ *
19
+ * The CPQ event payload supplies both numbers — the connector trusts
20
+ * the calendar arithmetic CPQ already did (it derived the period
21
+ * boundaries from the subscription's lifecycle). It does NOT
22
+ * re-compute them from `effectiveDate` and `cycleEnd` to avoid
23
+ * disagreeing with CPQ's source of truth.
24
+ */
25
+
26
+ /**
27
+ * Half-up 2dp rounding that survives JS floating-point edges
28
+ * (e.g. `1.005 * 100 === 100.49999…`). Matches billing's
29
+ * `roundHalfUp2dp` policy.
30
+ */
31
+ function roundHalfUp2dp(value: number): number {
32
+ return Math.round((value + Number.EPSILON) * 100) / 100
33
+ }
34
+
35
+ export type ProrationInput = {
36
+ unitPrice: number
37
+ quantity: number
38
+ daysInPeriod: number
39
+ daysRemaining: number
40
+ }
41
+
42
+ export type ProrationResult = {
43
+ /** Numeric amount, half-up 2dp, suitable for `rate_json.amount`. */
44
+ amount: number
45
+ /** Computed string form for `billing.items.create.rateJson` (4dp). */
46
+ amountString: string
47
+ }
48
+
49
+ export function computeProration(input: ProrationInput): ProrationResult {
50
+ // Guard against the degenerate "0 days in period" — should never
51
+ // happen if CPQ derived the values, but defending here avoids a
52
+ // silent NaN propagation if it ever does.
53
+ if (input.daysInPeriod <= 0) {
54
+ return { amount: 0, amountString: '0.0000' }
55
+ }
56
+ const raw =
57
+ (input.unitPrice * input.quantity * Math.max(0, input.daysRemaining)) /
58
+ input.daysInPeriod
59
+ const rounded = roundHalfUp2dp(raw)
60
+ return { amount: rounded, amountString: rounded.toFixed(4) }
61
+ }
62
+
63
+ /**
64
+ * Helper used by subscribers to format the proration description so
65
+ * the resulting invoice line reads naturally (matches spec example:
66
+ * "Proration: Package X from 2026-05-15 to 2026-05-31").
67
+ */
68
+ export function formatProrationDescription(params: {
69
+ productName: string
70
+ effectiveDate: string
71
+ cycleEnd: string
72
+ }): string {
73
+ return `Proration: ${params.productName} from ${params.effectiveDate} to ${params.cycleEnd}`
74
+ }
@@ -0,0 +1,11 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+
3
+ /**
4
+ * Connector has no tenant-init work to do. It ships zero entities,
5
+ * zero dictionaries, zero default roles. The subscribers auto-register
6
+ * via the generator's `subscribers/` discovery; nothing else is
7
+ * required for the module to function.
8
+ */
9
+ export const setup: ModuleSetupConfig = {}
10
+
11
+ export default setup
@@ -0,0 +1,134 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { BillingApiClient, type Scope, type AccountSeed } from '../lib/billingApiClient'
3
+ import { mapSubscriptionItemsToBillingItems } from '../lib/chargeMapper'
4
+ import type { CpqSubscriptionItem } from '../lib/chargeMapper'
5
+
6
+ /**
7
+ * Subscriber: `cpq.subscription.activated` → billing onboarding.
8
+ *
9
+ * `@dainamite/cpq` emits this event when a new-sale order is activated
10
+ * (`cpqOrderService.activateOrder`).
11
+ *
12
+ * Behaviour when fired:
13
+ * 1. Get-or-create the Billing Account for `(tenant, org, customerId)`.
14
+ * Missing account → shell account with placeholders (operator
15
+ * must configure before billing fires). `nextBillDate` defaults
16
+ * to today + one month so the Bill Run won't bill an under-
17
+ * configured account until the operator advances it.
18
+ * 2. For every charge on every CPQ subscription item, create a
19
+ * Billing Item via `billing.items.create`. `source_ref` is the
20
+ * deterministic `cpq-<subId>-<subItemId>-<chargeType>` key, so
21
+ * re-firing the activation event hits the idempotency guard
22
+ * instead of duplicating items.
23
+ *
24
+ * Errors propagate to the persistent queue worker, which retries
25
+ * per the OM queue contract.
26
+ */
27
+
28
+ export const metadata = {
29
+ event: 'cpq.subscription.activated',
30
+ persistent: true,
31
+ id: 'cpq-billing-connector:subscription-activated',
32
+ }
33
+
34
+ type ActivatedPayload = {
35
+ tenantId: string
36
+ organizationId: string
37
+ subscriptionId: string
38
+ customerId: string
39
+ currencyCode: string
40
+ /** Optional richer customer fields — used when present, otherwise placeholders. */
41
+ customerName?: string
42
+ invoiceEmail?: string
43
+ invoiceLanguage?: string
44
+ invoiceAddress?: Record<string, unknown>
45
+ billCycle?: 'monthly' | 'quarterly' | 'annually' | 'weekly'
46
+ billCycleAnchor?: string
47
+ nextBillDate?: string
48
+ taxId?: string | null
49
+ items: CpqSubscriptionItem[]
50
+ /** Activation date — used as bill_start_date on every created item. */
51
+ activationDate?: string
52
+ }
53
+
54
+ function isoDateAddMonths(months: number): string {
55
+ // Clamp the day to the last valid day of the target month so Jan 31 + 1
56
+ // month → Feb 28/29 (not Mar 3, which is what `setUTCMonth` alone would
57
+ // produce by overflowing into the next month).
58
+ const d = new Date()
59
+ const targetYear = d.getUTCFullYear()
60
+ const targetMonth = d.getUTCMonth() + months
61
+ const lastDayOfTargetMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate()
62
+ d.setUTCDate(Math.min(d.getUTCDate(), lastDayOfTargetMonth))
63
+ d.setUTCMonth(targetMonth)
64
+ return d.toISOString().slice(0, 10)
65
+ }
66
+
67
+ function todayIsoDate(): string {
68
+ return new Date().toISOString().slice(0, 10)
69
+ }
70
+
71
+ function buildAccountSeed(payload: ActivatedPayload): AccountSeed {
72
+ return {
73
+ customerId: payload.customerId,
74
+ name: payload.customerName ?? `Customer ${payload.customerId}`,
75
+ currencyCode: payload.currencyCode,
76
+ billCycle: payload.billCycle ?? 'monthly',
77
+ billCycleAnchor: payload.billCycleAnchor ?? '1',
78
+ invoiceEmail:
79
+ payload.invoiceEmail ?? `billing+${payload.customerId}@invalid.local`,
80
+ invoiceLanguage: payload.invoiceLanguage ?? 'en',
81
+ invoiceAddress: payload.invoiceAddress ?? {
82
+ line1: 'TBD',
83
+ city: 'TBD',
84
+ postal_code: 'TBD',
85
+ country: 'XX',
86
+ },
87
+ nextBillDate: payload.nextBillDate
88
+ ? new Date(payload.nextBillDate)
89
+ : new Date(isoDateAddMonths(1)),
90
+ taxId: payload.taxId ?? null,
91
+ }
92
+ }
93
+
94
+ export default async function handle(payload: ActivatedPayload): Promise<void> {
95
+ if (!payload?.tenantId || !payload.organizationId || !payload.customerId) {
96
+ return
97
+ }
98
+ if (!payload.subscriptionId || !Array.isArray(payload.items) || payload.items.length === 0) {
99
+ return
100
+ }
101
+
102
+ const container = await createRequestContainer()
103
+ const billingApi = new BillingApiClient(container)
104
+ const scope: Scope = {
105
+ tenantId: payload.tenantId,
106
+ organizationId: payload.organizationId,
107
+ }
108
+
109
+ const account = await billingApi.getOrCreateAccount(scope, buildAccountSeed(payload))
110
+
111
+ const billStartDate = payload.activationDate ?? todayIsoDate()
112
+ const itemPayloads = mapSubscriptionItemsToBillingItems({
113
+ subscriptionId: payload.subscriptionId,
114
+ items: payload.items,
115
+ billStartDate,
116
+ })
117
+
118
+ // Bulk-create — one command for the whole subscription's charges
119
+ // instead of one round-trip per charge. Idempotent per source_ref,
120
+ // so a re-fired activation event is a no-op.
121
+ await billingApi.bulkCreateItems(
122
+ scope,
123
+ itemPayloads.map((item) => ({
124
+ billAccountId: account.id,
125
+ type: item.type,
126
+ billStartDate: item.billStartDate,
127
+ description: item.description,
128
+ rateJson: item.rateJson,
129
+ subscriptionId: item.subscriptionId,
130
+ subscriptionItemId: item.subscriptionItemId,
131
+ sourceRef: item.sourceRef,
132
+ })),
133
+ )
134
+ }
@@ -0,0 +1,169 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ // TODO(L2-boundary): `cycleContaining` is a runtime function imported from
3
+ // `@dainamite/billing` internals (not a stable public surface). A breaking
4
+ // rename or signature change in billing's `billPeriod` will manifest here
5
+ // as a runtime crash on the first `cpq.subscription.amended` event. Same
6
+ // trade-off as `billingApiClient.ts` — kept inline for now because the
7
+ // proration math is non-trivial and we don't want to fork it. Promote
8
+ // `cycleContaining` to a documented public export of @dainamite/billing
9
+ // (or expose via a billing REST helper) before the connector hits v1.
10
+ import { cycleContaining } from '@dainamite/billing/modules/billing/lib/billPeriod'
11
+ import type { BillCycle } from '@dainamite/billing/modules/billing/data/validators'
12
+ import { BillingApiClient, type Scope } from '../lib/billingApiClient'
13
+ import {
14
+ mapSubscriptionItemsToBillingItems,
15
+ type CpqSubscriptionItem,
16
+ } from '../lib/chargeMapper'
17
+ import {
18
+ computeProration,
19
+ formatProrationDescription,
20
+ } from '../lib/prorationHelper'
21
+
22
+ /**
23
+ * Subscriber: `cpq.subscription.amended` → mid-cycle add / remove.
24
+ *
25
+ * CPQ's amended event carries `addedItems`, `removedSubscriptionItemIds`
26
+ * and the `effectiveDate` of the amendment. The connector:
27
+ * 1. Creates a Billing Item for every added charge — recurring items
28
+ * start on `effectiveDate`; the engine's mid-cycle rule skips the
29
+ * current cycle and bills from the next full cycle.
30
+ * 2. For every added RECURRING charge, posts a `one_time` proration
31
+ * line covering the partial current cycle. The billing cycle is
32
+ * derived HERE (not in CPQ) — proration is a billing concept, so
33
+ * the connector computes it from the account's `nextBillDate` +
34
+ * `billCycle` via billing's `cycleContaining`.
35
+ * 3. End-dates every Billing Item of each removed subscription item
36
+ * at `effectiveDate - 1 day`.
37
+ */
38
+
39
+ export const metadata = {
40
+ event: 'cpq.subscription.amended',
41
+ persistent: true,
42
+ id: 'cpq-billing-connector:subscription-amended',
43
+ }
44
+
45
+ type AmendedPayload = {
46
+ tenantId: string
47
+ organizationId: string
48
+ subscriptionId: string
49
+ customerId: string
50
+ /** YYYY-MM-DD; the date the amend takes effect mid-cycle. */
51
+ effectiveDate: string
52
+ addedItems?: CpqSubscriptionItem[]
53
+ removedSubscriptionItemIds?: string[]
54
+ }
55
+
56
+ const DAY_MS = 86_400_000
57
+
58
+ function toMidnightMs(value: Date | string): number {
59
+ const d =
60
+ value instanceof Date
61
+ ? new Date(value.getTime())
62
+ : new Date(`${value}T00:00:00.000Z`)
63
+ d.setUTCHours(0, 0, 0, 0)
64
+ return d.getTime()
65
+ }
66
+
67
+ /** Inclusive day count from `from` through `to`. */
68
+ function inclusiveDays(from: Date | string, to: Date | string): number {
69
+ return Math.round((toMidnightMs(to) - toMidnightMs(from)) / DAY_MS) + 1
70
+ }
71
+
72
+ function isoDateMinusOneDay(iso: string): string {
73
+ const d = new Date(`${iso}T00:00:00.000Z`)
74
+ d.setUTCDate(d.getUTCDate() - 1)
75
+ return d.toISOString().slice(0, 10)
76
+ }
77
+
78
+ export default async function handle(payload: AmendedPayload): Promise<void> {
79
+ if (!payload?.tenantId || !payload.organizationId) return
80
+ if (!payload.subscriptionId || !payload.effectiveDate) return
81
+
82
+ const added = payload.addedItems ?? []
83
+ const removed = payload.removedSubscriptionItemIds ?? []
84
+ if (added.length === 0 && removed.length === 0) return
85
+
86
+ const container = await createRequestContainer()
87
+ const billingApi = new BillingApiClient(container)
88
+ const scope: Scope = {
89
+ tenantId: payload.tenantId,
90
+ organizationId: payload.organizationId,
91
+ }
92
+
93
+ // Activation runs first and creates the account. If it's missing,
94
+ // there is nothing to amend — the queue will have retried activation.
95
+ const account = await billingApi.findAccountByCustomer(scope, payload.customerId)
96
+ if (!account) return
97
+
98
+ // 1. New Billing Items for every added charge.
99
+ const itemsToCreate = mapSubscriptionItemsToBillingItems({
100
+ subscriptionId: payload.subscriptionId,
101
+ items: added,
102
+ billStartDate: payload.effectiveDate,
103
+ }).map((item) => ({
104
+ billAccountId: account.id,
105
+ type: item.type,
106
+ billStartDate: item.billStartDate,
107
+ description: item.description,
108
+ rateJson: item.rateJson,
109
+ subscriptionId: item.subscriptionId,
110
+ subscriptionItemId: item.subscriptionItemId,
111
+ sourceRef: item.sourceRef,
112
+ }))
113
+
114
+ // 2. Mid-cycle proration. The billing cycle the amend lands in is
115
+ // derived from the account here — CPQ does not own the billing cycle.
116
+ if (added.length > 0) {
117
+ const period = cycleContaining(
118
+ account.nextBillDate,
119
+ account.billCycle as BillCycle,
120
+ payload.effectiveDate,
121
+ )
122
+ const cycleEnd = period.periodEnd.toISOString().slice(0, 10)
123
+ const daysInPeriod = inclusiveDays(period.periodStart, period.periodEnd)
124
+ const daysRemaining = inclusiveDays(payload.effectiveDate, period.periodEnd)
125
+
126
+ for (const addedItem of added) {
127
+ for (const charge of addedItem.charges) {
128
+ if (charge.type !== 'recurring' || typeof charge.unitPrice !== 'number') {
129
+ continue
130
+ }
131
+ const { amount } = computeProration({
132
+ unitPrice: charge.unitPrice,
133
+ quantity: addedItem.quantity,
134
+ daysInPeriod,
135
+ daysRemaining,
136
+ })
137
+ if (amount === 0) continue
138
+ itemsToCreate.push({
139
+ billAccountId: account.id,
140
+ type: 'one_time',
141
+ billStartDate: payload.effectiveDate,
142
+ description: formatProrationDescription({
143
+ productName: addedItem.productName,
144
+ effectiveDate: payload.effectiveDate,
145
+ cycleEnd,
146
+ }),
147
+ rateJson: { amount },
148
+ subscriptionId: payload.subscriptionId,
149
+ subscriptionItemId: addedItem.subscriptionItemId,
150
+ sourceRef: `cpq-${payload.subscriptionId}-${addedItem.subscriptionItemId}-proration-${payload.effectiveDate}`,
151
+ })
152
+ }
153
+ }
154
+ }
155
+
156
+ await billingApi.bulkCreateItems(scope, itemsToCreate)
157
+
158
+ // 3. End-date every Billing Item of each removed subscription item.
159
+ const endDate = isoDateMinusOneDay(payload.effectiveDate)
160
+ for (const removedSubItemId of removed) {
161
+ const items = await billingApi.findItemsBySubscriptionItem(scope, removedSubItemId)
162
+ for (const item of items) {
163
+ await billingApi.updateItem(scope, {
164
+ id: item.id,
165
+ billEndDate: endDate,
166
+ })
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,64 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { BillingApiClient, type Scope } from '../lib/billingApiClient'
3
+
4
+ /**
5
+ * Subscriber: `cpq.subscription.cancelled` → set bill_end_date on
6
+ * every billing item attached to the subscription.
7
+ *
8
+ * The connector uses the spec's `effectiveDate` (cancellation date)
9
+ * minus one day as `bill_end_date` so the engine's recurring
10
+ * selector stops including the item from the cycle containing the
11
+ * cancellation onward.
12
+ *
13
+ * Final-period proration / refund credits (per spec) are issued by
14
+ * CPQ — the connector does not invent them here. If a credit is
15
+ * needed, CPQ posts a negative `one_time` Billing Item via this
16
+ * same connector path (e.g. an `amend` event preceding the cancel).
17
+ */
18
+
19
+ export const metadata = {
20
+ event: 'cpq.subscription.cancelled',
21
+ persistent: true,
22
+ id: 'cpq-billing-connector:subscription-cancelled',
23
+ }
24
+
25
+ type CancelledPayload = {
26
+ tenantId: string
27
+ organizationId: string
28
+ subscriptionId: string
29
+ /** YYYY-MM-DD; the date the cancellation takes effect. */
30
+ effectiveDate: string
31
+ }
32
+
33
+ function isoDateMinusOneDay(iso: string): string {
34
+ const d = new Date(`${iso}T00:00:00.000Z`)
35
+ d.setUTCDate(d.getUTCDate() - 1)
36
+ return d.toISOString().slice(0, 10)
37
+ }
38
+
39
+ export default async function handle(payload: CancelledPayload): Promise<void> {
40
+ if (!payload?.tenantId || !payload.organizationId) return
41
+ if (!payload.subscriptionId || !payload.effectiveDate) return
42
+
43
+ const container = await createRequestContainer()
44
+ const billingApi = new BillingApiClient(container)
45
+ const scope: Scope = {
46
+ tenantId: payload.tenantId,
47
+ organizationId: payload.organizationId,
48
+ }
49
+
50
+ const items = await billingApi.findItemsBySubscription(scope, payload.subscriptionId)
51
+ if (items.length === 0) return
52
+
53
+ const endDate = isoDateMinusOneDay(payload.effectiveDate)
54
+ for (const item of items) {
55
+ // Idempotency: skip if already end-dated to this exact date.
56
+ if (item.billEndDate && item.billEndDate.toISOString().slice(0, 10) === endDate) {
57
+ continue
58
+ }
59
+ await billingApi.updateItem(scope, {
60
+ id: item.id,
61
+ billEndDate: endDate,
62
+ })
63
+ }
64
+ }
@@ -0,0 +1,56 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { BillingApiClient, type Scope } from '../lib/billingApiClient'
3
+
4
+ /**
5
+ * Subscriber: `cpq.subscription.merged` → re-link items to the
6
+ * merged subscription.
7
+ *
8
+ * Per the spec: "Move Billing Items from source subscriptions to
9
+ * the merged subscription (update `subscription_id`)." This keeps
10
+ * the audit trail continuous — same Billing Items, just pointing
11
+ * at the new subscription so any future lookup by subscription_id
12
+ * lands at the merged target.
13
+ *
14
+ * The source subscriptions transition to the terminal `superseded`
15
+ * status in CPQ; the `superseded` subscriber separately end-dates
16
+ * any items still pointing at the source ids (defense in depth — if
17
+ * a race left an item un-relinked, superseded catches it).
18
+ */
19
+
20
+ export const metadata = {
21
+ event: 'cpq.subscription.merged',
22
+ persistent: true,
23
+ id: 'cpq-billing-connector:subscription-merged',
24
+ }
25
+
26
+ type MergedPayload = {
27
+ tenantId: string
28
+ organizationId: string
29
+ /** The new merged subscription's id (the survivor). */
30
+ mergedSubscriptionId: string
31
+ /** The source subscriptions absorbed into the merge. */
32
+ sourceSubscriptionIds: string[]
33
+ }
34
+
35
+ export default async function handle(payload: MergedPayload): Promise<void> {
36
+ if (!payload?.tenantId || !payload.organizationId) return
37
+ if (!payload.mergedSubscriptionId || !Array.isArray(payload.sourceSubscriptionIds)) return
38
+ if (payload.sourceSubscriptionIds.length === 0) return
39
+
40
+ const container = await createRequestContainer()
41
+ const billingApi = new BillingApiClient(container)
42
+ const scope: Scope = {
43
+ tenantId: payload.tenantId,
44
+ organizationId: payload.organizationId,
45
+ }
46
+
47
+ for (const sourceId of payload.sourceSubscriptionIds) {
48
+ const items = await billingApi.findItemsBySubscription(scope, sourceId)
49
+ for (const item of items) {
50
+ await billingApi.updateItem(scope, {
51
+ id: item.id,
52
+ subscriptionId: payload.mergedSubscriptionId,
53
+ })
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,92 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { BillingApiClient, type Scope } from '../lib/billingApiClient'
3
+ import {
4
+ mapSubscriptionItemsToBillingItems,
5
+ type CpqSubscriptionItem,
6
+ } from '../lib/chargeMapper'
7
+
8
+ /**
9
+ * Subscriber: `cpq.subscription.renewed` → extend term + add items.
10
+ *
11
+ * On renewal the existing items either:
12
+ * - Stay active (no `bill_end_date` set, so the engine keeps
13
+ * billing them as recurring) — connector touches nothing.
14
+ * - Had a previously-set `bill_end_date` (e.g. fixed-term
15
+ * subscription) — connector clears or extends it to the new
16
+ * term's end (`newTermEnd` from the payload).
17
+ *
18
+ * New items added at renewal (e.g. upsell at renewal time) are
19
+ * created with `bill_start_date = newTermStart`, same shape as
20
+ * activation.
21
+ *
22
+ * The connector does NOT post a proration line for renewals — by
23
+ * definition renewal happens at the cycle boundary (or it's an early
24
+ * renewal, which the integrator should issue as `amend` + `renew`
25
+ * via the spec's ARC flow rather than this single event).
26
+ */
27
+
28
+ export const metadata = {
29
+ event: 'cpq.subscription.renewed',
30
+ persistent: true,
31
+ id: 'cpq-billing-connector:subscription-renewed',
32
+ }
33
+
34
+ type RenewedPayload = {
35
+ tenantId: string
36
+ organizationId: string
37
+ subscriptionId: string
38
+ customerId: string
39
+ newTermStart: string
40
+ newTermEnd?: string | null
41
+ addedItems?: CpqSubscriptionItem[]
42
+ }
43
+
44
+ export default async function handle(payload: RenewedPayload): Promise<void> {
45
+ if (!payload?.tenantId || !payload.organizationId) return
46
+ if (!payload.subscriptionId || !payload.newTermStart) return
47
+
48
+ const container = await createRequestContainer()
49
+ const billingApi = new BillingApiClient(container)
50
+ const scope: Scope = {
51
+ tenantId: payload.tenantId,
52
+ organizationId: payload.organizationId,
53
+ }
54
+
55
+ const existingItems = await billingApi.findItemsBySubscription(scope, payload.subscriptionId)
56
+
57
+ // Extend the bill_end_date on items that had one. Items without
58
+ // an end date are open-ended; nothing to do.
59
+ for (const item of existingItems) {
60
+ if (!item.billEndDate) continue
61
+ await billingApi.updateItem(scope, {
62
+ id: item.id,
63
+ billEndDate: payload.newTermEnd ?? null,
64
+ })
65
+ }
66
+
67
+ // Add new items at the renewal boundary.
68
+ const accountId =
69
+ existingItems[0]?.billAccountId ??
70
+ (await billingApi.findAccountByCustomer(scope, payload.customerId))?.id ??
71
+ null
72
+ if (!accountId) return
73
+
74
+ const newPayloads = mapSubscriptionItemsToBillingItems({
75
+ subscriptionId: payload.subscriptionId,
76
+ items: payload.addedItems ?? [],
77
+ billStartDate: payload.newTermStart,
78
+ })
79
+ await billingApi.bulkCreateItems(
80
+ scope,
81
+ newPayloads.map((item) => ({
82
+ billAccountId: accountId,
83
+ type: item.type,
84
+ billStartDate: item.billStartDate,
85
+ description: item.description,
86
+ rateJson: item.rateJson,
87
+ subscriptionId: item.subscriptionId,
88
+ subscriptionItemId: item.subscriptionItemId,
89
+ sourceRef: item.sourceRef,
90
+ })),
91
+ )
92
+ }
@@ -0,0 +1,66 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { BillingApiClient, type Scope } from '../lib/billingApiClient'
3
+
4
+ /**
5
+ * Subscriber: `cpq.subscription.superseded` → end-date items still
6
+ * pointing at a superseded source.
7
+ *
8
+ * Per the spec's XD-250 ARC notes, merge-renewal produces a brand
9
+ * new subscription (the "merge target") and transitions the source
10
+ * subscriptions to `superseded`. The `merged` subscriber already
11
+ * re-links Billing Items to the target — this subscriber is the
12
+ * defense-in-depth net: if any items remain attached to the
13
+ * superseded source (re-link missed them, race, manual fix-up),
14
+ * end-date them at the supersession effective date so the engine
15
+ * stops billing them.
16
+ *
17
+ * In the steady-state "no race" path, this subscriber finds zero
18
+ * items and is a no-op — which is fine.
19
+ */
20
+
21
+ export const metadata = {
22
+ event: 'cpq.subscription.superseded',
23
+ persistent: true,
24
+ id: 'cpq-billing-connector:subscription-superseded',
25
+ }
26
+
27
+ type SupersededPayload = {
28
+ tenantId: string
29
+ organizationId: string
30
+ /** The id that is being superseded (the OLD subscription). */
31
+ subscriptionId: string
32
+ /** YYYY-MM-DD; when the supersession took effect. */
33
+ effectiveDate: string
34
+ }
35
+
36
+ function isoDateMinusOneDay(iso: string): string {
37
+ const d = new Date(`${iso}T00:00:00.000Z`)
38
+ d.setUTCDate(d.getUTCDate() - 1)
39
+ return d.toISOString().slice(0, 10)
40
+ }
41
+
42
+ export default async function handle(payload: SupersededPayload): Promise<void> {
43
+ if (!payload?.tenantId || !payload.organizationId) return
44
+ if (!payload.subscriptionId || !payload.effectiveDate) return
45
+
46
+ const container = await createRequestContainer()
47
+ const billingApi = new BillingApiClient(container)
48
+ const scope: Scope = {
49
+ tenantId: payload.tenantId,
50
+ organizationId: payload.organizationId,
51
+ }
52
+
53
+ const stragglers = await billingApi.findItemsBySubscription(scope, payload.subscriptionId)
54
+ if (stragglers.length === 0) return
55
+
56
+ const endDate = isoDateMinusOneDay(payload.effectiveDate)
57
+ for (const item of stragglers) {
58
+ if (item.billEndDate && item.billEndDate.toISOString().slice(0, 10) === endDate) {
59
+ continue
60
+ }
61
+ await billingApi.updateItem(scope, {
62
+ id: item.id,
63
+ billEndDate: endDate,
64
+ })
65
+ }
66
+ }