@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,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,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
|
+
}
|