@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
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
+ const features = [];
2
+ var acl_default = features;
3
+ export {
4
+ acl_default as default,
5
+ features
6
+ };
7
+ //# sourceMappingURL=acl.js.map
@@ -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
+ const entities = [];
2
+ var ce_default = entities;
3
+ export {
4
+ ce_default as default,
5
+ entities
6
+ };
7
+ //# sourceMappingURL=ce.js.map
@@ -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,6 @@
1
+ function register(_container) {
2
+ }
3
+ export {
4
+ register
5
+ };
6
+ //# sourceMappingURL=di.js.map
@@ -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
+ const setup = {};
2
+ var setup_default = setup;
3
+ export {
4
+ setup_default as default,
5
+ setup
6
+ };
7
+ //# sourceMappingURL=setup.js.map
@@ -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
+ }