@dainamite/cpq-billing-connector 3.0.0 → 4.0.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 +69 -0
- package/dist/modules/cpq_billing_connector/lib/billingApiClient.js.map +2 -2
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.js +15 -11
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.js.map +2 -2
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.js +26 -68
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.js.map +2 -2
- package/dist/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.js.map +2 -2
- package/package.json +3 -3
- package/src/modules/cpq_billing_connector/__tests__/subscribers/accountSeed.test.ts +47 -0
- package/src/modules/cpq_billing_connector/__tests__/subscribers/amended.test.ts +44 -0
- package/src/modules/cpq_billing_connector/lib/billingApiClient.ts +8 -0
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-activated.ts +29 -21
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.ts +43 -102
- package/src/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.ts +10 -7
- package/dist/modules/cpq_billing_connector/lib/prorationHelper.js +0 -19
- package/dist/modules/cpq_billing_connector/lib/prorationHelper.js.map +0 -7
- package/src/modules/cpq_billing_connector/__tests__/lib/prorationHelper.test.ts +0 -93
- package/src/modules/cpq_billing_connector/lib/prorationHelper.ts +0 -74
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# @dainamite/cpq-billing-connector
|
|
2
2
|
|
|
3
|
+
## 4.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [34d3ac4]
|
|
8
|
+
- Updated dependencies [d86651d]
|
|
9
|
+
- @dainamite/billing@0.22.0
|
|
10
|
+
- @dainamite/cpq@0.7.0
|
|
11
|
+
|
|
12
|
+
## 3.1.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- 486db4d: Bill runs catch up on every due cycle, bill recurring items in advance or in arrears, and prorate partial periods (billing Spec A).
|
|
17
|
+
|
|
18
|
+
**`@dainamite/billing`**
|
|
19
|
+
|
|
20
|
+
- **Every run catches up.** Manual or scheduled, in real, test or dry mode, a run bills every due bill date up to `asOfDate`, with one draft per bill date (capped at 60 per account). Test and dry runs simulate every cycle in memory without changing the account. **Breaking:** the `catchUp` trigger flag and the `catch_up` run column/response field are removed. A `catchUp` sent by an old client is ignored.
|
|
21
|
+
- **Charge timing per item.** Recurring items carry `chargeTiming` (`in_advance` by default, or `in_arrears`). **Behaviour change:** recurring items are now billed in advance by default, so `next_bill_date` marks the start of the period billed.
|
|
22
|
+
- **Proration per item.** `prorate` defaults to `true`. A partial period (the item starts or ends mid-cycle) is charged by calendar days instead of being skipped. An in-advance item that ends after it was billed gets a negative credit line for the unused days. With `prorate: false`, partial periods are skipped and reported.
|
|
23
|
+
- **Run outcomes** carry `cycles` (bill date, service period, status, lines, net, invoice). `warnings.skipped_items` lists items that were not billed and why. The run summary adds `cycles_processed`. The run detail page shows a Cycles table and a Skipped items table.
|
|
24
|
+
- **Draft invoices** carry `metadata.bill_date`, the de-duplication key, together with `test_run`. Test drafts and real drafts never block each other. Lines carry `service_period_start` / `service_period_end`, plus `proration` and `credit_for` where they apply.
|
|
25
|
+
- **Item form** gets Charge timing and Prorate partial periods fields. The items list gets a Timing column.
|
|
26
|
+
- **Migration** `Migration20261114100000_billing_cycle_semantics`. Run `yarn mercato db migrate`. See `MIGRATION.md`; reseeding is recommended.
|
|
27
|
+
|
|
28
|
+
**`@dainamite/cpq-billing-connector`**
|
|
29
|
+
|
|
30
|
+
- **Proration moves to billing.** `cpq.subscription.amended` no longer posts `one_time` proration items, because the billing engine prorates. Release together with billing: an older connector would double-charge.
|
|
31
|
+
- **First bill date.** New billing accounts start billing on the activation date (they used to start one month later), so the first period is billed in advance.
|
|
32
|
+
- Removes `lib/prorationHelper` and the runtime import of billing's `cycleContaining`.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- d126be2: Billing accounts for persons (B2C), an invoice-language picklist, and pages that return to their billing account.
|
|
37
|
+
|
|
38
|
+
**`@dainamite/billing`**
|
|
39
|
+
|
|
40
|
+
- **B2C accounts.** `BillingAccount.customer_kind` (`company` | `person`, default `company`) records which customers collection `customer_id` points into. It is set on create (`customerKind`) and immutable afterwards, like `customerId`. `GET /api/billing/accounts` returns `customer_kind` and accepts a `customerKind` filter. Migration `Migration20261114000000_billing_customer_kind` adds the column (additive; existing rows become `company`). Run `yarn mercato db migrate`.
|
|
41
|
+
- **Account form.** A Company / Person switch picks the customer lookup (`/api/customers/companies` or `/api/customers/people`). Picking a customer prefills name, invoice email and the primary address into empty fields only. Prefill that the operator has not edited is rolled back when they pick another customer.
|
|
42
|
+
- **Account list and detail** show the customer's name with a company / person icon (one batched lookup per kind per page) and add a Customer type filter. The detail links to the customer record. If the customers module is unavailable, the raw id is shown.
|
|
43
|
+
- **Invoice language** is now a picklist of the languages the invoice template supports (`en`, `pl`).
|
|
44
|
+
**Behaviour change:** `invoiceLanguage` values other than `en` / `pl` are now rejected with 400 on create and update. On create, `invoiceLanguage` is optional and defaults to `en`.
|
|
45
|
+
- **Navigation.** Item pages opened from a billing account return to that account (back link, after delete). Creating an item from an account returns to the account after save or cancel. Items opened from the global Items list (`?from=items`) still return to the list.
|
|
46
|
+
|
|
47
|
+
**`@dainamite/cpq-billing-connector`**
|
|
48
|
+
|
|
49
|
+
- Passes an optional `customerKind` from `cpq.subscription.activated` to the billing account it creates.
|
|
50
|
+
- Normalises `invoiceLanguage` and falls back to `en` for languages billing does not support, so onboarding never fails on an unsupported code.
|
|
51
|
+
|
|
52
|
+
- c8a1bf7: Billing invoices become real VAT documents with a PDF and email delivery (billing Spec B).
|
|
53
|
+
|
|
54
|
+
- **VAT per line.** Billing items carry `taxRate` (filled from the new organization default, 23 %, when omitted). Every invoice line gets net / VAT / gross; proration and credit lines inherit the item rate; manual lines accept `taxRate`. Existing items are migrated at 0 % (no silent re-pricing).
|
|
55
|
+
- **Seller settings.** Settings → Billing adds the seller block, bank / IBAN (checksum validated) / BIC, default payment terms and VAT rate, invoice footer and logo. **Posting is refused with 409 `billing.settings.incomplete` until legal name, address, tax id and IBAN are filled.**
|
|
56
|
+
- **Due dates and overdue.** Posting sets `due_date` (account terms or the default). The invoice list filters by `overdue`, `dueFrom`, `dueTo` and shows days overdue; the account page gets an Invoices section and `GET /api/billing/accounts/{id}/summary`.
|
|
57
|
+
- **PDF.** `GET /api/billing/invoices/{id}/pdf` — built-in A4 template in the account's invoice language (en / pl), frozen as an attachment at post; drafts are watermarked previews.
|
|
58
|
+
- **Email.** `POST /api/billing/invoices/{id}/send` (feature `billing.invoice.send`) and automatic sending per account (`autoSendInvoice`), through the platform `sendEmail()`. Deliveries are logged on the invoice; events `billing.invoice.sent` / `billing.invoice.send_failed`.
|
|
59
|
+
- **Dependencies:** `requires` adds `attachments`; new dependencies `pdf-lib` and `@pdf-lib/fontkit`.
|
|
60
|
+
- Connector: `ItemCreatePayload` accepts an optional `taxRate` (unused until CPQ exposes a per-charge rate).
|
|
61
|
+
- Run `yarn mercato db migrate` (`Migration20261116000000_billing_invoice_documents`).
|
|
62
|
+
|
|
63
|
+
- Updated dependencies [d126be2]
|
|
64
|
+
- Updated dependencies [486db4d]
|
|
65
|
+
- Updated dependencies [c8a1bf7]
|
|
66
|
+
- Updated dependencies [6c138f8]
|
|
67
|
+
- Updated dependencies [418d67c]
|
|
68
|
+
- Updated dependencies [3dc3fa5]
|
|
69
|
+
- Updated dependencies [418d67c]
|
|
70
|
+
- @dainamite/billing@0.21.0
|
|
71
|
+
|
|
3
72
|
## 3.0.0
|
|
4
73
|
|
|
5
74
|
### Patch Changes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
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": "AAWA,SAAS,gBAAgB,mBAAmB;
|
|
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 /** 'company' (default when omitted) | 'person' \u2014 B2C accounts (billing Spec E). */\n customerKind?: 'company' | 'person'\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 * Billing Spec B: VAT % of the charge. CPQ charges carry no tax rate yet,\n * so the connector omits it and billing applies the organization default\n * at creation. Set it once CPQ exposes a per-charge rate.\n */\n taxRate?: number\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": "AAWA,SAAS,gBAAgB,mBAAmB;AAmE5C,SAAS,eAAe,WAA4B,OAAc;AAChE,SAAO;AAAA,IACL;AAAA,IACA,MAAM;AAAA,IACN,mBAAmB;AAAA,IACnB,wBAAwB,MAAM;AAAA,IAC9B,iBAAiB,CAAC,MAAM,cAAc;AAAA,EACxC;AACF;AAEO,MAAM,iBAAiB;AAAA,EAI5B,YAA6B,WAA4B;AAA5B;AAC3B,SAAK,KAAM,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC1D,SAAK,aAAa,UAAU,QAAQ,YAAY;AAAA,EAClD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,sBAAsB,OAAc,YAAoD;AAC5F,WAAO,KAAK,GAAG,QAAQ,gBAAgB;AAAA,MACrC,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB;AAAA,MACA,WAAW;AAAA,IACb,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,cAAc,OAAc,MAA4C;AAC5E,UAAM,SAAS,MAAM,KAAK,WAAW,QAAQ,2BAA2B;AAAA,MACtE,OAAO;AAAA,QACL,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB,GAAG;AAAA,MACL;AAAA,MACA,KAAK,eAAe,KAAK,WAAW,KAAK;AAAA,IAC3C,CAAC;AACD,WAAQ,OAAuC,UAAU,EAAE,IAAI,GAAG;AAAA,EACpE;AAAA,EAEA,MAAM,mBAAmB,OAAc,MAA8D;AACnG,UAAM,WAAW,MAAM,KAAK,sBAAsB,OAAO,KAAK,UAAU;AACxE,QAAI,SAAU,QAAO,EAAE,IAAI,SAAS,IAAI,SAAS,MAAM;AACvD,UAAM,UAAU,MAAM,KAAK,cAAc,OAAO,IAAI;AACpD,WAAO,EAAE,IAAI,QAAQ,IAAI,SAAS,KAAK;AAAA,EACzC;AAAA;AAAA,EAIA,MAAM,wBAAwB,OAAc,gBAAgD;AAC1F,WAAO,KAAK,GAAG,KAAK,aAAa;AAAA,MAC/B,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB;AAAA,MACA,WAAW;AAAA,IACb,CAAU;AAAA,EACZ;AAAA,EAEA,MAAM,4BACJ,OACA,oBACwB;AACxB,WAAO,KAAK,GAAG,KAAK,aAAa;AAAA,MAC/B,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB;AAAA,MACA,WAAW;AAAA,IACb,CAAU;AAAA,EACZ;AAAA,EAEA,MAAM,WACJ,OACA,SACgD;AAChD,UAAM,SAAS,MAAM,KAAK,WAAW,QAAQ,wBAAwB;AAAA,MACnE,OAAO;AAAA,QACL,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB,GAAG;AAAA,MACL;AAAA,MACA,KAAK,eAAe,KAAK,WAAW,KAAK;AAAA,IAC3C,CAAC;AACD,UAAM,IAAK,OAA8D;AACzE,WAAO,KAAK,EAAE,IAAI,IAAI,cAAc,MAAM;AAAA,EAC5C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,gBACJ,OACA,OAKC;AACD,QAAI,MAAM,WAAW,GAAG;AACtB,aAAO,EAAE,SAAS,GAAG,cAAc,GAAG,OAAO,CAAC,EAAE;AAAA,IAClD;AACA,UAAM,SAAS,MAAM,KAAK,WAAW,QAAQ,6BAA6B;AAAA,MACxE,OAAO;AAAA,QACL,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB;AAAA,MACF;AAAA,MACA,KAAK,eAAe,KAAK,WAAW,KAAK;AAAA,IAC3C,CAAC;AACD,UAAM,IACJ,OAOA;AACF,WAAO,KAAK,EAAE,SAAS,GAAG,cAAc,GAAG,OAAO,CAAC,EAAE;AAAA,EACvD;AAAA,EAEA,MAAM,WAAW,OAAc,SAAqD;AAClF,UAAM,SAAS,MAAM,KAAK,WAAW,QAAQ,wBAAwB;AAAA,MACnE,OAAO;AAAA,QACL,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB,GAAG;AAAA,MACL;AAAA,MACA,KAAK,eAAe,KAAK,WAAW,KAAK;AAAA,IAC3C,CAAC;AACD,WAAQ,OAAuC,UAAU,EAAE,IAAI,GAAG;AAAA,EACpE;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,39 +1,42 @@
|
|
|
1
1
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
2
2
|
import { BillingApiClient } from "../lib/billingApiClient.js";
|
|
3
3
|
import { mapSubscriptionItemsToBillingItems } from "../lib/chargeMapper.js";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_INVOICE_LANGUAGE,
|
|
6
|
+
isSupportedInvoiceLanguage
|
|
7
|
+
} from "@dainamite/billing/modules/billing/lib/invoiceLanguages";
|
|
4
8
|
const metadata = {
|
|
5
9
|
event: "cpq.subscription.activated",
|
|
6
10
|
persistent: true,
|
|
7
11
|
id: "cpq-billing-connector:subscription-activated"
|
|
8
12
|
};
|
|
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
13
|
function todayIsoDate() {
|
|
19
14
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
20
15
|
}
|
|
16
|
+
function resolveInvoiceLanguage(raw) {
|
|
17
|
+
const code = (raw ?? "").trim().toLowerCase();
|
|
18
|
+
return isSupportedInvoiceLanguage(code) ? code : DEFAULT_INVOICE_LANGUAGE;
|
|
19
|
+
}
|
|
21
20
|
function buildAccountSeed(payload) {
|
|
22
21
|
return {
|
|
23
22
|
customerId: payload.customerId,
|
|
23
|
+
...payload.customerKind ? { customerKind: payload.customerKind } : {},
|
|
24
24
|
name: payload.customerName ?? `Customer ${payload.customerId}`,
|
|
25
25
|
currencyCode: payload.currencyCode,
|
|
26
26
|
billCycle: payload.billCycle ?? "monthly",
|
|
27
27
|
billCycleAnchor: payload.billCycleAnchor ?? "1",
|
|
28
28
|
invoiceEmail: payload.invoiceEmail ?? `billing+${payload.customerId}@invalid.local`,
|
|
29
|
-
invoiceLanguage: payload.invoiceLanguage
|
|
29
|
+
invoiceLanguage: resolveInvoiceLanguage(payload.invoiceLanguage),
|
|
30
30
|
invoiceAddress: payload.invoiceAddress ?? {
|
|
31
31
|
line1: "TBD",
|
|
32
32
|
city: "TBD",
|
|
33
33
|
postal_code: "TBD",
|
|
34
34
|
country: "XX"
|
|
35
35
|
},
|
|
36
|
-
|
|
36
|
+
// Billing Spec A: recurring items are billed in advance, so the first
|
|
37
|
+
// bill date is the subscription start — billing the first period on
|
|
38
|
+
// activation. (Was "today + 1 month", which skipped that period.)
|
|
39
|
+
nextBillDate: new Date(payload.nextBillDate ?? payload.activationDate ?? todayIsoDate()),
|
|
37
40
|
taxId: payload.taxId ?? null
|
|
38
41
|
};
|
|
39
42
|
}
|
|
@@ -72,6 +75,7 @@ async function handle(payload) {
|
|
|
72
75
|
);
|
|
73
76
|
}
|
|
74
77
|
export {
|
|
78
|
+
buildAccountSeed,
|
|
75
79
|
handle as default,
|
|
76
80
|
metadata
|
|
77
81
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
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
|
|
5
|
-
"mappings": "AAAA,SAAS,8BAA8B;AACvC,SAAS,wBAAsD;AAC/D,SAAS,0CAA0C;
|
|
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'\nimport {\n DEFAULT_INVOICE_LANGUAGE,\n isSupportedInvoiceLanguage,\n} from '@dainamite/billing/modules/billing/lib/invoiceLanguages'\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 the activation date: recurring items are billed in advance\n * (billing Spec A), so the first period is billed from day one.\n * An EXISTING account keeps its bill date \u2014 billing prorates the\n * new subscription's partial first period.\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\nexport type ActivatedPayload = {\n tenantId: string\n organizationId: string\n subscriptionId: string\n customerId: string\n /** 'company' | 'person'. Omitted \u2192 billing defaults to 'company'. */\n customerKind?: 'company' | 'person'\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 todayIsoDate(): string {\n return new Date().toISOString().slice(0, 10)\n}\n\n/**\n * Billing only accepts invoice languages its document template supports.\n * An unsupported code from upstream must not fail onboarding (the persistent\n * queue would retry forever), so it falls back to billing's default \u2014 the\n * operator can change it on the account afterwards.\n */\nfunction resolveInvoiceLanguage(raw: string | undefined): string {\n const code = (raw ?? '').trim().toLowerCase()\n return isSupportedInvoiceLanguage(code) ? code : DEFAULT_INVOICE_LANGUAGE\n}\n\nexport function buildAccountSeed(payload: ActivatedPayload): AccountSeed {\n return {\n customerId: payload.customerId,\n ...(payload.customerKind ? { customerKind: payload.customerKind } : {}),\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: resolveInvoiceLanguage(payload.invoiceLanguage),\n invoiceAddress: payload.invoiceAddress ?? {\n line1: 'TBD',\n city: 'TBD',\n postal_code: 'TBD',\n country: 'XX',\n },\n // Billing Spec A: recurring items are billed in advance, so the first\n // bill date is the subscription start \u2014 billing the first period on\n // activation. (Was \"today + 1 month\", which skipped that period.)\n nextBillDate: new Date(payload.nextBillDate ?? payload.activationDate ?? todayIsoDate()),\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;AAEnD;AAAA,EACE;AAAA,EACA;AAAA,OACK;AA0BA,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAwBA,SAAS,eAAuB;AAC9B,UAAO,oBAAI,KAAK,GAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AAC7C;AAQA,SAAS,uBAAuB,KAAiC;AAC/D,QAAM,QAAQ,OAAO,IAAI,KAAK,EAAE,YAAY;AAC5C,SAAO,2BAA2B,IAAI,IAAI,OAAO;AACnD;AAEO,SAAS,iBAAiB,SAAwC;AACvE,SAAO;AAAA,IACL,YAAY,QAAQ;AAAA,IACpB,GAAI,QAAQ,eAAe,EAAE,cAAc,QAAQ,aAAa,IAAI,CAAC;AAAA,IACrE,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,uBAAuB,QAAQ,eAAe;AAAA,IAC/D,gBAAgB,QAAQ,kBAAkB;AAAA,MACxC,OAAO;AAAA,MACP,MAAM;AAAA,MACN,aAAa;AAAA,MACb,SAAS;AAAA,IACX;AAAA;AAAA;AAAA;AAAA,IAIA,cAAc,IAAI,KAAK,QAAQ,gBAAgB,QAAQ,kBAAkB,aAAa,CAAC;AAAA,IACvF,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
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,32 +1,37 @@
|
|
|
1
1
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
2
|
-
import { cycleContaining } from "@dainamite/billing/modules/billing/lib/billPeriod";
|
|
3
2
|
import { BillingApiClient } from "../lib/billingApiClient.js";
|
|
4
3
|
import {
|
|
5
4
|
mapSubscriptionItemsToBillingItems
|
|
6
5
|
} from "../lib/chargeMapper.js";
|
|
7
|
-
import {
|
|
8
|
-
computeProration,
|
|
9
|
-
formatProrationDescription
|
|
10
|
-
} from "../lib/prorationHelper.js";
|
|
11
6
|
const metadata = {
|
|
12
7
|
event: "cpq.subscription.amended",
|
|
13
8
|
persistent: true,
|
|
14
9
|
id: "cpq-billing-connector:subscription-amended"
|
|
15
10
|
};
|
|
16
|
-
const DAY_MS = 864e5;
|
|
17
|
-
function toMidnightMs(value) {
|
|
18
|
-
const d = value instanceof Date ? new Date(value.getTime()) : /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
19
|
-
d.setUTCHours(0, 0, 0, 0);
|
|
20
|
-
return d.getTime();
|
|
21
|
-
}
|
|
22
|
-
function inclusiveDays(from, to) {
|
|
23
|
-
return Math.round((toMidnightMs(to) - toMidnightMs(from)) / DAY_MS) + 1;
|
|
24
|
-
}
|
|
25
11
|
function isoDateMinusOneDay(iso) {
|
|
26
12
|
const d = /* @__PURE__ */ new Date(`${iso}T00:00:00.000Z`);
|
|
27
13
|
d.setUTCDate(d.getUTCDate() - 1);
|
|
28
14
|
return d.toISOString().slice(0, 10);
|
|
29
15
|
}
|
|
16
|
+
function buildAddedItemPayloads(billAccountId, payload) {
|
|
17
|
+
return mapSubscriptionItemsToBillingItems({
|
|
18
|
+
subscriptionId: payload.subscriptionId,
|
|
19
|
+
items: payload.addedItems ?? [],
|
|
20
|
+
billStartDate: payload.effectiveDate
|
|
21
|
+
}).map((item) => ({
|
|
22
|
+
billAccountId,
|
|
23
|
+
type: item.type,
|
|
24
|
+
billStartDate: item.billStartDate,
|
|
25
|
+
description: item.description,
|
|
26
|
+
rateJson: item.rateJson,
|
|
27
|
+
subscriptionId: item.subscriptionId,
|
|
28
|
+
subscriptionItemId: item.subscriptionItemId,
|
|
29
|
+
sourceRef: item.sourceRef
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
function removedItemEndDate(effectiveDate) {
|
|
33
|
+
return isoDateMinusOneDay(effectiveDate);
|
|
34
|
+
}
|
|
30
35
|
async function handle(payload) {
|
|
31
36
|
if (!payload?.tenantId || !payload.organizationId) return;
|
|
32
37
|
if (!payload.subscriptionId || !payload.effectiveDate) return;
|
|
@@ -41,60 +46,11 @@ async function handle(payload) {
|
|
|
41
46
|
};
|
|
42
47
|
const account = await billingApi.findAccountByCustomer(scope, payload.customerId);
|
|
43
48
|
if (!account) return;
|
|
44
|
-
const itemsToCreate =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
billStartDate: payload.effectiveDate
|
|
48
|
-
}).map((item) => ({
|
|
49
|
-
billAccountId: account.id,
|
|
50
|
-
type: item.type,
|
|
51
|
-
billStartDate: item.billStartDate,
|
|
52
|
-
description: item.description,
|
|
53
|
-
rateJson: item.rateJson,
|
|
54
|
-
subscriptionId: item.subscriptionId,
|
|
55
|
-
subscriptionItemId: item.subscriptionItemId,
|
|
56
|
-
sourceRef: item.sourceRef
|
|
57
|
-
}));
|
|
58
|
-
if (added.length > 0) {
|
|
59
|
-
const period = cycleContaining(
|
|
60
|
-
account.nextBillDate,
|
|
61
|
-
account.billCycle,
|
|
62
|
-
payload.effectiveDate
|
|
63
|
-
);
|
|
64
|
-
const cycleEnd = period.periodEnd.toISOString().slice(0, 10);
|
|
65
|
-
const daysInPeriod = inclusiveDays(period.periodStart, period.periodEnd);
|
|
66
|
-
const daysRemaining = inclusiveDays(payload.effectiveDate, period.periodEnd);
|
|
67
|
-
for (const addedItem of added) {
|
|
68
|
-
for (const charge of addedItem.charges) {
|
|
69
|
-
if (charge.type !== "recurring" || typeof charge.unitPrice !== "number") {
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const { amount } = computeProration({
|
|
73
|
-
unitPrice: charge.unitPrice,
|
|
74
|
-
quantity: addedItem.quantity,
|
|
75
|
-
daysInPeriod,
|
|
76
|
-
daysRemaining
|
|
77
|
-
});
|
|
78
|
-
if (amount === 0) continue;
|
|
79
|
-
itemsToCreate.push({
|
|
80
|
-
billAccountId: account.id,
|
|
81
|
-
type: "one_time",
|
|
82
|
-
billStartDate: payload.effectiveDate,
|
|
83
|
-
description: formatProrationDescription({
|
|
84
|
-
productName: addedItem.productName,
|
|
85
|
-
effectiveDate: payload.effectiveDate,
|
|
86
|
-
cycleEnd
|
|
87
|
-
}),
|
|
88
|
-
rateJson: { amount },
|
|
89
|
-
subscriptionId: payload.subscriptionId,
|
|
90
|
-
subscriptionItemId: addedItem.subscriptionItemId,
|
|
91
|
-
sourceRef: `cpq-${payload.subscriptionId}-${addedItem.subscriptionItemId}-proration-${payload.effectiveDate}`
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
49
|
+
const itemsToCreate = buildAddedItemPayloads(account.id, payload);
|
|
50
|
+
if (itemsToCreate.length > 0) {
|
|
51
|
+
await billingApi.bulkCreateItems(scope, itemsToCreate);
|
|
95
52
|
}
|
|
96
|
-
|
|
97
|
-
const endDate = isoDateMinusOneDay(payload.effectiveDate);
|
|
53
|
+
const endDate = removedItemEndDate(payload.effectiveDate);
|
|
98
54
|
for (const removedSubItemId of removed) {
|
|
99
55
|
const items = await billingApi.findItemsBySubscriptionItem(scope, removedSubItemId);
|
|
100
56
|
for (const item of items) {
|
|
@@ -106,7 +62,9 @@ async function handle(payload) {
|
|
|
106
62
|
}
|
|
107
63
|
}
|
|
108
64
|
export {
|
|
65
|
+
buildAddedItemPayloads,
|
|
109
66
|
handle as default,
|
|
110
|
-
metadata
|
|
67
|
+
metadata,
|
|
68
|
+
removedItemEndDate
|
|
111
69
|
};
|
|
112
70
|
//# sourceMappingURL=cpq-subscription-amended.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/cpq_billing_connector/subscribers/cpq-subscription-amended.ts"],
|
|
4
|
-
"sourcesContent": ["import { createRequestContainer } from '@open-mercato/shared/lib/di/container'\
|
|
5
|
-
"mappings": "AAAA,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["import { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { BillingApiClient, type ItemCreatePayload, type Scope } from '../lib/billingApiClient'\nimport {\n mapSubscriptionItemsToBillingItems,\n type CpqSubscriptionItem,\n} from '../lib/chargeMapper'\n\n/**\n * Subscriber: `cpq.subscription.amended` \u2192 mid-cycle add / remove.\n *\n * CPQ's amended event carries `addedItems`, `removedSubscriptionItemIds`\n * and the `effectiveDate` of the amendment. The connector:\n * 1. Creates a Billing Item for every added charge, starting on\n * `effectiveDate`.\n * 2. End-dates every Billing Item of each removed subscription item at\n * `effectiveDate - 1 day`.\n *\n * Proration is billing's job (billing Spec A): the Bill Run engine prorates\n * the partial period of an item that starts mid-cycle, and credits the\n * unused days of an item billed in advance that ends mid-cycle. The\n * connector therefore no longer posts its own `one_time` proration lines \u2014\n * doing both would double-charge the customer. A \"no refund, end at period\n * end\" policy is expressed by CPQ choosing an `effectiveDate` on the period\n * boundary.\n */\n\nexport const metadata = {\n event: 'cpq.subscription.amended',\n persistent: true,\n id: 'cpq-billing-connector:subscription-amended',\n}\n\nexport type AmendedPayload = {\n tenantId: string\n organizationId: string\n subscriptionId: string\n customerId: string\n /** YYYY-MM-DD; the date the amend takes effect mid-cycle. */\n effectiveDate: string\n addedItems?: CpqSubscriptionItem[]\n removedSubscriptionItemIds?: string[]\n}\n\nfunction isoDateMinusOneDay(iso: string): string {\n const d = new Date(`${iso}T00:00:00.000Z`)\n d.setUTCDate(d.getUTCDate() - 1)\n return d.toISOString().slice(0, 10)\n}\n\n/** Billing Item payloads for the added charges \u2014 pure, no proration lines. */\nexport function buildAddedItemPayloads(billAccountId: string, payload: AmendedPayload): ItemCreatePayload[] {\n return mapSubscriptionItemsToBillingItems({\n subscriptionId: payload.subscriptionId,\n items: payload.addedItems ?? [],\n billStartDate: payload.effectiveDate,\n }).map((item) => ({\n billAccountId,\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/** `bill_end_date` for the items of removed subscription items. */\nexport function removedItemEndDate(effectiveDate: string): string {\n return isoDateMinusOneDay(effectiveDate)\n}\n\nexport default async function handle(payload: AmendedPayload): Promise<void> {\n if (!payload?.tenantId || !payload.organizationId) return\n if (!payload.subscriptionId || !payload.effectiveDate) return\n\n const added = payload.addedItems ?? []\n const removed = payload.removedSubscriptionItemIds ?? []\n if (added.length === 0 && removed.length === 0) return\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 // Activation runs first and creates the account. If it's missing,\n // there is nothing to amend \u2014 the queue will have retried activation.\n const account = await billingApi.findAccountByCustomer(scope, payload.customerId)\n if (!account) return\n\n // 1. New Billing Items for every added charge (the engine prorates the stub).\n const itemsToCreate = buildAddedItemPayloads(account.id, payload)\n if (itemsToCreate.length > 0) {\n await billingApi.bulkCreateItems(scope, itemsToCreate)\n }\n\n // 2. End-date every Billing Item of each removed subscription item.\n const endDate = removedItemEndDate(payload.effectiveDate)\n for (const removedSubItemId of removed) {\n const items = await billingApi.findItemsBySubscriptionItem(scope, removedSubItemId)\n for (const item of items) {\n await billingApi.updateItem(scope, {\n id: item.id,\n billEndDate: endDate,\n })\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AACvC,SAAS,wBAA4D;AACrE;AAAA,EACE;AAAA,OAEK;AAqBA,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAaA,SAAS,mBAAmB,KAAqB;AAC/C,QAAM,IAAI,oBAAI,KAAK,GAAG,GAAG,gBAAgB;AACzC,IAAE,WAAW,EAAE,WAAW,IAAI,CAAC;AAC/B,SAAO,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AACpC;AAGO,SAAS,uBAAuB,eAAuB,SAA8C;AAC1G,SAAO,mCAAmC;AAAA,IACxC,gBAAgB,QAAQ;AAAA,IACxB,OAAO,QAAQ,cAAc,CAAC;AAAA,IAC9B,eAAe,QAAQ;AAAA,EACzB,CAAC,EAAE,IAAI,CAAC,UAAU;AAAA,IAChB;AAAA,IACA,MAAM,KAAK;AAAA,IACX,eAAe,KAAK;AAAA,IACpB,aAAa,KAAK;AAAA,IAClB,UAAU,KAAK;AAAA,IACf,gBAAgB,KAAK;AAAA,IACrB,oBAAoB,KAAK;AAAA,IACzB,WAAW,KAAK;AAAA,EAClB,EAAE;AACJ;AAGO,SAAS,mBAAmB,eAA+B;AAChE,SAAO,mBAAmB,aAAa;AACzC;AAEA,eAAO,OAA8B,SAAwC;AAC3E,MAAI,CAAC,SAAS,YAAY,CAAC,QAAQ,eAAgB;AACnD,MAAI,CAAC,QAAQ,kBAAkB,CAAC,QAAQ,cAAe;AAEvD,QAAM,QAAQ,QAAQ,cAAc,CAAC;AACrC,QAAM,UAAU,QAAQ,8BAA8B,CAAC;AACvD,MAAI,MAAM,WAAW,KAAK,QAAQ,WAAW,EAAG;AAEhD,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,aAAa,IAAI,iBAAiB,SAAS;AACjD,QAAM,QAAe;AAAA,IACnB,UAAU,QAAQ;AAAA,IAClB,gBAAgB,QAAQ;AAAA,EAC1B;AAIA,QAAM,UAAU,MAAM,WAAW,sBAAsB,OAAO,QAAQ,UAAU;AAChF,MAAI,CAAC,QAAS;AAGd,QAAM,gBAAgB,uBAAuB,QAAQ,IAAI,OAAO;AAChE,MAAI,cAAc,SAAS,GAAG;AAC5B,UAAM,WAAW,gBAAgB,OAAO,aAAa;AAAA,EACvD;AAGA,QAAM,UAAU,mBAAmB,QAAQ,aAAa;AACxD,aAAW,oBAAoB,SAAS;AACtC,UAAM,QAAQ,MAAM,WAAW,4BAA4B,OAAO,gBAAgB;AAClF,eAAW,QAAQ,OAAO;AACxB,YAAM,WAAW,WAAW,OAAO;AAAA,QACjC,IAAI,KAAK;AAAA,QACT,aAAa;AAAA,MACf,CAAC;AAAA,IACH;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/cpq_billing_connector/subscribers/cpq-subscription-cancelled.ts"],
|
|
4
|
-
"sourcesContent": ["import { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { BillingApiClient, type Scope } from '../lib/billingApiClient'\n\n/**\n * Subscriber: `cpq.subscription.cancelled` \u2192 set bill_end_date on\n * every billing item attached to the subscription.\n *\n * The connector uses the spec's `effectiveDate` (cancellation date)\n * minus one day as `bill_end_date
|
|
5
|
-
"mappings": "AAAA,SAAS,8BAA8B;AACvC,SAAS,wBAAoC;
|
|
4
|
+
"sourcesContent": ["import { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { BillingApiClient, type Scope } from '../lib/billingApiClient'\n\n/**\n * Subscriber: `cpq.subscription.cancelled` \u2192 set bill_end_date on\n * every billing item attached to the subscription.\n *\n * The connector uses the spec's `effectiveDate` (cancellation date)\n * minus one day as `bill_end_date`. From there the billing engine\n * (billing Spec A) handles the money:\n * - an item billed in arrears is charged pro rata up to the end date;\n * - an item already billed in advance past the end date gets an\n * automatic credit line for the unused days (when `prorate` is on).\n *\n * The cancellation POLICY belongs to CPQ: \"cancel at the end of the\n * current period, no refund\" means CPQ sends an `effectiveDate` on the\n * period boundary \u2014 then no credit arises. The connector never invents\n * credits itself (billing does), so CPQ must NOT also post a negative\n * `one_time` item for the same days.\n */\n\nexport const metadata = {\n event: 'cpq.subscription.cancelled',\n persistent: true,\n id: 'cpq-billing-connector:subscription-cancelled',\n}\n\ntype CancelledPayload = {\n tenantId: string\n organizationId: string\n subscriptionId: string\n /** YYYY-MM-DD; the date the cancellation takes effect. */\n effectiveDate: string\n}\n\nfunction isoDateMinusOneDay(iso: string): string {\n const d = new Date(`${iso}T00:00:00.000Z`)\n d.setUTCDate(d.getUTCDate() - 1)\n return d.toISOString().slice(0, 10)\n}\n\nexport default async function handle(payload: CancelledPayload): Promise<void> {\n if (!payload?.tenantId || !payload.organizationId) return\n if (!payload.subscriptionId || !payload.effectiveDate) return\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 items = await billingApi.findItemsBySubscription(scope, payload.subscriptionId)\n if (items.length === 0) return\n\n const endDate = isoDateMinusOneDay(payload.effectiveDate)\n for (const item of items) {\n // Idempotency: skip if already end-dated to this exact date.\n if (item.billEndDate && item.billEndDate.toISOString().slice(0, 10) === endDate) {\n continue\n }\n await billingApi.updateItem(scope, {\n id: item.id,\n billEndDate: endDate,\n })\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AACvC,SAAS,wBAAoC;AAoBtC,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAUA,SAAS,mBAAmB,KAAqB;AAC/C,QAAM,IAAI,oBAAI,KAAK,GAAG,GAAG,gBAAgB;AACzC,IAAE,WAAW,EAAE,WAAW,IAAI,CAAC;AAC/B,SAAO,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AACpC;AAEA,eAAO,OAA8B,SAA0C;AAC7E,MAAI,CAAC,SAAS,YAAY,CAAC,QAAQ,eAAgB;AACnD,MAAI,CAAC,QAAQ,kBAAkB,CAAC,QAAQ,cAAe;AAEvD,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,QAAQ,MAAM,WAAW,wBAAwB,OAAO,QAAQ,cAAc;AACpF,MAAI,MAAM,WAAW,EAAG;AAExB,QAAM,UAAU,mBAAmB,QAAQ,aAAa;AACxD,aAAW,QAAQ,OAAO;AAExB,QAAI,KAAK,eAAe,KAAK,YAAY,YAAY,EAAE,MAAM,GAAG,EAAE,MAAM,SAAS;AAC/E;AAAA,IACF;AACA,UAAM,WAAW,WAAW,OAAO;AAAA,MACjC,IAAI,KAAK;AAAA,MACT,aAAa;AAAA,IACf,CAAC;AAAA,EACH;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dainamite/cpq-billing-connector",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Wires @dainamite/cpq subscription lifecycle events to @dainamite/billing API calls (activated / amended / renewed / cancelled / merged / superseded).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/modules/cpq_billing_connector/index.js",
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
95
|
"peerDependencies": {
|
|
96
|
-
"@dainamite/billing": "^0.
|
|
97
|
-
"@dainamite/cpq": "^0.
|
|
96
|
+
"@dainamite/billing": "^0.22.0",
|
|
97
|
+
"@dainamite/cpq": "^0.7.0",
|
|
98
98
|
"@mikro-orm/core": "^7.1.14",
|
|
99
99
|
"@mikro-orm/decorators": "^7.1.14",
|
|
100
100
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildAccountSeed, type ActivatedPayload } from '../../subscribers/cpq-subscription-activated'
|
|
2
|
+
|
|
3
|
+
function payload(overrides: Partial<ActivatedPayload> = {}): ActivatedPayload {
|
|
4
|
+
return {
|
|
5
|
+
tenantId: 'tenant-1',
|
|
6
|
+
organizationId: 'org-1',
|
|
7
|
+
subscriptionId: 'sub-1',
|
|
8
|
+
customerId: 'cust-1',
|
|
9
|
+
currencyCode: 'PLN',
|
|
10
|
+
items: [],
|
|
11
|
+
...overrides,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('buildAccountSeed', () => {
|
|
16
|
+
it('omits customerKind when upstream does not know it (billing defaults to company)', () => {
|
|
17
|
+
expect('customerKind' in buildAccountSeed(payload())).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('passes customerKind through for B2C (person) customers', () => {
|
|
21
|
+
expect(buildAccountSeed(payload({ customerKind: 'person' })).customerKind).toBe('person')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps a supported invoice language, normalised', () => {
|
|
25
|
+
expect(buildAccountSeed(payload({ invoiceLanguage: ' PL ' })).invoiceLanguage).toBe('pl')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it.each(['en', 'es', 'de', 'pl'])('passes every billing invoice language through (%s)', (code) => {
|
|
29
|
+
expect(buildAccountSeed(payload({ invoiceLanguage: code.toUpperCase() })).invoiceLanguage).toBe(code)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('falls back to the default language for unsupported or missing codes', () => {
|
|
33
|
+
expect(buildAccountSeed(payload({ invoiceLanguage: 'fr' })).invoiceLanguage).toBe('en')
|
|
34
|
+
expect(buildAccountSeed(payload()).invoiceLanguage).toBe('en')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('starts billing on the activation date (billed in advance), not a month later', () => {
|
|
38
|
+
expect(buildAccountSeed(payload({ activationDate: '2026-11-14' })).nextBillDate.toISOString().slice(0, 10)).toBe(
|
|
39
|
+
'2026-11-14',
|
|
40
|
+
)
|
|
41
|
+
expect(
|
|
42
|
+
buildAccountSeed(payload({ activationDate: '2026-11-14', nextBillDate: '2026-12-01' })).nextBillDate
|
|
43
|
+
.toISOString()
|
|
44
|
+
.slice(0, 10),
|
|
45
|
+
).toBe('2026-12-01')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildAddedItemPayloads,
|
|
3
|
+
removedItemEndDate,
|
|
4
|
+
type AmendedPayload,
|
|
5
|
+
} from '../../subscribers/cpq-subscription-amended'
|
|
6
|
+
|
|
7
|
+
const payload: AmendedPayload = {
|
|
8
|
+
tenantId: 't-1',
|
|
9
|
+
organizationId: 'o-1',
|
|
10
|
+
subscriptionId: 'sub-1',
|
|
11
|
+
customerId: 'cust-1',
|
|
12
|
+
effectiveDate: '2026-11-14',
|
|
13
|
+
addedItems: [
|
|
14
|
+
{
|
|
15
|
+
subscriptionItemId: 'si-1',
|
|
16
|
+
productName: 'Static IP',
|
|
17
|
+
quantity: 1,
|
|
18
|
+
charges: [{ type: 'recurring', unitPrice: 30, description: 'Static IP' }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('cpq-subscription-amended', () => {
|
|
24
|
+
it('creates the added recurring item from the effective date and no proration line (billing prorates)', () => {
|
|
25
|
+
const items = buildAddedItemPayloads('acc-1', payload)
|
|
26
|
+
expect(items).toHaveLength(1)
|
|
27
|
+
expect(items[0]).toMatchObject({
|
|
28
|
+
billAccountId: 'acc-1',
|
|
29
|
+
type: 'recurring',
|
|
30
|
+
billStartDate: '2026-11-14',
|
|
31
|
+
subscriptionId: 'sub-1',
|
|
32
|
+
subscriptionItemId: 'si-1',
|
|
33
|
+
})
|
|
34
|
+
expect(items.some((item) => item.type === 'one_time')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns nothing to create when only removals are amended', () => {
|
|
38
|
+
expect(buildAddedItemPayloads('acc-1', { ...payload, addedItems: [] })).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('end-dates removed items the day before the effective date', () => {
|
|
42
|
+
expect(removedItemEndDate('2026-12-01')).toBe('2026-11-30')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -36,6 +36,8 @@ export type Scope = {
|
|
|
36
36
|
|
|
37
37
|
export type AccountSeed = {
|
|
38
38
|
customerId: string
|
|
39
|
+
/** 'company' (default when omitted) | 'person' — B2C accounts (billing Spec E). */
|
|
40
|
+
customerKind?: 'company' | 'person'
|
|
39
41
|
name: string
|
|
40
42
|
currencyCode: string
|
|
41
43
|
billCycle: 'monthly' | 'quarterly' | 'annually' | 'weekly'
|
|
@@ -58,6 +60,12 @@ export type ItemCreatePayload = {
|
|
|
58
60
|
subscriptionId?: string
|
|
59
61
|
subscriptionItemId?: string
|
|
60
62
|
sourceRef?: string
|
|
63
|
+
/**
|
|
64
|
+
* Billing Spec B: VAT % of the charge. CPQ charges carry no tax rate yet,
|
|
65
|
+
* so the connector omits it and billing applies the organization default
|
|
66
|
+
* at creation. Set it once CPQ exposes a per-charge rate.
|
|
67
|
+
*/
|
|
68
|
+
taxRate?: number
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
export type ItemUpdatePayload = {
|
|
@@ -2,6 +2,10 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
2
2
|
import { BillingApiClient, type Scope, type AccountSeed } from '../lib/billingApiClient'
|
|
3
3
|
import { mapSubscriptionItemsToBillingItems } from '../lib/chargeMapper'
|
|
4
4
|
import type { CpqSubscriptionItem } from '../lib/chargeMapper'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_INVOICE_LANGUAGE,
|
|
7
|
+
isSupportedInvoiceLanguage,
|
|
8
|
+
} from '@dainamite/billing/modules/billing/lib/invoiceLanguages'
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Subscriber: `cpq.subscription.activated` → billing onboarding.
|
|
@@ -13,8 +17,10 @@ import type { CpqSubscriptionItem } from '../lib/chargeMapper'
|
|
|
13
17
|
* 1. Get-or-create the Billing Account for `(tenant, org, customerId)`.
|
|
14
18
|
* Missing account → shell account with placeholders (operator
|
|
15
19
|
* must configure before billing fires). `nextBillDate` defaults
|
|
16
|
-
* to
|
|
17
|
-
*
|
|
20
|
+
* to the activation date: recurring items are billed in advance
|
|
21
|
+
* (billing Spec A), so the first period is billed from day one.
|
|
22
|
+
* An EXISTING account keeps its bill date — billing prorates the
|
|
23
|
+
* new subscription's partial first period.
|
|
18
24
|
* 2. For every charge on every CPQ subscription item, create a
|
|
19
25
|
* Billing Item via `billing.items.create`. `source_ref` is the
|
|
20
26
|
* deterministic `cpq-<subId>-<subItemId>-<chargeType>` key, so
|
|
@@ -31,11 +37,13 @@ export const metadata = {
|
|
|
31
37
|
id: 'cpq-billing-connector:subscription-activated',
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
type ActivatedPayload = {
|
|
40
|
+
export type ActivatedPayload = {
|
|
35
41
|
tenantId: string
|
|
36
42
|
organizationId: string
|
|
37
43
|
subscriptionId: string
|
|
38
44
|
customerId: string
|
|
45
|
+
/** 'company' | 'person'. Omitted → billing defaults to 'company'. */
|
|
46
|
+
customerKind?: 'company' | 'person'
|
|
39
47
|
currencyCode: string
|
|
40
48
|
/** Optional richer customer fields — used when present, otherwise placeholders. */
|
|
41
49
|
customerName?: string
|
|
@@ -51,42 +59,42 @@ type ActivatedPayload = {
|
|
|
51
59
|
activationDate?: string
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
function isoDateAddMonths(months: number): string {
|
|
55
|
-
// Clamp the day to the last valid day of the target month so Jan 31 + 1
|
|
56
|
-
// month → Feb 28/29 (not Mar 3, which is what `setUTCMonth` alone would
|
|
57
|
-
// produce by overflowing into the next month).
|
|
58
|
-
const d = new Date()
|
|
59
|
-
const targetYear = d.getUTCFullYear()
|
|
60
|
-
const targetMonth = d.getUTCMonth() + months
|
|
61
|
-
const lastDayOfTargetMonth = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate()
|
|
62
|
-
d.setUTCDate(Math.min(d.getUTCDate(), lastDayOfTargetMonth))
|
|
63
|
-
d.setUTCMonth(targetMonth)
|
|
64
|
-
return d.toISOString().slice(0, 10)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
62
|
function todayIsoDate(): string {
|
|
68
63
|
return new Date().toISOString().slice(0, 10)
|
|
69
64
|
}
|
|
70
65
|
|
|
71
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Billing only accepts invoice languages its document template supports.
|
|
68
|
+
* An unsupported code from upstream must not fail onboarding (the persistent
|
|
69
|
+
* queue would retry forever), so it falls back to billing's default — the
|
|
70
|
+
* operator can change it on the account afterwards.
|
|
71
|
+
*/
|
|
72
|
+
function resolveInvoiceLanguage(raw: string | undefined): string {
|
|
73
|
+
const code = (raw ?? '').trim().toLowerCase()
|
|
74
|
+
return isSupportedInvoiceLanguage(code) ? code : DEFAULT_INVOICE_LANGUAGE
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildAccountSeed(payload: ActivatedPayload): AccountSeed {
|
|
72
78
|
return {
|
|
73
79
|
customerId: payload.customerId,
|
|
80
|
+
...(payload.customerKind ? { customerKind: payload.customerKind } : {}),
|
|
74
81
|
name: payload.customerName ?? `Customer ${payload.customerId}`,
|
|
75
82
|
currencyCode: payload.currencyCode,
|
|
76
83
|
billCycle: payload.billCycle ?? 'monthly',
|
|
77
84
|
billCycleAnchor: payload.billCycleAnchor ?? '1',
|
|
78
85
|
invoiceEmail:
|
|
79
86
|
payload.invoiceEmail ?? `billing+${payload.customerId}@invalid.local`,
|
|
80
|
-
invoiceLanguage: payload.invoiceLanguage
|
|
87
|
+
invoiceLanguage: resolveInvoiceLanguage(payload.invoiceLanguage),
|
|
81
88
|
invoiceAddress: payload.invoiceAddress ?? {
|
|
82
89
|
line1: 'TBD',
|
|
83
90
|
city: 'TBD',
|
|
84
91
|
postal_code: 'TBD',
|
|
85
92
|
country: 'XX',
|
|
86
93
|
},
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
// Billing Spec A: recurring items are billed in advance, so the first
|
|
95
|
+
// bill date is the subscription start — billing the first period on
|
|
96
|
+
// activation. (Was "today + 1 month", which skipped that period.)
|
|
97
|
+
nextBillDate: new Date(payload.nextBillDate ?? payload.activationDate ?? todayIsoDate()),
|
|
90
98
|
taxId: payload.taxId ?? null,
|
|
91
99
|
}
|
|
92
100
|
}
|
|
@@ -1,39 +1,27 @@
|
|
|
1
1
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
2
|
-
|
|
3
|
-
// `@dainamite/billing` internals (not a stable public surface). A breaking
|
|
4
|
-
// rename or signature change in billing's `billPeriod` will manifest here
|
|
5
|
-
// as a runtime crash on the first `cpq.subscription.amended` event. Same
|
|
6
|
-
// trade-off as `billingApiClient.ts` — kept inline for now because the
|
|
7
|
-
// proration math is non-trivial and we don't want to fork it. Promote
|
|
8
|
-
// `cycleContaining` to a documented public export of @dainamite/billing
|
|
9
|
-
// (or expose via a billing REST helper) before the connector hits v1.
|
|
10
|
-
import { cycleContaining } from '@dainamite/billing/modules/billing/lib/billPeriod'
|
|
11
|
-
import type { BillCycle } from '@dainamite/billing/modules/billing/data/validators'
|
|
12
|
-
import { BillingApiClient, type Scope } from '../lib/billingApiClient'
|
|
2
|
+
import { BillingApiClient, type ItemCreatePayload, type Scope } from '../lib/billingApiClient'
|
|
13
3
|
import {
|
|
14
4
|
mapSubscriptionItemsToBillingItems,
|
|
15
5
|
type CpqSubscriptionItem,
|
|
16
6
|
} from '../lib/chargeMapper'
|
|
17
|
-
import {
|
|
18
|
-
computeProration,
|
|
19
|
-
formatProrationDescription,
|
|
20
|
-
} from '../lib/prorationHelper'
|
|
21
7
|
|
|
22
8
|
/**
|
|
23
9
|
* Subscriber: `cpq.subscription.amended` → mid-cycle add / remove.
|
|
24
10
|
*
|
|
25
11
|
* CPQ's amended event carries `addedItems`, `removedSubscriptionItemIds`
|
|
26
12
|
* and the `effectiveDate` of the amendment. The connector:
|
|
27
|
-
* 1. Creates a Billing Item for every added charge
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
13
|
+
* 1. Creates a Billing Item for every added charge, starting on
|
|
14
|
+
* `effectiveDate`.
|
|
15
|
+
* 2. End-dates every Billing Item of each removed subscription item at
|
|
16
|
+
* `effectiveDate - 1 day`.
|
|
17
|
+
*
|
|
18
|
+
* Proration is billing's job (billing Spec A): the Bill Run engine prorates
|
|
19
|
+
* the partial period of an item that starts mid-cycle, and credits the
|
|
20
|
+
* unused days of an item billed in advance that ends mid-cycle. The
|
|
21
|
+
* connector therefore no longer posts its own `one_time` proration lines —
|
|
22
|
+
* doing both would double-charge the customer. A "no refund, end at period
|
|
23
|
+
* end" policy is expressed by CPQ choosing an `effectiveDate` on the period
|
|
24
|
+
* boundary.
|
|
37
25
|
*/
|
|
38
26
|
|
|
39
27
|
export const metadata = {
|
|
@@ -42,7 +30,7 @@ export const metadata = {
|
|
|
42
30
|
id: 'cpq-billing-connector:subscription-amended',
|
|
43
31
|
}
|
|
44
32
|
|
|
45
|
-
type AmendedPayload = {
|
|
33
|
+
export type AmendedPayload = {
|
|
46
34
|
tenantId: string
|
|
47
35
|
organizationId: string
|
|
48
36
|
subscriptionId: string
|
|
@@ -53,28 +41,35 @@ type AmendedPayload = {
|
|
|
53
41
|
removedSubscriptionItemIds?: string[]
|
|
54
42
|
}
|
|
55
43
|
|
|
56
|
-
const DAY_MS = 86_400_000
|
|
57
|
-
|
|
58
|
-
function toMidnightMs(value: Date | string): number {
|
|
59
|
-
const d =
|
|
60
|
-
value instanceof Date
|
|
61
|
-
? new Date(value.getTime())
|
|
62
|
-
: new Date(`${value}T00:00:00.000Z`)
|
|
63
|
-
d.setUTCHours(0, 0, 0, 0)
|
|
64
|
-
return d.getTime()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Inclusive day count from `from` through `to`. */
|
|
68
|
-
function inclusiveDays(from: Date | string, to: Date | string): number {
|
|
69
|
-
return Math.round((toMidnightMs(to) - toMidnightMs(from)) / DAY_MS) + 1
|
|
70
|
-
}
|
|
71
|
-
|
|
72
44
|
function isoDateMinusOneDay(iso: string): string {
|
|
73
45
|
const d = new Date(`${iso}T00:00:00.000Z`)
|
|
74
46
|
d.setUTCDate(d.getUTCDate() - 1)
|
|
75
47
|
return d.toISOString().slice(0, 10)
|
|
76
48
|
}
|
|
77
49
|
|
|
50
|
+
/** Billing Item payloads for the added charges — pure, no proration lines. */
|
|
51
|
+
export function buildAddedItemPayloads(billAccountId: string, payload: AmendedPayload): ItemCreatePayload[] {
|
|
52
|
+
return mapSubscriptionItemsToBillingItems({
|
|
53
|
+
subscriptionId: payload.subscriptionId,
|
|
54
|
+
items: payload.addedItems ?? [],
|
|
55
|
+
billStartDate: payload.effectiveDate,
|
|
56
|
+
}).map((item) => ({
|
|
57
|
+
billAccountId,
|
|
58
|
+
type: item.type,
|
|
59
|
+
billStartDate: item.billStartDate,
|
|
60
|
+
description: item.description,
|
|
61
|
+
rateJson: item.rateJson,
|
|
62
|
+
subscriptionId: item.subscriptionId,
|
|
63
|
+
subscriptionItemId: item.subscriptionItemId,
|
|
64
|
+
sourceRef: item.sourceRef,
|
|
65
|
+
}))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `bill_end_date` for the items of removed subscription items. */
|
|
69
|
+
export function removedItemEndDate(effectiveDate: string): string {
|
|
70
|
+
return isoDateMinusOneDay(effectiveDate)
|
|
71
|
+
}
|
|
72
|
+
|
|
78
73
|
export default async function handle(payload: AmendedPayload): Promise<void> {
|
|
79
74
|
if (!payload?.tenantId || !payload.organizationId) return
|
|
80
75
|
if (!payload.subscriptionId || !payload.effectiveDate) return
|
|
@@ -95,68 +90,14 @@ export default async function handle(payload: AmendedPayload): Promise<void> {
|
|
|
95
90
|
const account = await billingApi.findAccountByCustomer(scope, payload.customerId)
|
|
96
91
|
if (!account) return
|
|
97
92
|
|
|
98
|
-
// 1. New Billing Items for every added charge.
|
|
99
|
-
const itemsToCreate =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
billStartDate: payload.effectiveDate,
|
|
103
|
-
}).map((item) => ({
|
|
104
|
-
billAccountId: account.id,
|
|
105
|
-
type: item.type,
|
|
106
|
-
billStartDate: item.billStartDate,
|
|
107
|
-
description: item.description,
|
|
108
|
-
rateJson: item.rateJson,
|
|
109
|
-
subscriptionId: item.subscriptionId,
|
|
110
|
-
subscriptionItemId: item.subscriptionItemId,
|
|
111
|
-
sourceRef: item.sourceRef,
|
|
112
|
-
}))
|
|
113
|
-
|
|
114
|
-
// 2. Mid-cycle proration. The billing cycle the amend lands in is
|
|
115
|
-
// derived from the account here — CPQ does not own the billing cycle.
|
|
116
|
-
if (added.length > 0) {
|
|
117
|
-
const period = cycleContaining(
|
|
118
|
-
account.nextBillDate,
|
|
119
|
-
account.billCycle as BillCycle,
|
|
120
|
-
payload.effectiveDate,
|
|
121
|
-
)
|
|
122
|
-
const cycleEnd = period.periodEnd.toISOString().slice(0, 10)
|
|
123
|
-
const daysInPeriod = inclusiveDays(period.periodStart, period.periodEnd)
|
|
124
|
-
const daysRemaining = inclusiveDays(payload.effectiveDate, period.periodEnd)
|
|
125
|
-
|
|
126
|
-
for (const addedItem of added) {
|
|
127
|
-
for (const charge of addedItem.charges) {
|
|
128
|
-
if (charge.type !== 'recurring' || typeof charge.unitPrice !== 'number') {
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
const { amount } = computeProration({
|
|
132
|
-
unitPrice: charge.unitPrice,
|
|
133
|
-
quantity: addedItem.quantity,
|
|
134
|
-
daysInPeriod,
|
|
135
|
-
daysRemaining,
|
|
136
|
-
})
|
|
137
|
-
if (amount === 0) continue
|
|
138
|
-
itemsToCreate.push({
|
|
139
|
-
billAccountId: account.id,
|
|
140
|
-
type: 'one_time',
|
|
141
|
-
billStartDate: payload.effectiveDate,
|
|
142
|
-
description: formatProrationDescription({
|
|
143
|
-
productName: addedItem.productName,
|
|
144
|
-
effectiveDate: payload.effectiveDate,
|
|
145
|
-
cycleEnd,
|
|
146
|
-
}),
|
|
147
|
-
rateJson: { amount },
|
|
148
|
-
subscriptionId: payload.subscriptionId,
|
|
149
|
-
subscriptionItemId: addedItem.subscriptionItemId,
|
|
150
|
-
sourceRef: `cpq-${payload.subscriptionId}-${addedItem.subscriptionItemId}-proration-${payload.effectiveDate}`,
|
|
151
|
-
})
|
|
152
|
-
}
|
|
153
|
-
}
|
|
93
|
+
// 1. New Billing Items for every added charge (the engine prorates the stub).
|
|
94
|
+
const itemsToCreate = buildAddedItemPayloads(account.id, payload)
|
|
95
|
+
if (itemsToCreate.length > 0) {
|
|
96
|
+
await billingApi.bulkCreateItems(scope, itemsToCreate)
|
|
154
97
|
}
|
|
155
98
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// 3. End-date every Billing Item of each removed subscription item.
|
|
159
|
-
const endDate = isoDateMinusOneDay(payload.effectiveDate)
|
|
99
|
+
// 2. End-date every Billing Item of each removed subscription item.
|
|
100
|
+
const endDate = removedItemEndDate(payload.effectiveDate)
|
|
160
101
|
for (const removedSubItemId of removed) {
|
|
161
102
|
const items = await billingApi.findItemsBySubscriptionItem(scope, removedSubItemId)
|
|
162
103
|
for (const item of items) {
|
|
@@ -6,14 +6,17 @@ import { BillingApiClient, type Scope } from '../lib/billingApiClient'
|
|
|
6
6
|
* every billing item attached to the subscription.
|
|
7
7
|
*
|
|
8
8
|
* The connector uses the spec's `effectiveDate` (cancellation date)
|
|
9
|
-
* minus one day as `bill_end_date
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* minus one day as `bill_end_date`. From there the billing engine
|
|
10
|
+
* (billing Spec A) handles the money:
|
|
11
|
+
* - an item billed in arrears is charged pro rata up to the end date;
|
|
12
|
+
* - an item already billed in advance past the end date gets an
|
|
13
|
+
* automatic credit line for the unused days (when `prorate` is on).
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* The cancellation POLICY belongs to CPQ: "cancel at the end of the
|
|
16
|
+
* current period, no refund" means CPQ sends an `effectiveDate` on the
|
|
17
|
+
* period boundary — then no credit arises. The connector never invents
|
|
18
|
+
* credits itself (billing does), so CPQ must NOT also post a negative
|
|
19
|
+
* `one_time` item for the same days.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
22
|
export const metadata = {
|
|
@@ -1,19 +0,0 @@
|
|
|
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
|
|
@@ -1,7 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
computeProration,
|
|
3
|
-
formatProrationDescription,
|
|
4
|
-
} from '../../lib/prorationHelper'
|
|
5
|
-
|
|
6
|
-
describe('computeProration', () => {
|
|
7
|
-
it('canonical spec example: 29 EUR × 17/31 → 15.90 (Story 2)', () => {
|
|
8
|
-
const result = computeProration({
|
|
9
|
-
unitPrice: 29,
|
|
10
|
-
quantity: 1,
|
|
11
|
-
daysInPeriod: 31,
|
|
12
|
-
daysRemaining: 17,
|
|
13
|
-
})
|
|
14
|
-
// 29 × 17 / 31 = 15.903225… → 15.90
|
|
15
|
-
expect(result.amount).toBe(15.9)
|
|
16
|
-
expect(result.amountString).toBe('15.9000')
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('full-period proration equals full unit_price × quantity', () => {
|
|
20
|
-
const result = computeProration({
|
|
21
|
-
unitPrice: 49.99,
|
|
22
|
-
quantity: 2,
|
|
23
|
-
daysInPeriod: 30,
|
|
24
|
-
daysRemaining: 30,
|
|
25
|
-
})
|
|
26
|
-
expect(result.amount).toBe(99.98)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('zero days remaining → zero amount', () => {
|
|
30
|
-
const result = computeProration({
|
|
31
|
-
unitPrice: 49,
|
|
32
|
-
quantity: 1,
|
|
33
|
-
daysInPeriod: 30,
|
|
34
|
-
daysRemaining: 0,
|
|
35
|
-
})
|
|
36
|
-
expect(result.amount).toBe(0)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('rounds half-up to 2dp', () => {
|
|
40
|
-
// 1 × 1 × 1 / 200 = 0.005 → 0.01 (half-up)
|
|
41
|
-
const result = computeProration({
|
|
42
|
-
unitPrice: 1,
|
|
43
|
-
quantity: 1,
|
|
44
|
-
daysInPeriod: 200,
|
|
45
|
-
daysRemaining: 1,
|
|
46
|
-
})
|
|
47
|
-
expect(result.amount).toBe(0.01)
|
|
48
|
-
expect(result.amountString).toBe('0.0100')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('defends against daysInPeriod === 0 (degenerate input)', () => {
|
|
52
|
-
const result = computeProration({
|
|
53
|
-
unitPrice: 49,
|
|
54
|
-
quantity: 1,
|
|
55
|
-
daysInPeriod: 0,
|
|
56
|
-
daysRemaining: 5,
|
|
57
|
-
})
|
|
58
|
-
expect(result.amount).toBe(0)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('clamps negative daysRemaining to 0 (defensive)', () => {
|
|
62
|
-
const result = computeProration({
|
|
63
|
-
unitPrice: 49,
|
|
64
|
-
quantity: 1,
|
|
65
|
-
daysInPeriod: 30,
|
|
66
|
-
daysRemaining: -3,
|
|
67
|
-
})
|
|
68
|
-
expect(result.amount).toBe(0)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('scales by quantity correctly', () => {
|
|
72
|
-
// 10 seats × 5 EUR × 15/30 = 25 EUR
|
|
73
|
-
const result = computeProration({
|
|
74
|
-
unitPrice: 5,
|
|
75
|
-
quantity: 10,
|
|
76
|
-
daysInPeriod: 30,
|
|
77
|
-
daysRemaining: 15,
|
|
78
|
-
})
|
|
79
|
-
expect(result.amount).toBe(25)
|
|
80
|
-
})
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
describe('formatProrationDescription', () => {
|
|
84
|
-
it('matches the spec example phrasing', () => {
|
|
85
|
-
expect(
|
|
86
|
-
formatProrationDescription({
|
|
87
|
-
productName: 'Package X',
|
|
88
|
-
effectiveDate: '2026-05-15',
|
|
89
|
-
cycleEnd: '2026-05-31',
|
|
90
|
-
}),
|
|
91
|
-
).toBe('Proration: Package X from 2026-05-15 to 2026-05-31')
|
|
92
|
-
})
|
|
93
|
-
})
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Proration math owned by the connector.
|
|
3
|
-
*
|
|
4
|
-
* Per spec: "Connectors computing proration values should follow the
|
|
5
|
-
* same convention for consistency on the draft" — i.e. half-up 2dp,
|
|
6
|
-
* matching billing's `roundHalfUp2dp` policy. Duplicated here (vs
|
|
7
|
-
* imported from `@dainamite/billing`) on purpose so the connector
|
|
8
|
-
* doesn't reach into billing's internals via a non-peer-dep path.
|
|
9
|
-
*
|
|
10
|
-
* The formula is the linear-day proration documented in the spec's
|
|
11
|
-
* "CPQ-side payload contracts" section:
|
|
12
|
-
* `prorationAmount = unitPrice × quantity × daysRemaining / daysInPeriod`
|
|
13
|
-
*
|
|
14
|
-
* For mid-cycle additions (e.g. customer buys an add-on on 2026-05-15
|
|
15
|
-
* for a monthly cycle anchored on the 1st), `daysRemaining` is the
|
|
16
|
-
* number of full days from the effective date through the cycle end
|
|
17
|
-
* (inclusive), and `daysInPeriod` is the cycle's calendar-day length.
|
|
18
|
-
*
|
|
19
|
-
* The CPQ event payload supplies both numbers — the connector trusts
|
|
20
|
-
* the calendar arithmetic CPQ already did (it derived the period
|
|
21
|
-
* boundaries from the subscription's lifecycle). It does NOT
|
|
22
|
-
* re-compute them from `effectiveDate` and `cycleEnd` to avoid
|
|
23
|
-
* disagreeing with CPQ's source of truth.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Half-up 2dp rounding that survives JS floating-point edges
|
|
28
|
-
* (e.g. `1.005 * 100 === 100.49999…`). Matches billing's
|
|
29
|
-
* `roundHalfUp2dp` policy.
|
|
30
|
-
*/
|
|
31
|
-
function roundHalfUp2dp(value: number): number {
|
|
32
|
-
return Math.round((value + Number.EPSILON) * 100) / 100
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type ProrationInput = {
|
|
36
|
-
unitPrice: number
|
|
37
|
-
quantity: number
|
|
38
|
-
daysInPeriod: number
|
|
39
|
-
daysRemaining: number
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export type ProrationResult = {
|
|
43
|
-
/** Numeric amount, half-up 2dp, suitable for `rate_json.amount`. */
|
|
44
|
-
amount: number
|
|
45
|
-
/** Computed string form for `billing.items.create.rateJson` (4dp). */
|
|
46
|
-
amountString: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function computeProration(input: ProrationInput): ProrationResult {
|
|
50
|
-
// Guard against the degenerate "0 days in period" — should never
|
|
51
|
-
// happen if CPQ derived the values, but defending here avoids a
|
|
52
|
-
// silent NaN propagation if it ever does.
|
|
53
|
-
if (input.daysInPeriod <= 0) {
|
|
54
|
-
return { amount: 0, amountString: '0.0000' }
|
|
55
|
-
}
|
|
56
|
-
const raw =
|
|
57
|
-
(input.unitPrice * input.quantity * Math.max(0, input.daysRemaining)) /
|
|
58
|
-
input.daysInPeriod
|
|
59
|
-
const rounded = roundHalfUp2dp(raw)
|
|
60
|
-
return { amount: rounded, amountString: rounded.toFixed(4) }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Helper used by subscribers to format the proration description so
|
|
65
|
-
* the resulting invoice line reads naturally (matches spec example:
|
|
66
|
-
* "Proration: Package X from 2026-05-15 to 2026-05-31").
|
|
67
|
-
*/
|
|
68
|
-
export function formatProrationDescription(params: {
|
|
69
|
-
productName: string
|
|
70
|
-
effectiveDate: string
|
|
71
|
-
cycleEnd: string
|
|
72
|
-
}): string {
|
|
73
|
-
return `Proration: ${params.productName} from ${params.effectiveDate} to ${params.cycleEnd}`
|
|
74
|
-
}
|