@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @dainamite/cpq-billing-connector
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — Initial release (unreleased)
|
|
4
|
+
|
|
5
|
+
- 6 subscribers translating CPQ subscription lifecycle events to
|
|
6
|
+
`@dainamite/billing` command-bus calls:
|
|
7
|
+
- `cpq.subscription.activated` *(awaits CPQ upstream PR)* → create
|
|
8
|
+
Billing Account if missing, bulk-create Billing Items per charge.
|
|
9
|
+
- `cpq.subscription.amended` → bulk-add new items (including the
|
|
10
|
+
proration `one_time` Item the connector computes here), set
|
|
11
|
+
`bill_end_date` on removed items.
|
|
12
|
+
- `cpq.subscription.renewed` → extend `bill_end_date` + bulk-add any
|
|
13
|
+
new items.
|
|
14
|
+
- `cpq.subscription.cancelled` → set `bill_end_date` on every item
|
|
15
|
+
for the cancelled subscription.
|
|
16
|
+
- `cpq.subscription.merged` → re-link items from source
|
|
17
|
+
subscriptions to the merged target.
|
|
18
|
+
- `cpq.subscription.superseded` → set `bill_end_date` on every
|
|
19
|
+
item for the superseded subscription.
|
|
20
|
+
- Pure helpers:
|
|
21
|
+
- `lib/prorationHelper.ts` — `unitPrice × quantity × daysRemaining
|
|
22
|
+
/ daysInPeriod` with half-up 2dp rounding, matching the spec's
|
|
23
|
+
invoice-amount rounding policy. Proration math lives here (not
|
|
24
|
+
in CPQ) so CPQ stays neutral on billing calendar semantics.
|
|
25
|
+
- `lib/chargeMapper.ts` — CPQ charge → billing item create payload.
|
|
26
|
+
- `lib/billingApiClient.ts` — internal commandBus wrapper. Subscribers
|
|
27
|
+
go through the same command IDs that the billing REST routes
|
|
28
|
+
use — `billing.accounts.create`, `billing.items.bulk_create`,
|
|
29
|
+
etc. — so there is only one write path for billing state. Item
|
|
30
|
+
creation always goes through `billing.items.bulk_create` (one
|
|
31
|
+
batched account lookup + idempotency query + flush) rather than a
|
|
32
|
+
per-item loop.
|
|
33
|
+
- Module ID: `cpq_billing_connector` (plural-ish-snake-case, matches
|
|
34
|
+
OM's `auth` / `customers` / `dictionaries` convention).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dainamite Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @dainamite/cpq-billing-connector
|
|
2
|
+
|
|
3
|
+
Pre-built integration that wires `@dainamite/cpq` subscription lifecycle
|
|
4
|
+
events to `@dainamite/billing` API calls.
|
|
5
|
+
|
|
6
|
+
It depends on both `@dainamite/cpq` and `@dainamite/billing` (as peers) and
|
|
7
|
+
**must be registered after both** so their services are resolvable when its
|
|
8
|
+
subscribers fire:
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
// src/modules.ts — order matters
|
|
12
|
+
{ id: 'cpq', from: '@dainamite/cpq' }
|
|
13
|
+
{ id: 'billing', from: '@dainamite/billing' }
|
|
14
|
+
{ id: 'cpq_billing_connector', from: '@dainamite/cpq-billing-connector' }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
yarn generate
|
|
19
|
+
yarn mercato db migrate
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
There is no UI or schema of its own to configure — it is pure event glue.
|
|
23
|
+
Engines: **Node ≥ 24**.
|
|
24
|
+
|
|
25
|
+
The connector subscribes to six CPQ lifecycle events and translates each
|
|
26
|
+
to the billing API contract:
|
|
27
|
+
|
|
28
|
+
| CPQ event | Connector action |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `cpq.subscription.activated` | Create Billing Account if missing; create Billing Items per charge (one-time + recurring split) |
|
|
31
|
+
| `cpq.subscription.amended` | Create new Billing Items for added subscription items; compute proration value for the partial cycle and post as `one_time` Billing Item; set `bill_end_date` on removed items |
|
|
32
|
+
| `cpq.subscription.renewed` | Extend `bill_end_date` on existing Billing Items; create new Items for any added subscription items |
|
|
33
|
+
| `cpq.subscription.cancelled` | Set `bill_end_date` on all Billing Items for the subscription |
|
|
34
|
+
| `cpq.subscription.merged` | Move Billing Items from source subscriptions to the merged subscription (update `subscription_id`) |
|
|
35
|
+
| `cpq.subscription.superseded` | Set `bill_end_date` on all Billing Items for the superseded subscription |
|
|
36
|
+
|
|
37
|
+
## Status
|
|
38
|
+
|
|
39
|
+
**Built — publishing soon** (alongside `@dainamite/billing`; not yet on npm).
|
|
40
|
+
All six subscribers are wired end-to-end — `@dainamite/cpq` emits the
|
|
41
|
+
matching events (`cpq.subscription.activated` on new-sale activation,
|
|
42
|
+
the ARC events on amend / renew / cancel / merge). Proration math lives
|
|
43
|
+
in this package: the `amended` subscriber derives the bill cycle from
|
|
44
|
+
the billing account via billing's `cycleContaining` (CPQ stays neutral
|
|
45
|
+
on billing-calendar semantics).
|
|
46
|
+
|
|
47
|
+
See [`specs/implementation/xd-249-billing-spec.md`](../../specs/implementation/xd-249-billing-spec.md)
|
|
48
|
+
§ CPQ Integration for the full design.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/cpq_billing_connector/acl.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * The connector is plumbing \u2014 it executes work in response to CPQ\n * events on the system's authority, not on a user's request. There\n * are therefore no operator-facing actions to gate at this layer.\n *\n * A future \"manually replay CPQ events for subscription X\" admin\n * feature (Phase 4b backstop for ops emergencies) would declare a\n * feature here.\n */\nexport const features = [] as const\n\nexport default features\n"],
|
|
5
|
+
"mappings": "AASO,MAAM,WAAW,CAAC;AAEzB,IAAO,cAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/cpq_billing_connector/ce.ts"],
|
|
4
|
+
"sourcesContent": ["import type { CustomEntitySpec } from '@open-mercato/shared/modules/entities'\n\nconst entities: CustomEntitySpec[] = []\n\nexport { entities }\nexport default entities\n"],
|
|
5
|
+
"mappings": "AAEA,MAAM,WAA+B,CAAC;AAGtC,IAAO,aAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/cpq_billing_connector/di.ts"],
|
|
4
|
+
"sourcesContent": ["import type { AppContainer } from '@open-mercato/shared/lib/di/container'\n\n/**\n * No DI registrations \u2014 the connector resolves billing's command bus\n * + dependencies from the host container at subscriber invocation\n * time. Kept as a no-op so the generator picks `cpq_billing_connector`\n * up as a DI-aware module without surprises when registrations land\n * in a later phase.\n */\nexport function register(_container: AppContainer): void {\n // intentionally empty\n}\n"],
|
|
5
|
+
"mappings": "AASO,SAAS,SAAS,YAAgC;AAEzD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const metadata = {
|
|
2
|
+
name: "cpq_billing_connector",
|
|
3
|
+
title: "CPQ \u2192 Billing Connector",
|
|
4
|
+
version: "0.1.0",
|
|
5
|
+
description: "Wires @dainamite/cpq subscription lifecycle events to @dainamite/billing API calls. Owns proration math (CPQ stays neutral on billing calendar semantics).",
|
|
6
|
+
author: "Dainamite",
|
|
7
|
+
license: "MIT",
|
|
8
|
+
// `requires` lists every module whose data we read (directly or via
|
|
9
|
+
// command-bus calls). The connector itself stores no entities — it's
|
|
10
|
+
// pure plumbing — but it does call billing's command bus + needs CPQ
|
|
11
|
+
// events to fire.
|
|
12
|
+
requires: ["cpq", "billing", "auth", "directory"],
|
|
13
|
+
ejectable: false
|
|
14
|
+
};
|
|
15
|
+
import { features } from "./acl.js";
|
|
16
|
+
export {
|
|
17
|
+
features,
|
|
18
|
+
metadata
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/cpq_billing_connector/index.ts"],
|
|
4
|
+
"sourcesContent": ["import type { ModuleInfo } from '@open-mercato/shared/modules/registry'\n\nexport const metadata: ModuleInfo = {\n name: 'cpq_billing_connector',\n title: 'CPQ \u2192 Billing Connector',\n version: '0.1.0',\n description:\n 'Wires @dainamite/cpq subscription lifecycle events to @dainamite/billing API calls. ' +\n 'Owns proration math (CPQ stays neutral on billing calendar semantics).',\n author: 'Dainamite',\n license: 'MIT',\n // `requires` lists every module whose data we read (directly or via\n // command-bus calls). The connector itself stores no entities \u2014 it's\n // pure plumbing \u2014 but it does call billing's command bus + needs CPQ\n // events to fire.\n requires: ['cpq', 'billing', 'auth', 'directory'],\n ejectable: false,\n}\n\nexport { features } from './acl'\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,WAAuB;AAAA,EAClC,MAAM;AAAA,EACN,OAAO;AAAA,EACP,SAAS;AAAA,EACT,aACE;AAAA,EAEF,QAAQ;AAAA,EACR,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,EAKT,UAAU,CAAC,OAAO,WAAW,QAAQ,WAAW;AAAA,EAChD,WAAW;AACb;AAEA,SAAS,gBAAgB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { BillingAccount, BillingItem } from "@dainamite/billing/modules/billing/data/entities";
|
|
2
|
+
function buildSystemCtx(container, scope) {
|
|
3
|
+
return {
|
|
4
|
+
container,
|
|
5
|
+
auth: null,
|
|
6
|
+
organizationScope: null,
|
|
7
|
+
selectedOrganizationId: scope.organizationId,
|
|
8
|
+
organizationIds: [scope.organizationId]
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
class BillingApiClient {
|
|
12
|
+
constructor(container) {
|
|
13
|
+
this.container = container;
|
|
14
|
+
this.em = container.resolve("em").fork();
|
|
15
|
+
this.commandBus = container.resolve("commandBus");
|
|
16
|
+
}
|
|
17
|
+
// ─── Accounts ───────────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Find a non-deleted Billing Account for `(tenant, organization,
|
|
20
|
+
* customer)`. Returns the first match or `null`. The connector
|
|
21
|
+
* uses this to skip account creation when the customer already
|
|
22
|
+
* has one.
|
|
23
|
+
*/
|
|
24
|
+
async findAccountByCustomer(scope, customerId) {
|
|
25
|
+
return this.em.findOne(BillingAccount, {
|
|
26
|
+
tenantId: scope.tenantId,
|
|
27
|
+
organizationId: scope.organizationId,
|
|
28
|
+
customerId,
|
|
29
|
+
deletedAt: null
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async createAccount(scope, seed) {
|
|
33
|
+
const result = await this.commandBus.execute("billing.accounts.create", {
|
|
34
|
+
input: {
|
|
35
|
+
tenantId: scope.tenantId,
|
|
36
|
+
organizationId: scope.organizationId,
|
|
37
|
+
...seed
|
|
38
|
+
},
|
|
39
|
+
ctx: buildSystemCtx(this.container, scope)
|
|
40
|
+
});
|
|
41
|
+
return result.result ?? { id: "" };
|
|
42
|
+
}
|
|
43
|
+
async getOrCreateAccount(scope, seed) {
|
|
44
|
+
const existing = await this.findAccountByCustomer(scope, seed.customerId);
|
|
45
|
+
if (existing) return { id: existing.id, created: false };
|
|
46
|
+
const created = await this.createAccount(scope, seed);
|
|
47
|
+
return { id: created.id, created: true };
|
|
48
|
+
}
|
|
49
|
+
// ─── Items ──────────────────────────────────────────────────────
|
|
50
|
+
async findItemsBySubscription(scope, subscriptionId) {
|
|
51
|
+
return this.em.find(BillingItem, {
|
|
52
|
+
tenantId: scope.tenantId,
|
|
53
|
+
organizationId: scope.organizationId,
|
|
54
|
+
subscriptionId,
|
|
55
|
+
deletedAt: null
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async findItemsBySubscriptionItem(scope, subscriptionItemId) {
|
|
59
|
+
return this.em.find(BillingItem, {
|
|
60
|
+
tenantId: scope.tenantId,
|
|
61
|
+
organizationId: scope.organizationId,
|
|
62
|
+
subscriptionItemId,
|
|
63
|
+
deletedAt: null
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async createItem(scope, payload) {
|
|
67
|
+
const result = await this.commandBus.execute("billing.items.create", {
|
|
68
|
+
input: {
|
|
69
|
+
tenantId: scope.tenantId,
|
|
70
|
+
organizationId: scope.organizationId,
|
|
71
|
+
...payload
|
|
72
|
+
},
|
|
73
|
+
ctx: buildSystemCtx(this.container, scope)
|
|
74
|
+
});
|
|
75
|
+
const r = result.result;
|
|
76
|
+
return r ?? { id: "", deduplicated: false };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Batch-create items in a single `billing.items.bulk_create`
|
|
80
|
+
* command. Use this whenever a subscriber has more than one item
|
|
81
|
+
* to create — it collapses N command-bus round-trips + N flushes
|
|
82
|
+
* into one. Idempotent per `source_ref` exactly like `createItem`.
|
|
83
|
+
*
|
|
84
|
+
* No-ops on an empty payload so callers skip the length guard.
|
|
85
|
+
*/
|
|
86
|
+
async bulkCreateItems(scope, items) {
|
|
87
|
+
if (items.length === 0) {
|
|
88
|
+
return { created: 0, deduplicated: 0, items: [] };
|
|
89
|
+
}
|
|
90
|
+
const result = await this.commandBus.execute("billing.items.bulk_create", {
|
|
91
|
+
input: {
|
|
92
|
+
tenantId: scope.tenantId,
|
|
93
|
+
organizationId: scope.organizationId,
|
|
94
|
+
items
|
|
95
|
+
},
|
|
96
|
+
ctx: buildSystemCtx(this.container, scope)
|
|
97
|
+
});
|
|
98
|
+
const r = result.result;
|
|
99
|
+
return r ?? { created: 0, deduplicated: 0, items: [] };
|
|
100
|
+
}
|
|
101
|
+
async updateItem(scope, payload) {
|
|
102
|
+
const result = await this.commandBus.execute("billing.items.update", {
|
|
103
|
+
input: {
|
|
104
|
+
tenantId: scope.tenantId,
|
|
105
|
+
organizationId: scope.organizationId,
|
|
106
|
+
...payload
|
|
107
|
+
},
|
|
108
|
+
ctx: buildSystemCtx(this.container, scope)
|
|
109
|
+
});
|
|
110
|
+
return result.result ?? { id: "" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
BillingApiClient
|
|
115
|
+
};
|
|
116
|
+
//# sourceMappingURL=billingApiClient.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/cpq_billing_connector/lib/billingApiClient.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\n// TODO(L2-boundary): direct entity-class import couples the connector to\n// billing's internal ORM shape \u2014 violates the dainamite-layering rule that\n// cross-package coupling should be FK strings or REST. Tolerable at v0.1.x\n// because reads are em-driven for performance (one round-trip vs serialise/\n// deserialise via REST), but a `BillingAccount` / `BillingItem` rename in\n// `@dainamite/billing` will manifest here as a silent runtime crash.\n// Migrate to billing REST list endpoints once the connector graduates to\n// stable v1 \u2014 track in the dainamite-cpq packaging plan.\nimport { BillingAccount, BillingItem } from '@dainamite/billing/modules/billing/data/entities'\n\n/**\n * Thin facade the connector uses to talk to billing.\n *\n * Reads (existence checks, lookups by subscription) go through the\n * EM directly \u2014 billing's REST list endpoints would work, but going\n * via the EM is one round-trip instead of a serialize-then-deserialize\n * loop. Writes go through the billing command bus exclusively, so\n * there is one and only one write path for every billing state\n * mutation (the same path the REST routes invoke).\n *\n * Auth context: subscribers run as a system identity, not as a user.\n * The command-bus `ctx` therefore carries `auth: null` and the\n * tenant/organization explicitly. `ensureTenantScope` /\n * `ensureOrganizationScope` are no-ops when `ctx.auth` is null\n * (verified against `@open-mercato/shared/lib/commands/scope`) so\n * the explicit fields in the payload are the authority.\n */\n\nexport type Scope = {\n tenantId: string\n organizationId: string\n}\n\nexport type AccountSeed = {\n customerId: string\n name: string\n currencyCode: string\n billCycle: 'monthly' | 'quarterly' | 'annually' | 'weekly'\n billCycleAnchor: string\n invoiceEmail: string\n invoiceLanguage: string\n invoiceAddress: Record<string, unknown>\n nextBillDate: Date\n taxId?: string | null\n}\n\nexport type ItemCreatePayload = {\n billAccountId: string\n type: 'one_time' | 'recurring' | 'usage'\n billStartDate: Date | string\n billEndDate?: Date | string | null\n description: string\n rateJson: Record<string, unknown>\n uomCode?: string\n subscriptionId?: string\n subscriptionItemId?: string\n sourceRef?: string\n}\n\nexport type ItemUpdatePayload = {\n id: string\n billEndDate?: Date | string | null\n subscriptionId?: string | null\n subscriptionItemId?: string | null\n isActive?: boolean\n}\n\nfunction buildSystemCtx(container: AwilixContainer, scope: Scope) {\n return {\n container,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: [scope.organizationId],\n }\n}\n\nexport class BillingApiClient {\n private readonly em: EntityManager\n private readonly commandBus: CommandBus\n\n constructor(private readonly container: AwilixContainer) {\n this.em = (container.resolve('em') as EntityManager).fork()\n this.commandBus = container.resolve('commandBus') as CommandBus\n }\n\n // \u2500\u2500\u2500 Accounts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /**\n * Find a non-deleted Billing Account for `(tenant, organization,\n * customer)`. Returns the first match or `null`. The connector\n * uses this to skip account creation when the customer already\n * has one.\n */\n async findAccountByCustomer(scope: Scope, customerId: string): Promise<BillingAccount | null> {\n return this.em.findOne(BillingAccount, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n customerId,\n deletedAt: null,\n })\n }\n\n async createAccount(scope: Scope, seed: AccountSeed): Promise<{ id: string }> {\n const result = await this.commandBus.execute('billing.accounts.create', {\n input: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n ...seed,\n },\n ctx: buildSystemCtx(this.container, scope),\n })\n return (result as { result?: { id: string } }).result ?? { id: '' }\n }\n\n async getOrCreateAccount(scope: Scope, seed: AccountSeed): Promise<{ id: string; created: boolean }> {\n const existing = await this.findAccountByCustomer(scope, seed.customerId)\n if (existing) return { id: existing.id, created: false }\n const created = await this.createAccount(scope, seed)\n return { id: created.id, created: true }\n }\n\n // \u2500\u2500\u2500 Items \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n async findItemsBySubscription(scope: Scope, subscriptionId: string): Promise<BillingItem[]> {\n return this.em.find(BillingItem, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n subscriptionId,\n deletedAt: null,\n } as never)\n }\n\n async findItemsBySubscriptionItem(\n scope: Scope,\n subscriptionItemId: string,\n ): Promise<BillingItem[]> {\n return this.em.find(BillingItem, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n subscriptionItemId,\n deletedAt: null,\n } as never)\n }\n\n async createItem(\n scope: Scope,\n payload: ItemCreatePayload,\n ): Promise<{ id: string; deduplicated: boolean }> {\n const result = await this.commandBus.execute('billing.items.create', {\n input: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n ...payload,\n },\n ctx: buildSystemCtx(this.container, scope),\n })\n const r = (result as { result?: { id: string; deduplicated: boolean } }).result\n return r ?? { id: '', deduplicated: false }\n }\n\n /**\n * Batch-create items in a single `billing.items.bulk_create`\n * command. Use this whenever a subscriber has more than one item\n * to create \u2014 it collapses N command-bus round-trips + N flushes\n * into one. Idempotent per `source_ref` exactly like `createItem`.\n *\n * No-ops on an empty payload so callers skip the length guard.\n */\n async bulkCreateItems(\n scope: Scope,\n items: ItemCreatePayload[],\n ): Promise<{\n created: number\n deduplicated: number\n items: Array<{ sourceRef: string | null; id: string; deduplicated: boolean }>\n }> {\n if (items.length === 0) {\n return { created: 0, deduplicated: 0, items: [] }\n }\n const result = await this.commandBus.execute('billing.items.bulk_create', {\n input: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n items,\n },\n ctx: buildSystemCtx(this.container, scope),\n })\n const r = (\n result as {\n result?: {\n created: number\n deduplicated: number\n items: Array<{ sourceRef: string | null; id: string; deduplicated: boolean }>\n }\n }\n ).result\n return r ?? { created: 0, deduplicated: 0, items: [] }\n }\n\n async updateItem(scope: Scope, payload: ItemUpdatePayload): Promise<{ id: string }> {\n const result = await this.commandBus.execute('billing.items.update', {\n input: {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n ...payload,\n },\n ctx: buildSystemCtx(this.container, scope),\n })\n return (result as { result?: { id: string } }).result ?? { id: '' }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function buildSourceRef(subscriptionId, subscriptionItemId, chargeType) {
|
|
2
|
+
return `cpq-${subscriptionId}-${subscriptionItemId}-${chargeType}`;
|
|
3
|
+
}
|
|
4
|
+
function mapChargeToBillingItem(params) {
|
|
5
|
+
const { subscriptionId, subscriptionItem, charge, billStartDate } = params;
|
|
6
|
+
if (charge.type === "one_time") {
|
|
7
|
+
if (typeof charge.amount !== "number") return null;
|
|
8
|
+
return {
|
|
9
|
+
type: "one_time",
|
|
10
|
+
description: `${subscriptionItem.productName} \u2014 ${charge.description}`,
|
|
11
|
+
rateJson: { amount: charge.amount },
|
|
12
|
+
subscriptionId,
|
|
13
|
+
subscriptionItemId: subscriptionItem.subscriptionItemId,
|
|
14
|
+
sourceRef: buildSourceRef(subscriptionId, subscriptionItem.subscriptionItemId, "one_time"),
|
|
15
|
+
billStartDate
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (typeof charge.unitPrice !== "number") return null;
|
|
19
|
+
return {
|
|
20
|
+
type: "recurring",
|
|
21
|
+
description: `${subscriptionItem.productName} \u2014 ${charge.description}`,
|
|
22
|
+
rateJson: { unit_price: charge.unitPrice },
|
|
23
|
+
subscriptionId,
|
|
24
|
+
subscriptionItemId: subscriptionItem.subscriptionItemId,
|
|
25
|
+
sourceRef: buildSourceRef(subscriptionId, subscriptionItem.subscriptionItemId, "recurring"),
|
|
26
|
+
billStartDate
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function mapSubscriptionItemsToBillingItems(params) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const item of params.items) {
|
|
32
|
+
for (const charge of item.charges) {
|
|
33
|
+
const mapped = mapChargeToBillingItem({
|
|
34
|
+
subscriptionId: params.subscriptionId,
|
|
35
|
+
subscriptionItem: item,
|
|
36
|
+
charge,
|
|
37
|
+
billStartDate: params.billStartDate
|
|
38
|
+
});
|
|
39
|
+
if (mapped) out.push(mapped);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
buildSourceRef,
|
|
46
|
+
mapChargeToBillingItem,
|
|
47
|
+
mapSubscriptionItemsToBillingItems
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=chargeMapper.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/cpq_billing_connector/lib/chargeMapper.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * CPQ charge \u2192 billing item create payload mapping.\n *\n * CPQ subscription events carry an `items[].charges[]` array where\n * each charge has `type: 'one_time' | 'recurring'`, an optional\n * `amount` (for one-time) or `unitPrice` (for recurring), and a\n * description. This module turns each charge into the\n * `billing.items.create` payload shape (one Billing Item per charge,\n * not per CPQ subscription item \u2014 per the spec: \"If a CPQ\n * subscription item has both a one-time charge \u2026 and a recurring\n * charge \u2026, the connector creates two separate Billing Items\").\n *\n * `sourceRef` is built deterministically:\n * `cpq-<subscriptionId>-<subscriptionItemId>-<charge.type>`\n * so re-firing the same activation event hits the `(tenant_id,\n * bill_account_id, source_ref)` unique constraint and the duplicate\n * is dropped with `deduplicated: true` instead of creating a second\n * item.\n */\n\nexport type CpqChargeType = 'one_time' | 'recurring'\n\nexport type CpqCharge = {\n type: CpqChargeType\n amount?: number\n unitPrice?: number\n description: string\n}\n\nexport type CpqSubscriptionItem = {\n subscriptionItemId: string\n productName: string\n quantity: number\n charges: CpqCharge[]\n}\n\nexport type BillingItemPayload = {\n type: CpqChargeType\n description: string\n rateJson: { amount: number } | { unit_price: number }\n subscriptionId: string\n subscriptionItemId: string\n sourceRef: string\n billStartDate: string\n}\n\nexport function buildSourceRef(\n subscriptionId: string,\n subscriptionItemId: string,\n chargeType: CpqChargeType,\n): string {\n return `cpq-${subscriptionId}-${subscriptionItemId}-${chargeType}`\n}\n\n/**\n * Map a single CPQ charge to a billing.items.create payload.\n * Returns `null` if the charge is missing the required price field\n * for its type \u2014 surfaces malformed CPQ payloads to the subscriber\n * which can log + skip instead of crashing.\n */\nexport function mapChargeToBillingItem(params: {\n subscriptionId: string\n subscriptionItem: CpqSubscriptionItem\n charge: CpqCharge\n /** YYYY-MM-DD; typically the subscription activation date. */\n billStartDate: string\n}): BillingItemPayload | null {\n const { subscriptionId, subscriptionItem, charge, billStartDate } = params\n\n if (charge.type === 'one_time') {\n if (typeof charge.amount !== 'number') return null\n return {\n type: 'one_time',\n description: `${subscriptionItem.productName} \u2014 ${charge.description}`,\n rateJson: { amount: charge.amount },\n subscriptionId,\n subscriptionItemId: subscriptionItem.subscriptionItemId,\n sourceRef: buildSourceRef(subscriptionId, subscriptionItem.subscriptionItemId, 'one_time'),\n billStartDate,\n }\n }\n\n // recurring\n if (typeof charge.unitPrice !== 'number') return null\n return {\n type: 'recurring',\n description: `${subscriptionItem.productName} \u2014 ${charge.description}`,\n rateJson: { unit_price: charge.unitPrice },\n subscriptionId,\n subscriptionItemId: subscriptionItem.subscriptionItemId,\n sourceRef: buildSourceRef(subscriptionId, subscriptionItem.subscriptionItemId, 'recurring'),\n billStartDate,\n }\n}\n\n/**\n * Map every charge on every subscription item in a CPQ payload to\n * billing item payloads. Skips charges that fail the per-charge\n * mapping (returns `null`) \u2014 the connector logs those rather than\n * partially-failing the whole event.\n */\nexport function mapSubscriptionItemsToBillingItems(params: {\n subscriptionId: string\n items: CpqSubscriptionItem[]\n billStartDate: string\n}): BillingItemPayload[] {\n const out: BillingItemPayload[] = []\n for (const item of params.items) {\n for (const charge of item.charges) {\n const mapped = mapChargeToBillingItem({\n subscriptionId: params.subscriptionId,\n subscriptionItem: item,\n charge,\n billStartDate: params.billStartDate,\n })\n if (mapped) out.push(mapped)\n }\n }\n return out\n}\n"],
|
|
5
|
+
"mappings": "AA8CO,SAAS,eACd,gBACA,oBACA,YACQ;AACR,SAAO,OAAO,cAAc,IAAI,kBAAkB,IAAI,UAAU;AAClE;AAQO,SAAS,uBAAuB,QAMT;AAC5B,QAAM,EAAE,gBAAgB,kBAAkB,QAAQ,cAAc,IAAI;AAEpE,MAAI,OAAO,SAAS,YAAY;AAC9B,QAAI,OAAO,OAAO,WAAW,SAAU,QAAO;AAC9C,WAAO;AAAA,MACL,MAAM;AAAA,MACN,aAAa,GAAG,iBAAiB,WAAW,WAAM,OAAO,WAAW;AAAA,MACpE,UAAU,EAAE,QAAQ,OAAO,OAAO;AAAA,MAClC;AAAA,MACA,oBAAoB,iBAAiB;AAAA,MACrC,WAAW,eAAe,gBAAgB,iBAAiB,oBAAoB,UAAU;AAAA,MACzF;AAAA,IACF;AAAA,EACF;AAGA,MAAI,OAAO,OAAO,cAAc,SAAU,QAAO;AACjD,SAAO;AAAA,IACL,MAAM;AAAA,IACN,aAAa,GAAG,iBAAiB,WAAW,WAAM,OAAO,WAAW;AAAA,IACpE,UAAU,EAAE,YAAY,OAAO,UAAU;AAAA,IACzC;AAAA,IACA,oBAAoB,iBAAiB;AAAA,IACrC,WAAW,eAAe,gBAAgB,iBAAiB,oBAAoB,WAAW;AAAA,IAC1F;AAAA,EACF;AACF;AAQO,SAAS,mCAAmC,QAI1B;AACvB,QAAM,MAA4B,CAAC;AACnC,aAAW,QAAQ,OAAO,OAAO;AAC/B,eAAW,UAAU,KAAK,SAAS;AACjC,YAAM,SAAS,uBAAuB;AAAA,QACpC,gBAAgB,OAAO;AAAA,QACvB,kBAAkB;AAAA,QAClB;AAAA,QACA,eAAe,OAAO;AAAA,MACxB,CAAC;AACD,UAAI,OAAQ,KAAI,KAAK,MAAM;AAAA,IAC7B;AAAA,EACF;AACA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function roundHalfUp2dp(value) {
|
|
2
|
+
return Math.round((value + Number.EPSILON) * 100) / 100;
|
|
3
|
+
}
|
|
4
|
+
function computeProration(input) {
|
|
5
|
+
if (input.daysInPeriod <= 0) {
|
|
6
|
+
return { amount: 0, amountString: "0.0000" };
|
|
7
|
+
}
|
|
8
|
+
const raw = input.unitPrice * input.quantity * Math.max(0, input.daysRemaining) / input.daysInPeriod;
|
|
9
|
+
const rounded = roundHalfUp2dp(raw);
|
|
10
|
+
return { amount: rounded, amountString: rounded.toFixed(4) };
|
|
11
|
+
}
|
|
12
|
+
function formatProrationDescription(params) {
|
|
13
|
+
return `Proration: ${params.productName} from ${params.effectiveDate} to ${params.cycleEnd}`;
|
|
14
|
+
}
|
|
15
|
+
export {
|
|
16
|
+
computeProration,
|
|
17
|
+
formatProrationDescription
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=prorationHelper.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/cpq_billing_connector/lib/prorationHelper.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Proration math owned by the connector.\n *\n * Per spec: \"Connectors computing proration values should follow the\n * same convention for consistency on the draft\" \u2014 i.e. half-up 2dp,\n * matching billing's `roundHalfUp2dp` policy. Duplicated here (vs\n * imported from `@dainamite/billing`) on purpose so the connector\n * doesn't reach into billing's internals via a non-peer-dep path.\n *\n * The formula is the linear-day proration documented in the spec's\n * \"CPQ-side payload contracts\" section:\n * `prorationAmount = unitPrice \u00D7 quantity \u00D7 daysRemaining / daysInPeriod`\n *\n * For mid-cycle additions (e.g. customer buys an add-on on 2026-05-15\n * for a monthly cycle anchored on the 1st), `daysRemaining` is the\n * number of full days from the effective date through the cycle end\n * (inclusive), and `daysInPeriod` is the cycle's calendar-day length.\n *\n * The CPQ event payload supplies both numbers \u2014 the connector trusts\n * the calendar arithmetic CPQ already did (it derived the period\n * boundaries from the subscription's lifecycle). It does NOT\n * re-compute them from `effectiveDate` and `cycleEnd` to avoid\n * disagreeing with CPQ's source of truth.\n */\n\n/**\n * Half-up 2dp rounding that survives JS floating-point edges\n * (e.g. `1.005 * 100 === 100.49999\u2026`). Matches billing's\n * `roundHalfUp2dp` policy.\n */\nfunction roundHalfUp2dp(value: number): number {\n return Math.round((value + Number.EPSILON) * 100) / 100\n}\n\nexport type ProrationInput = {\n unitPrice: number\n quantity: number\n daysInPeriod: number\n daysRemaining: number\n}\n\nexport type ProrationResult = {\n /** Numeric amount, half-up 2dp, suitable for `rate_json.amount`. */\n amount: number\n /** Computed string form for `billing.items.create.rateJson` (4dp). */\n amountString: string\n}\n\nexport function computeProration(input: ProrationInput): ProrationResult {\n // Guard against the degenerate \"0 days in period\" \u2014 should never\n // happen if CPQ derived the values, but defending here avoids a\n // silent NaN propagation if it ever does.\n if (input.daysInPeriod <= 0) {\n return { amount: 0, amountString: '0.0000' }\n }\n const raw =\n (input.unitPrice * input.quantity * Math.max(0, input.daysRemaining)) /\n input.daysInPeriod\n const rounded = roundHalfUp2dp(raw)\n return { amount: rounded, amountString: rounded.toFixed(4) }\n}\n\n/**\n * Helper used by subscribers to format the proration description so\n * the resulting invoice line reads naturally (matches spec example:\n * \"Proration: Package X from 2026-05-15 to 2026-05-31\").\n */\nexport function formatProrationDescription(params: {\n productName: string\n effectiveDate: string\n cycleEnd: string\n}): string {\n return `Proration: ${params.productName} from ${params.effectiveDate} to ${params.cycleEnd}`\n}\n"],
|
|
5
|
+
"mappings": "AA8BA,SAAS,eAAe,OAAuB;AAC7C,SAAO,KAAK,OAAO,QAAQ,OAAO,WAAW,GAAG,IAAI;AACtD;AAgBO,SAAS,iBAAiB,OAAwC;AAIvE,MAAI,MAAM,gBAAgB,GAAG;AAC3B,WAAO,EAAE,QAAQ,GAAG,cAAc,SAAS;AAAA,EAC7C;AACA,QAAM,MACH,MAAM,YAAY,MAAM,WAAW,KAAK,IAAI,GAAG,MAAM,aAAa,IACnE,MAAM;AACR,QAAM,UAAU,eAAe,GAAG;AAClC,SAAO,EAAE,QAAQ,SAAS,cAAc,QAAQ,QAAQ,CAAC,EAAE;AAC7D;AAOO,SAAS,2BAA2B,QAIhC;AACT,SAAO,cAAc,OAAO,WAAW,SAAS,OAAO,aAAa,OAAO,OAAO,QAAQ;AAC5F;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/cpq_billing_connector/setup.ts"],
|
|
4
|
+
"sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\n/**\n * Connector has no tenant-init work to do. It ships zero entities,\n * zero dictionaries, zero default roles. The subscribers auto-register\n * via the generator's `subscribers/` discovery; nothing else is\n * required for the module to function.\n */\nexport const setup: ModuleSetupConfig = {}\n\nexport default setup\n"],
|
|
5
|
+
"mappings": "AAQO,MAAM,QAA2B,CAAC;AAEzC,IAAO,gBAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
2
|
+
import { BillingApiClient } from "../lib/billingApiClient.js";
|
|
3
|
+
import { mapSubscriptionItemsToBillingItems } from "../lib/chargeMapper.js";
|
|
4
|
+
const metadata = {
|
|
5
|
+
event: "cpq.subscription.activated",
|
|
6
|
+
persistent: true,
|
|
7
|
+
id: "cpq-billing-connector:subscription-activated"
|
|
8
|
+
};
|
|
9
|
+
function isoDateAddMonths(months) {
|
|
10
|
+
const d = /* @__PURE__ */ new Date();
|
|
11
|
+
const targetYear = d.getUTCFullYear();
|
|
12
|
+
const targetMonth = d.getUTCMonth() + months;
|
|
13
|
+
const lastDayOfTargetMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate();
|
|
14
|
+
d.setUTCDate(Math.min(d.getUTCDate(), lastDayOfTargetMonth));
|
|
15
|
+
d.setUTCMonth(targetMonth);
|
|
16
|
+
return d.toISOString().slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
function todayIsoDate() {
|
|
19
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
function buildAccountSeed(payload) {
|
|
22
|
+
return {
|
|
23
|
+
customerId: payload.customerId,
|
|
24
|
+
name: payload.customerName ?? `Customer ${payload.customerId}`,
|
|
25
|
+
currencyCode: payload.currencyCode,
|
|
26
|
+
billCycle: payload.billCycle ?? "monthly",
|
|
27
|
+
billCycleAnchor: payload.billCycleAnchor ?? "1",
|
|
28
|
+
invoiceEmail: payload.invoiceEmail ?? `billing+${payload.customerId}@invalid.local`,
|
|
29
|
+
invoiceLanguage: payload.invoiceLanguage ?? "en",
|
|
30
|
+
invoiceAddress: payload.invoiceAddress ?? {
|
|
31
|
+
line1: "TBD",
|
|
32
|
+
city: "TBD",
|
|
33
|
+
postal_code: "TBD",
|
|
34
|
+
country: "XX"
|
|
35
|
+
},
|
|
36
|
+
nextBillDate: payload.nextBillDate ? new Date(payload.nextBillDate) : new Date(isoDateAddMonths(1)),
|
|
37
|
+
taxId: payload.taxId ?? null
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function handle(payload) {
|
|
41
|
+
if (!payload?.tenantId || !payload.organizationId || !payload.customerId) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!payload.subscriptionId || !Array.isArray(payload.items) || payload.items.length === 0) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const container = await createRequestContainer();
|
|
48
|
+
const billingApi = new BillingApiClient(container);
|
|
49
|
+
const scope = {
|
|
50
|
+
tenantId: payload.tenantId,
|
|
51
|
+
organizationId: payload.organizationId
|
|
52
|
+
};
|
|
53
|
+
const account = await billingApi.getOrCreateAccount(scope, buildAccountSeed(payload));
|
|
54
|
+
const billStartDate = payload.activationDate ?? todayIsoDate();
|
|
55
|
+
const itemPayloads = mapSubscriptionItemsToBillingItems({
|
|
56
|
+
subscriptionId: payload.subscriptionId,
|
|
57
|
+
items: payload.items,
|
|
58
|
+
billStartDate
|
|
59
|
+
});
|
|
60
|
+
await billingApi.bulkCreateItems(
|
|
61
|
+
scope,
|
|
62
|
+
itemPayloads.map((item) => ({
|
|
63
|
+
billAccountId: account.id,
|
|
64
|
+
type: item.type,
|
|
65
|
+
billStartDate: item.billStartDate,
|
|
66
|
+
description: item.description,
|
|
67
|
+
rateJson: item.rateJson,
|
|
68
|
+
subscriptionId: item.subscriptionId,
|
|
69
|
+
subscriptionItemId: item.subscriptionItemId,
|
|
70
|
+
sourceRef: item.sourceRef
|
|
71
|
+
}))
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
export {
|
|
75
|
+
handle as default,
|
|
76
|
+
metadata
|
|
77
|
+
};
|
|
78
|
+
//# sourceMappingURL=cpq-subscription-activated.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.ts"],
|
|
4
|
+
"sourcesContent": ["import { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { BillingApiClient, type Scope, type AccountSeed } from '../lib/billingApiClient'\nimport { mapSubscriptionItemsToBillingItems } from '../lib/chargeMapper'\nimport type { CpqSubscriptionItem } from '../lib/chargeMapper'\n\n/**\n * Subscriber: `cpq.subscription.activated` \u2192 billing onboarding.\n *\n * `@dainamite/cpq` emits this event when a new-sale order is activated\n * (`cpqOrderService.activateOrder`).\n *\n * Behaviour when fired:\n * 1. Get-or-create the Billing Account for `(tenant, org, customerId)`.\n * Missing account \u2192 shell account with placeholders (operator\n * must configure before billing fires). `nextBillDate` defaults\n * to today + one month so the Bill Run won't bill an under-\n * configured account until the operator advances it.\n * 2. For every charge on every CPQ subscription item, create a\n * Billing Item via `billing.items.create`. `source_ref` is the\n * deterministic `cpq-<subId>-<subItemId>-<chargeType>` key, so\n * re-firing the activation event hits the idempotency guard\n * instead of duplicating items.\n *\n * Errors propagate to the persistent queue worker, which retries\n * per the OM queue contract.\n */\n\nexport const metadata = {\n event: 'cpq.subscription.activated',\n persistent: true,\n id: 'cpq-billing-connector:subscription-activated',\n}\n\ntype ActivatedPayload = {\n tenantId: string\n organizationId: string\n subscriptionId: string\n customerId: string\n currencyCode: string\n /** Optional richer customer fields \u2014 used when present, otherwise placeholders. */\n customerName?: string\n invoiceEmail?: string\n invoiceLanguage?: string\n invoiceAddress?: Record<string, unknown>\n billCycle?: 'monthly' | 'quarterly' | 'annually' | 'weekly'\n billCycleAnchor?: string\n nextBillDate?: string\n taxId?: string | null\n items: CpqSubscriptionItem[]\n /** Activation date \u2014 used as bill_start_date on every created item. */\n activationDate?: string\n}\n\nfunction isoDateAddMonths(months: number): string {\n // Clamp the day to the last valid day of the target month so Jan 31 + 1\n // month \u2192 Feb 28/29 (not Mar 3, which is what `setUTCMonth` alone would\n // produce by overflowing into the next month).\n const d = new Date()\n const targetYear = d.getUTCFullYear()\n const targetMonth = d.getUTCMonth() + months\n const lastDayOfTargetMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate()\n d.setUTCDate(Math.min(d.getUTCDate(), lastDayOfTargetMonth))\n d.setUTCMonth(targetMonth)\n return d.toISOString().slice(0, 10)\n}\n\nfunction todayIsoDate(): string {\n return new Date().toISOString().slice(0, 10)\n}\n\nfunction buildAccountSeed(payload: ActivatedPayload): AccountSeed {\n return {\n customerId: payload.customerId,\n name: payload.customerName ?? `Customer ${payload.customerId}`,\n currencyCode: payload.currencyCode,\n billCycle: payload.billCycle ?? 'monthly',\n billCycleAnchor: payload.billCycleAnchor ?? '1',\n invoiceEmail:\n payload.invoiceEmail ?? `billing+${payload.customerId}@invalid.local`,\n invoiceLanguage: payload.invoiceLanguage ?? 'en',\n invoiceAddress: payload.invoiceAddress ?? {\n line1: 'TBD',\n city: 'TBD',\n postal_code: 'TBD',\n country: 'XX',\n },\n nextBillDate: payload.nextBillDate\n ? new Date(payload.nextBillDate)\n : new Date(isoDateAddMonths(1)),\n taxId: payload.taxId ?? null,\n }\n}\n\nexport default async function handle(payload: ActivatedPayload): Promise<void> {\n if (!payload?.tenantId || !payload.organizationId || !payload.customerId) {\n return\n }\n if (!payload.subscriptionId || !Array.isArray(payload.items) || payload.items.length === 0) {\n return\n }\n\n const container = await createRequestContainer()\n const billingApi = new BillingApiClient(container)\n const scope: Scope = {\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n }\n\n const account = await billingApi.getOrCreateAccount(scope, buildAccountSeed(payload))\n\n const billStartDate = payload.activationDate ?? todayIsoDate()\n const itemPayloads = mapSubscriptionItemsToBillingItems({\n subscriptionId: payload.subscriptionId,\n items: payload.items,\n billStartDate,\n })\n\n // Bulk-create \u2014 one command for the whole subscription's charges\n // instead of one round-trip per charge. Idempotent per source_ref,\n // so a re-fired activation event is a no-op.\n await billingApi.bulkCreateItems(\n scope,\n itemPayloads.map((item) => ({\n billAccountId: account.id,\n type: item.type,\n billStartDate: item.billStartDate,\n description: item.description,\n rateJson: item.rateJson,\n subscriptionId: item.subscriptionId,\n subscriptionItemId: item.subscriptionItemId,\n sourceRef: item.sourceRef,\n })),\n )\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AACvC,SAAS,wBAAsD;AAC/D,SAAS,0CAA0C;AAyB5C,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAsBA,SAAS,iBAAiB,QAAwB;AAIhD,QAAM,IAAI,oBAAI,KAAK;AACnB,QAAM,aAAa,EAAE,eAAe;AACpC,QAAM,cAAc,EAAE,YAAY,IAAI;AACtC,QAAM,uBAAuB,IAAI,KAAK,KAAK,IAAI,YAAY,cAAc,GAAG,CAAC,CAAC,EAAE,WAAW;AAC3F,IAAE,WAAW,KAAK,IAAI,EAAE,WAAW,GAAG,oBAAoB,CAAC;AAC3D,IAAE,YAAY,WAAW;AACzB,SAAO,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AACpC;AAEA,SAAS,eAAuB;AAC9B,UAAO,oBAAI,KAAK,GAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AAC7C;AAEA,SAAS,iBAAiB,SAAwC;AAChE,SAAO;AAAA,IACL,YAAY,QAAQ;AAAA,IACpB,MAAM,QAAQ,gBAAgB,YAAY,QAAQ,UAAU;AAAA,IAC5D,cAAc,QAAQ;AAAA,IACtB,WAAW,QAAQ,aAAa;AAAA,IAChC,iBAAiB,QAAQ,mBAAmB;AAAA,IAC5C,cACE,QAAQ,gBAAgB,WAAW,QAAQ,UAAU;AAAA,IACvD,iBAAiB,QAAQ,mBAAmB;AAAA,IAC5C,gBAAgB,QAAQ,kBAAkB;AAAA,MACxC,OAAO;AAAA,MACP,MAAM;AAAA,MACN,aAAa;AAAA,MACb,SAAS;AAAA,IACX;AAAA,IACA,cAAc,QAAQ,eAClB,IAAI,KAAK,QAAQ,YAAY,IAC7B,IAAI,KAAK,iBAAiB,CAAC,CAAC;AAAA,IAChC,OAAO,QAAQ,SAAS;AAAA,EAC1B;AACF;AAEA,eAAO,OAA8B,SAA0C;AAC7E,MAAI,CAAC,SAAS,YAAY,CAAC,QAAQ,kBAAkB,CAAC,QAAQ,YAAY;AACxE;AAAA,EACF;AACA,MAAI,CAAC,QAAQ,kBAAkB,CAAC,MAAM,QAAQ,QAAQ,KAAK,KAAK,QAAQ,MAAM,WAAW,GAAG;AAC1F;AAAA,EACF;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,aAAa,IAAI,iBAAiB,SAAS;AACjD,QAAM,QAAe;AAAA,IACnB,UAAU,QAAQ;AAAA,IAClB,gBAAgB,QAAQ;AAAA,EAC1B;AAEA,QAAM,UAAU,MAAM,WAAW,mBAAmB,OAAO,iBAAiB,OAAO,CAAC;AAEpF,QAAM,gBAAgB,QAAQ,kBAAkB,aAAa;AAC7D,QAAM,eAAe,mCAAmC;AAAA,IACtD,gBAAgB,QAAQ;AAAA,IACxB,OAAO,QAAQ;AAAA,IACf;AAAA,EACF,CAAC;AAKD,QAAM,WAAW;AAAA,IACf;AAAA,IACA,aAAa,IAAI,CAAC,UAAU;AAAA,MAC1B,eAAe,QAAQ;AAAA,MACvB,MAAM,KAAK;AAAA,MACX,eAAe,KAAK;AAAA,MACpB,aAAa,KAAK;AAAA,MAClB,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,MACrB,oBAAoB,KAAK;AAAA,MACzB,WAAW,KAAK;AAAA,IAClB,EAAE;AAAA,EACJ;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|