@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 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": "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",
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": "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",
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 ?? "en",
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
- nextBillDate: payload.nextBillDate ? new Date(payload.nextBillDate) : new Date(isoDateAddMonths(1)),
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 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;",
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 = mapSubscriptionItemsToBillingItems({
45
- subscriptionId: payload.subscriptionId,
46
- items: added,
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
- await billingApi.bulkCreateItems(scope, itemsToCreate);
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'\n// TODO(L2-boundary): `cycleContaining` is a runtime function imported from\n// `@dainamite/billing` internals (not a stable public surface). A breaking\n// rename or signature change in billing's `billPeriod` will manifest here\n// as a runtime crash on the first `cpq.subscription.amended` event. Same\n// trade-off as `billingApiClient.ts` \u2014 kept inline for now because the\n// proration math is non-trivial and we don't want to fork it. Promote\n// `cycleContaining` to a documented public export of @dainamite/billing\n// (or expose via a billing REST helper) before the connector hits v1.\nimport { cycleContaining } from '@dainamite/billing/modules/billing/lib/billPeriod'\nimport type { BillCycle } from '@dainamite/billing/modules/billing/data/validators'\nimport { BillingApiClient, type Scope } from '../lib/billingApiClient'\nimport {\n mapSubscriptionItemsToBillingItems,\n type CpqSubscriptionItem,\n} from '../lib/chargeMapper'\nimport {\n computeProration,\n formatProrationDescription,\n} from '../lib/prorationHelper'\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 \u2014 recurring items\n * start on `effectiveDate`; the engine's mid-cycle rule skips the\n * current cycle and bills from the next full cycle.\n * 2. For every added RECURRING charge, posts a `one_time` proration\n * line covering the partial current cycle. The billing cycle is\n * derived HERE (not in CPQ) \u2014 proration is a billing concept, so\n * the connector computes it from the account's `nextBillDate` +\n * `billCycle` via billing's `cycleContaining`.\n * 3. End-dates every Billing Item of each removed subscription item\n * at `effectiveDate - 1 day`.\n */\n\nexport const metadata = {\n event: 'cpq.subscription.amended',\n persistent: true,\n id: 'cpq-billing-connector:subscription-amended',\n}\n\ntype 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\nconst DAY_MS = 86_400_000\n\nfunction toMidnightMs(value: Date | string): number {\n const d =\n value instanceof Date\n ? new Date(value.getTime())\n : new Date(`${value}T00:00:00.000Z`)\n d.setUTCHours(0, 0, 0, 0)\n return d.getTime()\n}\n\n/** Inclusive day count from `from` through `to`. */\nfunction inclusiveDays(from: Date | string, to: Date | string): number {\n return Math.round((toMidnightMs(to) - toMidnightMs(from)) / DAY_MS) + 1\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: 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.\n const itemsToCreate = mapSubscriptionItemsToBillingItems({\n subscriptionId: payload.subscriptionId,\n items: added,\n billStartDate: payload.effectiveDate,\n }).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 // 2. Mid-cycle proration. The billing cycle the amend lands in is\n // derived from the account here \u2014 CPQ does not own the billing cycle.\n if (added.length > 0) {\n const period = cycleContaining(\n account.nextBillDate,\n account.billCycle as BillCycle,\n payload.effectiveDate,\n )\n const cycleEnd = period.periodEnd.toISOString().slice(0, 10)\n const daysInPeriod = inclusiveDays(period.periodStart, period.periodEnd)\n const daysRemaining = inclusiveDays(payload.effectiveDate, period.periodEnd)\n\n for (const addedItem of added) {\n for (const charge of addedItem.charges) {\n if (charge.type !== 'recurring' || typeof charge.unitPrice !== 'number') {\n continue\n }\n const { amount } = computeProration({\n unitPrice: charge.unitPrice,\n quantity: addedItem.quantity,\n daysInPeriod,\n daysRemaining,\n })\n if (amount === 0) continue\n itemsToCreate.push({\n billAccountId: account.id,\n type: 'one_time',\n billStartDate: payload.effectiveDate,\n description: formatProrationDescription({\n productName: addedItem.productName,\n effectiveDate: payload.effectiveDate,\n cycleEnd,\n }),\n rateJson: { amount },\n subscriptionId: payload.subscriptionId,\n subscriptionItemId: addedItem.subscriptionItemId,\n sourceRef: `cpq-${payload.subscriptionId}-${addedItem.subscriptionItemId}-proration-${payload.effectiveDate}`,\n })\n }\n }\n }\n\n await billingApi.bulkCreateItems(scope, itemsToCreate)\n\n // 3. End-date every Billing Item of each removed subscription item.\n const endDate = isoDateMinusOneDay(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": "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",
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` so the engine's recurring\n * selector stops including the item from the cycle containing the\n * cancellation onward.\n *\n * Final-period proration / refund credits (per spec) are issued by\n * CPQ \u2014 the connector does not invent them here. If a credit is\n * needed, CPQ posts a negative `one_time` Billing Item via this\n * same connector path (e.g. an `amend` event preceding the cancel).\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;AAiBtC,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;",
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.0.0",
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.20.0",
97
- "@dainamite/cpq": "^0.6.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 today + one month so the Bill Run won't bill an under-
17
- * configured account until the operator advances it.
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
- function buildAccountSeed(payload: ActivatedPayload): AccountSeed {
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 ?? 'en',
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
- nextBillDate: payload.nextBillDate
88
- ? new Date(payload.nextBillDate)
89
- : new Date(isoDateAddMonths(1)),
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
- // TODO(L2-boundary): `cycleContaining` is a runtime function imported from
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 — recurring items
28
- * start on `effectiveDate`; the engine's mid-cycle rule skips the
29
- * current cycle and bills from the next full cycle.
30
- * 2. For every added RECURRING charge, posts a `one_time` proration
31
- * line covering the partial current cycle. The billing cycle is
32
- * derived HERE (not in CPQ) — proration is a billing concept, so
33
- * the connector computes it from the account's `nextBillDate` +
34
- * `billCycle` via billing's `cycleContaining`.
35
- * 3. End-dates every Billing Item of each removed subscription item
36
- * at `effectiveDate - 1 day`.
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 = mapSubscriptionItemsToBillingItems({
100
- subscriptionId: payload.subscriptionId,
101
- items: added,
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
- await billingApi.bulkCreateItems(scope, itemsToCreate)
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` so the engine's recurring
10
- * selector stops including the item from the cycle containing the
11
- * cancellation onward.
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
- * Final-period proration / refund credits (per spec) are issued by
14
- * CPQ — the connector does not invent them here. If a credit is
15
- * needed, CPQ posts a negative `one_time` Billing Item via this
16
- * same connector path (e.g. an `amend` event preceding the cancel).
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
- }