@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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/modules/cpq_billing_connector/acl.js +7 -0
- package/dist/modules/cpq_billing_connector/acl.js.map +7 -0
- package/dist/modules/cpq_billing_connector/ce.js +7 -0
- package/dist/modules/cpq_billing_connector/ce.js.map +7 -0
- package/dist/modules/cpq_billing_connector/di.js +6 -0
- package/dist/modules/cpq_billing_connector/di.js.map +7 -0
- package/dist/modules/cpq_billing_connector/index.js +20 -0
- package/dist/modules/cpq_billing_connector/index.js.map +7 -0
- package/dist/modules/cpq_billing_connector/lib/billingApiClient.js +116 -0
- package/dist/modules/cpq_billing_connector/lib/billingApiClient.js.map +7 -0
- package/dist/modules/cpq_billing_connector/lib/chargeMapper.js +49 -0
- package/dist/modules/cpq_billing_connector/lib/chargeMapper.js.map +7 -0
- package/dist/modules/cpq_billing_connector/lib/prorationHelper.js +19 -0
- package/dist/modules/cpq_billing_connector/lib/prorationHelper.js.map +7 -0
- package/dist/modules/cpq_billing_connector/setup.js +7 -0
- package/dist/modules/cpq_billing_connector/setup.js.map +7 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.js +78 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.js.map +7 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.js +112 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.js.map +7 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.js +39 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.js.map +7 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-merged.js +32 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-merged.js.map +7 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-renewed.js +53 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-renewed.js.map +7 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-superseded.js +39 -0
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-superseded.js.map +7 -0
- package/package.json +119 -0
- package/src/modules/cpq_billing_connector/__tests__/lib/chargeMapper.test.ts +158 -0
- package/src/modules/cpq_billing_connector/__tests__/lib/prorationHelper.test.ts +93 -0
- package/src/modules/cpq_billing_connector/__tests__/package-isolation.test.ts +184 -0
- package/src/modules/cpq_billing_connector/acl.ts +12 -0
- package/src/modules/cpq_billing_connector/ce.ts +6 -0
- package/src/modules/cpq_billing_connector/di.ts +12 -0
- package/src/modules/cpq_billing_connector/index.ts +20 -0
- package/src/modules/cpq_billing_connector/lib/billingApiClient.ts +215 -0
- package/src/modules/cpq_billing_connector/lib/chargeMapper.ts +120 -0
- package/src/modules/cpq_billing_connector/lib/prorationHelper.ts +74 -0
- package/src/modules/cpq_billing_connector/setup.ts +11 -0
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.ts +134 -0
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.ts +169 -0
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.ts +64 -0
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-merged.ts +56 -0
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-renewed.ts +92 -0
- 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
|
+
}
|