@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,93 @@
1
+ import {
2
+ computeProration,
3
+ formatProrationDescription,
4
+ } from '../../lib/prorationHelper'
5
+
6
+ describe('computeProration', () => {
7
+ it('canonical spec example: 29 EUR × 17/31 → 15.90 (Story 2)', () => {
8
+ const result = computeProration({
9
+ unitPrice: 29,
10
+ quantity: 1,
11
+ daysInPeriod: 31,
12
+ daysRemaining: 17,
13
+ })
14
+ // 29 × 17 / 31 = 15.903225… → 15.90
15
+ expect(result.amount).toBe(15.9)
16
+ expect(result.amountString).toBe('15.9000')
17
+ })
18
+
19
+ it('full-period proration equals full unit_price × quantity', () => {
20
+ const result = computeProration({
21
+ unitPrice: 49.99,
22
+ quantity: 2,
23
+ daysInPeriod: 30,
24
+ daysRemaining: 30,
25
+ })
26
+ expect(result.amount).toBe(99.98)
27
+ })
28
+
29
+ it('zero days remaining → zero amount', () => {
30
+ const result = computeProration({
31
+ unitPrice: 49,
32
+ quantity: 1,
33
+ daysInPeriod: 30,
34
+ daysRemaining: 0,
35
+ })
36
+ expect(result.amount).toBe(0)
37
+ })
38
+
39
+ it('rounds half-up to 2dp', () => {
40
+ // 1 × 1 × 1 / 200 = 0.005 → 0.01 (half-up)
41
+ const result = computeProration({
42
+ unitPrice: 1,
43
+ quantity: 1,
44
+ daysInPeriod: 200,
45
+ daysRemaining: 1,
46
+ })
47
+ expect(result.amount).toBe(0.01)
48
+ expect(result.amountString).toBe('0.0100')
49
+ })
50
+
51
+ it('defends against daysInPeriod === 0 (degenerate input)', () => {
52
+ const result = computeProration({
53
+ unitPrice: 49,
54
+ quantity: 1,
55
+ daysInPeriod: 0,
56
+ daysRemaining: 5,
57
+ })
58
+ expect(result.amount).toBe(0)
59
+ })
60
+
61
+ it('clamps negative daysRemaining to 0 (defensive)', () => {
62
+ const result = computeProration({
63
+ unitPrice: 49,
64
+ quantity: 1,
65
+ daysInPeriod: 30,
66
+ daysRemaining: -3,
67
+ })
68
+ expect(result.amount).toBe(0)
69
+ })
70
+
71
+ it('scales by quantity correctly', () => {
72
+ // 10 seats × 5 EUR × 15/30 = 25 EUR
73
+ const result = computeProration({
74
+ unitPrice: 5,
75
+ quantity: 10,
76
+ daysInPeriod: 30,
77
+ daysRemaining: 15,
78
+ })
79
+ expect(result.amount).toBe(25)
80
+ })
81
+ })
82
+
83
+ describe('formatProrationDescription', () => {
84
+ it('matches the spec example phrasing', () => {
85
+ expect(
86
+ formatProrationDescription({
87
+ productName: 'Package X',
88
+ effectiveDate: '2026-05-15',
89
+ cycleEnd: '2026-05-31',
90
+ }),
91
+ ).toBe('Proration: Package X from 2026-05-15 to 2026-05-31')
92
+ })
93
+ })
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Package isolation guard for @dainamite/cpq-billing-connector.
3
+ *
4
+ * Mirrors the @dainamite/billing variant. Allowed import prefixes
5
+ * include `@dainamite/billing/...` since the connector imports
6
+ * billing's entity classes (BillingAccount, BillingItem) for
7
+ * read-side lookups — peer-dep, declared in package.json, fine.
8
+ */
9
+
10
+ import { promises as fs } from 'node:fs'
11
+ import path from 'node:path'
12
+
13
+ import { metadata } from '../index'
14
+
15
+ const PACKAGE_ROOT = path.resolve(__dirname, '..')
16
+
17
+ const ALLOWED_IMPORT_PREFIXES = [
18
+ './',
19
+ '../',
20
+ '@open-mercato/',
21
+ '@mikro-orm/',
22
+ '@dainamite/',
23
+ 'awilix',
24
+ 'lucide-react',
25
+ 'next',
26
+ 'next/',
27
+ 'react',
28
+ 'react/',
29
+ 'react-dom',
30
+ 'react-dom/',
31
+ 'zod',
32
+ 'node:',
33
+ ]
34
+
35
+ const NODE_BUILTINS = new Set([
36
+ 'crypto',
37
+ 'fs',
38
+ 'path',
39
+ 'os',
40
+ 'util',
41
+ 'stream',
42
+ 'buffer',
43
+ 'http',
44
+ 'https',
45
+ 'url',
46
+ 'querystring',
47
+ 'child_process',
48
+ 'zlib',
49
+ 'events',
50
+ 'assert',
51
+ ])
52
+
53
+ const FORBIDDEN_PREFIXES = [
54
+ '@/modules/',
55
+ 'src/modules/',
56
+ '../../../../catalog/',
57
+ '../../../../sales/',
58
+ '../../../../customers/',
59
+ '../../../../auth/',
60
+ '../../../../directory/',
61
+ '../../../../demo_',
62
+ '../../../../portal/',
63
+ '../../../../customer_accounts/',
64
+ '../../../../example/',
65
+ ]
66
+
67
+ async function walk(dir: string, acc: string[] = []): Promise<string[]> {
68
+ const entries = await fs.readdir(dir, { withFileTypes: true })
69
+ for (const entry of entries) {
70
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue
71
+ if (entry.name === '__tests__' || entry.name === '__integration__') continue
72
+ const full = path.join(dir, entry.name)
73
+ if (entry.isDirectory()) {
74
+ await walk(full, acc)
75
+ } else if (/\.(ts|tsx|mts|cts)$/.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
76
+ acc.push(full)
77
+ }
78
+ }
79
+ return acc
80
+ }
81
+
82
+ const IMPORT_RE = /(?:^|\n)\s*(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+(?:[^'"]+\s+from\s+)|import\s*\(\s*)["']([^"']+)["']/g
83
+
84
+ function extractImports(source: string): string[] {
85
+ const out: string[] = []
86
+ let m: RegExpExecArray | null
87
+ while ((m = IMPORT_RE.exec(source)) !== null) {
88
+ out.push(m[1])
89
+ }
90
+ return out
91
+ }
92
+
93
+ function isAllowedSpecifier(spec: string): boolean {
94
+ if (NODE_BUILTINS.has(spec)) return true
95
+ return ALLOWED_IMPORT_PREFIXES.some((p) => spec === p || spec.startsWith(p))
96
+ }
97
+
98
+ function isForbiddenSpecifier(spec: string): boolean {
99
+ return FORBIDDEN_PREFIXES.some((p) => spec.startsWith(p))
100
+ }
101
+
102
+ describe('@dainamite/cpq-billing-connector package isolation', () => {
103
+ let allFiles: string[] = []
104
+ let importsByFile = new Map<string, string[]>()
105
+
106
+ beforeAll(async () => {
107
+ allFiles = await walk(PACKAGE_ROOT)
108
+ importsByFile = new Map()
109
+ for (const file of allFiles) {
110
+ const source = await fs.readFile(file, 'utf-8')
111
+ importsByFile.set(file, extractImports(source))
112
+ }
113
+ })
114
+
115
+ it('metadata.requires lists cpq + billing (peer-dep modules)', () => {
116
+ expect(metadata.requires).toEqual(
117
+ expect.arrayContaining(['cpq', 'billing']),
118
+ )
119
+ })
120
+
121
+ it('does not import from sibling app modules', () => {
122
+ const violations: Array<{ file: string; spec: string }> = []
123
+ for (const [file, specs] of importsByFile) {
124
+ for (const spec of specs) {
125
+ if (isForbiddenSpecifier(spec)) {
126
+ violations.push({ file: path.relative(PACKAGE_ROOT, file), spec })
127
+ }
128
+ }
129
+ }
130
+ if (violations.length > 0) {
131
+ throw new Error(
132
+ `Forbidden cross-module imports:\n${violations
133
+ .map((v) => ` ${v.file} → ${v.spec}`)
134
+ .join('\n')}`,
135
+ )
136
+ }
137
+ })
138
+
139
+ it('only uses allowed import prefixes', () => {
140
+ const violations: Array<{ file: string; spec: string }> = []
141
+ for (const [file, specs] of importsByFile) {
142
+ for (const spec of specs) {
143
+ if (!isAllowedSpecifier(spec)) {
144
+ violations.push({ file: path.relative(PACKAGE_ROOT, file), spec })
145
+ }
146
+ }
147
+ }
148
+ if (violations.length > 0) {
149
+ throw new Error(
150
+ `Imports outside the allowed prefix set:\n${violations
151
+ .map((v) => ` ${v.file} → ${v.spec}`)
152
+ .join('\n')}`,
153
+ )
154
+ }
155
+ })
156
+
157
+ it('contains no ORM relations (FK string only, peer-dep contract)', async () => {
158
+ const relRe = /@(ManyToOne|OneToMany|ManyToMany|OneToOne)\s*\(/g
159
+ const violations: Array<{ file: string; line: number }> = []
160
+ for (const file of allFiles) {
161
+ const source = await fs.readFile(file, 'utf-8')
162
+ const lines = source.split('\n')
163
+ lines.forEach((line, idx) => {
164
+ if (line.match(relRe)) {
165
+ violations.push({ file: path.relative(PACKAGE_ROOT, file), line: idx + 1 })
166
+ }
167
+ })
168
+ }
169
+ if (violations.length > 0) {
170
+ throw new Error(
171
+ `Cross-entity ORM relations forbidden in connector:\n${violations
172
+ .map((v) => ` ${v.file}:${v.line}`)
173
+ .join('\n')}`,
174
+ )
175
+ }
176
+ })
177
+
178
+ it('module index.ts loads without throwing', async () => {
179
+ const mod = await import('../index')
180
+ expect(mod.metadata).toBeDefined()
181
+ expect(mod.metadata.name).toBe('cpq_billing_connector')
182
+ expect(mod.features).toBeDefined()
183
+ })
184
+ })
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The connector is plumbing — it executes work in response to CPQ
3
+ * events on the system's authority, not on a user's request. There
4
+ * are therefore no operator-facing actions to gate at this layer.
5
+ *
6
+ * A future "manually replay CPQ events for subscription X" admin
7
+ * feature (Phase 4b backstop for ops emergencies) would declare a
8
+ * feature here.
9
+ */
10
+ export const features = [] as const
11
+
12
+ export default features
@@ -0,0 +1,6 @@
1
+ import type { CustomEntitySpec } from '@open-mercato/shared/modules/entities'
2
+
3
+ const entities: CustomEntitySpec[] = []
4
+
5
+ export { entities }
6
+ export default entities
@@ -0,0 +1,12 @@
1
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
+
3
+ /**
4
+ * No DI registrations — the connector resolves billing's command bus
5
+ * + dependencies from the host container at subscriber invocation
6
+ * time. Kept as a no-op so the generator picks `cpq_billing_connector`
7
+ * up as a DI-aware module without surprises when registrations land
8
+ * in a later phase.
9
+ */
10
+ export function register(_container: AppContainer): void {
11
+ // intentionally empty
12
+ }
@@ -0,0 +1,20 @@
1
+ import type { ModuleInfo } from '@open-mercato/shared/modules/registry'
2
+
3
+ export const metadata: ModuleInfo = {
4
+ name: 'cpq_billing_connector',
5
+ title: 'CPQ → Billing Connector',
6
+ version: '0.1.0',
7
+ description:
8
+ 'Wires @dainamite/cpq subscription lifecycle events to @dainamite/billing API calls. ' +
9
+ 'Owns proration math (CPQ stays neutral on billing calendar semantics).',
10
+ author: 'Dainamite',
11
+ license: 'MIT',
12
+ // `requires` lists every module whose data we read (directly or via
13
+ // command-bus calls). The connector itself stores no entities — it's
14
+ // pure plumbing — but it does call billing's command bus + needs CPQ
15
+ // events to fire.
16
+ requires: ['cpq', 'billing', 'auth', 'directory'],
17
+ ejectable: false,
18
+ }
19
+
20
+ export { features } from './acl'
@@ -0,0 +1,215 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
4
+ // TODO(L2-boundary): direct entity-class import couples the connector to
5
+ // billing's internal ORM shape — violates the dainamite-layering rule that
6
+ // cross-package coupling should be FK strings or REST. Tolerable at v0.1.x
7
+ // because reads are em-driven for performance (one round-trip vs serialise/
8
+ // deserialise via REST), but a `BillingAccount` / `BillingItem` rename in
9
+ // `@dainamite/billing` will manifest here as a silent runtime crash.
10
+ // Migrate to billing REST list endpoints once the connector graduates to
11
+ // stable v1 — track in the dainamite-cpq packaging plan.
12
+ import { BillingAccount, BillingItem } from '@dainamite/billing/modules/billing/data/entities'
13
+
14
+ /**
15
+ * Thin facade the connector uses to talk to billing.
16
+ *
17
+ * Reads (existence checks, lookups by subscription) go through the
18
+ * EM directly — billing's REST list endpoints would work, but going
19
+ * via the EM is one round-trip instead of a serialize-then-deserialize
20
+ * loop. Writes go through the billing command bus exclusively, so
21
+ * there is one and only one write path for every billing state
22
+ * mutation (the same path the REST routes invoke).
23
+ *
24
+ * Auth context: subscribers run as a system identity, not as a user.
25
+ * The command-bus `ctx` therefore carries `auth: null` and the
26
+ * tenant/organization explicitly. `ensureTenantScope` /
27
+ * `ensureOrganizationScope` are no-ops when `ctx.auth` is null
28
+ * (verified against `@open-mercato/shared/lib/commands/scope`) so
29
+ * the explicit fields in the payload are the authority.
30
+ */
31
+
32
+ export type Scope = {
33
+ tenantId: string
34
+ organizationId: string
35
+ }
36
+
37
+ export type AccountSeed = {
38
+ customerId: string
39
+ name: string
40
+ currencyCode: string
41
+ billCycle: 'monthly' | 'quarterly' | 'annually' | 'weekly'
42
+ billCycleAnchor: string
43
+ invoiceEmail: string
44
+ invoiceLanguage: string
45
+ invoiceAddress: Record<string, unknown>
46
+ nextBillDate: Date
47
+ taxId?: string | null
48
+ }
49
+
50
+ export type ItemCreatePayload = {
51
+ billAccountId: string
52
+ type: 'one_time' | 'recurring' | 'usage'
53
+ billStartDate: Date | string
54
+ billEndDate?: Date | string | null
55
+ description: string
56
+ rateJson: Record<string, unknown>
57
+ uomCode?: string
58
+ subscriptionId?: string
59
+ subscriptionItemId?: string
60
+ sourceRef?: string
61
+ }
62
+
63
+ export type ItemUpdatePayload = {
64
+ id: string
65
+ billEndDate?: Date | string | null
66
+ subscriptionId?: string | null
67
+ subscriptionItemId?: string | null
68
+ isActive?: boolean
69
+ }
70
+
71
+ function buildSystemCtx(container: AwilixContainer, scope: Scope) {
72
+ return {
73
+ container,
74
+ auth: null,
75
+ organizationScope: null,
76
+ selectedOrganizationId: scope.organizationId,
77
+ organizationIds: [scope.organizationId],
78
+ }
79
+ }
80
+
81
+ export class BillingApiClient {
82
+ private readonly em: EntityManager
83
+ private readonly commandBus: CommandBus
84
+
85
+ constructor(private readonly container: AwilixContainer) {
86
+ this.em = (container.resolve('em') as EntityManager).fork()
87
+ this.commandBus = container.resolve('commandBus') as CommandBus
88
+ }
89
+
90
+ // ─── Accounts ───────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Find a non-deleted Billing Account for `(tenant, organization,
94
+ * customer)`. Returns the first match or `null`. The connector
95
+ * uses this to skip account creation when the customer already
96
+ * has one.
97
+ */
98
+ async findAccountByCustomer(scope: Scope, customerId: string): Promise<BillingAccount | null> {
99
+ return this.em.findOne(BillingAccount, {
100
+ tenantId: scope.tenantId,
101
+ organizationId: scope.organizationId,
102
+ customerId,
103
+ deletedAt: null,
104
+ })
105
+ }
106
+
107
+ async createAccount(scope: Scope, seed: AccountSeed): Promise<{ id: string }> {
108
+ const result = await this.commandBus.execute('billing.accounts.create', {
109
+ input: {
110
+ tenantId: scope.tenantId,
111
+ organizationId: scope.organizationId,
112
+ ...seed,
113
+ },
114
+ ctx: buildSystemCtx(this.container, scope),
115
+ })
116
+ return (result as { result?: { id: string } }).result ?? { id: '' }
117
+ }
118
+
119
+ async getOrCreateAccount(scope: Scope, seed: AccountSeed): Promise<{ id: string; created: boolean }> {
120
+ const existing = await this.findAccountByCustomer(scope, seed.customerId)
121
+ if (existing) return { id: existing.id, created: false }
122
+ const created = await this.createAccount(scope, seed)
123
+ return { id: created.id, created: true }
124
+ }
125
+
126
+ // ─── Items ──────────────────────────────────────────────────────
127
+
128
+ async findItemsBySubscription(scope: Scope, subscriptionId: string): Promise<BillingItem[]> {
129
+ return this.em.find(BillingItem, {
130
+ tenantId: scope.tenantId,
131
+ organizationId: scope.organizationId,
132
+ subscriptionId,
133
+ deletedAt: null,
134
+ } as never)
135
+ }
136
+
137
+ async findItemsBySubscriptionItem(
138
+ scope: Scope,
139
+ subscriptionItemId: string,
140
+ ): Promise<BillingItem[]> {
141
+ return this.em.find(BillingItem, {
142
+ tenantId: scope.tenantId,
143
+ organizationId: scope.organizationId,
144
+ subscriptionItemId,
145
+ deletedAt: null,
146
+ } as never)
147
+ }
148
+
149
+ async createItem(
150
+ scope: Scope,
151
+ payload: ItemCreatePayload,
152
+ ): Promise<{ id: string; deduplicated: boolean }> {
153
+ const result = await this.commandBus.execute('billing.items.create', {
154
+ input: {
155
+ tenantId: scope.tenantId,
156
+ organizationId: scope.organizationId,
157
+ ...payload,
158
+ },
159
+ ctx: buildSystemCtx(this.container, scope),
160
+ })
161
+ const r = (result as { result?: { id: string; deduplicated: boolean } }).result
162
+ return r ?? { id: '', deduplicated: false }
163
+ }
164
+
165
+ /**
166
+ * Batch-create items in a single `billing.items.bulk_create`
167
+ * command. Use this whenever a subscriber has more than one item
168
+ * to create — it collapses N command-bus round-trips + N flushes
169
+ * into one. Idempotent per `source_ref` exactly like `createItem`.
170
+ *
171
+ * No-ops on an empty payload so callers skip the length guard.
172
+ */
173
+ async bulkCreateItems(
174
+ scope: Scope,
175
+ items: ItemCreatePayload[],
176
+ ): Promise<{
177
+ created: number
178
+ deduplicated: number
179
+ items: Array<{ sourceRef: string | null; id: string; deduplicated: boolean }>
180
+ }> {
181
+ if (items.length === 0) {
182
+ return { created: 0, deduplicated: 0, items: [] }
183
+ }
184
+ const result = await this.commandBus.execute('billing.items.bulk_create', {
185
+ input: {
186
+ tenantId: scope.tenantId,
187
+ organizationId: scope.organizationId,
188
+ items,
189
+ },
190
+ ctx: buildSystemCtx(this.container, scope),
191
+ })
192
+ const r = (
193
+ result as {
194
+ result?: {
195
+ created: number
196
+ deduplicated: number
197
+ items: Array<{ sourceRef: string | null; id: string; deduplicated: boolean }>
198
+ }
199
+ }
200
+ ).result
201
+ return r ?? { created: 0, deduplicated: 0, items: [] }
202
+ }
203
+
204
+ async updateItem(scope: Scope, payload: ItemUpdatePayload): Promise<{ id: string }> {
205
+ const result = await this.commandBus.execute('billing.items.update', {
206
+ input: {
207
+ tenantId: scope.tenantId,
208
+ organizationId: scope.organizationId,
209
+ ...payload,
210
+ },
211
+ ctx: buildSystemCtx(this.container, scope),
212
+ })
213
+ return (result as { result?: { id: string } }).result ?? { id: '' }
214
+ }
215
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * CPQ charge → billing item create payload mapping.
3
+ *
4
+ * CPQ subscription events carry an `items[].charges[]` array where
5
+ * each charge has `type: 'one_time' | 'recurring'`, an optional
6
+ * `amount` (for one-time) or `unitPrice` (for recurring), and a
7
+ * description. This module turns each charge into the
8
+ * `billing.items.create` payload shape (one Billing Item per charge,
9
+ * not per CPQ subscription item — per the spec: "If a CPQ
10
+ * subscription item has both a one-time charge … and a recurring
11
+ * charge …, the connector creates two separate Billing Items").
12
+ *
13
+ * `sourceRef` is built deterministically:
14
+ * `cpq-<subscriptionId>-<subscriptionItemId>-<charge.type>`
15
+ * so re-firing the same activation event hits the `(tenant_id,
16
+ * bill_account_id, source_ref)` unique constraint and the duplicate
17
+ * is dropped with `deduplicated: true` instead of creating a second
18
+ * item.
19
+ */
20
+
21
+ export type CpqChargeType = 'one_time' | 'recurring'
22
+
23
+ export type CpqCharge = {
24
+ type: CpqChargeType
25
+ amount?: number
26
+ unitPrice?: number
27
+ description: string
28
+ }
29
+
30
+ export type CpqSubscriptionItem = {
31
+ subscriptionItemId: string
32
+ productName: string
33
+ quantity: number
34
+ charges: CpqCharge[]
35
+ }
36
+
37
+ export type BillingItemPayload = {
38
+ type: CpqChargeType
39
+ description: string
40
+ rateJson: { amount: number } | { unit_price: number }
41
+ subscriptionId: string
42
+ subscriptionItemId: string
43
+ sourceRef: string
44
+ billStartDate: string
45
+ }
46
+
47
+ export function buildSourceRef(
48
+ subscriptionId: string,
49
+ subscriptionItemId: string,
50
+ chargeType: CpqChargeType,
51
+ ): string {
52
+ return `cpq-${subscriptionId}-${subscriptionItemId}-${chargeType}`
53
+ }
54
+
55
+ /**
56
+ * Map a single CPQ charge to a billing.items.create payload.
57
+ * Returns `null` if the charge is missing the required price field
58
+ * for its type — surfaces malformed CPQ payloads to the subscriber
59
+ * which can log + skip instead of crashing.
60
+ */
61
+ export function mapChargeToBillingItem(params: {
62
+ subscriptionId: string
63
+ subscriptionItem: CpqSubscriptionItem
64
+ charge: CpqCharge
65
+ /** YYYY-MM-DD; typically the subscription activation date. */
66
+ billStartDate: string
67
+ }): BillingItemPayload | null {
68
+ const { subscriptionId, subscriptionItem, charge, billStartDate } = params
69
+
70
+ if (charge.type === 'one_time') {
71
+ if (typeof charge.amount !== 'number') return null
72
+ return {
73
+ type: 'one_time',
74
+ description: `${subscriptionItem.productName} — ${charge.description}`,
75
+ rateJson: { amount: charge.amount },
76
+ subscriptionId,
77
+ subscriptionItemId: subscriptionItem.subscriptionItemId,
78
+ sourceRef: buildSourceRef(subscriptionId, subscriptionItem.subscriptionItemId, 'one_time'),
79
+ billStartDate,
80
+ }
81
+ }
82
+
83
+ // recurring
84
+ if (typeof charge.unitPrice !== 'number') return null
85
+ return {
86
+ type: 'recurring',
87
+ description: `${subscriptionItem.productName} — ${charge.description}`,
88
+ rateJson: { unit_price: charge.unitPrice },
89
+ subscriptionId,
90
+ subscriptionItemId: subscriptionItem.subscriptionItemId,
91
+ sourceRef: buildSourceRef(subscriptionId, subscriptionItem.subscriptionItemId, 'recurring'),
92
+ billStartDate,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Map every charge on every subscription item in a CPQ payload to
98
+ * billing item payloads. Skips charges that fail the per-charge
99
+ * mapping (returns `null`) — the connector logs those rather than
100
+ * partially-failing the whole event.
101
+ */
102
+ export function mapSubscriptionItemsToBillingItems(params: {
103
+ subscriptionId: string
104
+ items: CpqSubscriptionItem[]
105
+ billStartDate: string
106
+ }): BillingItemPayload[] {
107
+ const out: BillingItemPayload[] = []
108
+ for (const item of params.items) {
109
+ for (const charge of item.charges) {
110
+ const mapped = mapChargeToBillingItem({
111
+ subscriptionId: params.subscriptionId,
112
+ subscriptionItem: item,
113
+ charge,
114
+ billStartDate: params.billStartDate,
115
+ })
116
+ if (mapped) out.push(mapped)
117
+ }
118
+ }
119
+ return out
120
+ }