From 005410321d22bdb26f08acda49f3a142f63f7b28 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Tue, 4 Aug 2026 00:22:33 +0200 Subject: [PATCH] feat: decabill noop subscriptions allow to create plans without a provisioning provider (no operation) --- ...0000_NullableServiceTypeOnPlansAndItems.ts | 156 +++++++++++++++ .../src/i18n/messages.xlf | 4 +- .../features/service-types-and-plans.md | 38 ++-- docs/decabill/features/subscriptions.md | 2 +- docs/decabill/features/webhooks.md | 2 + graph/graph.json | 17 +- .../backend/feature-billing-manager/README.md | 2 +- .../feature-billing-manager/spec/openapi.yaml | 33 ++- .../feature-billing-manager/src/index.ts | 1 + .../service-type-id.constants.spec.ts | 50 +++++ .../constants/service-type-id.constants.ts | 51 +++++ .../src/lib/controllers/pricing.controller.ts | 20 +- ...-service-plan-offerings.controller.spec.ts | 1 + ...ublic-service-plan-offerings.controller.ts | 3 +- .../service-plans.controller.spec.ts | 133 +++++++++++- .../controllers/service-plans.controller.ts | 189 ++++++++++++++---- .../src/lib/dto/create-service-plan.dto.ts | 10 +- .../dto/public-service-plan-offering.dto.ts | 2 +- .../src/lib/dto/service-plan-response.dto.ts | 2 +- .../lib/dto/subscription-item-response.dto.ts | 2 +- .../src/lib/entities/service-plan.entity.ts | 13 +- .../lib/entities/subscription-item.entity.ts | 9 +- .../service-plans.repository.spec.ts | 40 +++- .../repositories/service-plans.repository.ts | 74 ++++--- .../repositories/subscriptions.repository.ts | 5 + .../subscription-item-server.service.spec.ts | 19 ++ .../subscription-item-server.service.ts | 12 +- .../src/lib/services/subscription.service.ts | 111 +++++++++- ...ostgres-foreign-key-violation.util.spec.ts | 16 ++ .../postgres-foreign-key-violation.util.ts | 12 ++ .../src/lib/utils/tenant-query.utils.ts | 5 + .../data-access-billing-console/src/index.ts | 1 + .../service-type-id.constants.spec.ts | 12 ++ .../constants/service-type-id.constants.ts | 7 + .../src/lib/types/billing.types.ts | 7 +- .../lib/types/portal-service-plans.types.ts | 2 +- .../admin-promotions-page.component.ts | 4 +- .../service-plans-page.component.html | 103 +++++----- .../service-plans-page.component.ts | 132 +++++++----- .../subscriptions/subscriptions.component.ts | 90 +++++---- 40 files changed, 1117 insertions(+), 275 deletions(-) create mode 100644 apps/decabill/backend-billing-manager/src/migrations/1775800000000_NullableServiceTypeOnPlansAndItems.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.spec.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.spec.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.ts create mode 100644 libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.spec.ts create mode 100644 libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.ts diff --git a/apps/decabill/backend-billing-manager/src/migrations/1775800000000_NullableServiceTypeOnPlansAndItems.ts b/apps/decabill/backend-billing-manager/src/migrations/1775800000000_NullableServiceTypeOnPlansAndItems.ts new file mode 100644 index 000000000..0c7429262 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1775800000000_NullableServiceTypeOnPlansAndItems.ts @@ -0,0 +1,156 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey, TableIndex } from 'typeorm'; + +/** + * Allows service plans (and subscription items) without a service type so billing-only + * plans can deploy nothing. Adds plan.tenant_id for tenant isolation when the type FK is null. + */ +export class NullableServiceTypeOnPlansAndItems1775800000000 implements MigrationInterface { + name = 'NullableServiceTypeOnPlansAndItems1775800000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn('billing_service_plans', 'tenant_id'))) { + await queryRunner.addColumn( + 'billing_service_plans', + new TableColumn({ + name: 'tenant_id', + type: 'varchar', + length: '64', + isNullable: false, + default: "'default'", + }), + ); + } + + await queryRunner.query(` + UPDATE billing_service_plans sp + SET tenant_id = st.tenant_id + FROM billing_service_types st + WHERE st.id = sp.service_type_id + `); + + await queryRunner.createIndex( + 'billing_service_plans', + new TableIndex({ + name: 'IDX_billing_service_plans_tenant_id', + columnNames: ['tenant_id'], + }), + ); + + await this.dropForeignKeysOnColumn(queryRunner, 'billing_service_plans', 'service_type_id'); + await queryRunner.changeColumn( + 'billing_service_plans', + 'service_type_id', + new TableColumn({ + name: 'service_type_id', + type: 'uuid', + isNullable: true, + }), + ); + await queryRunner.createForeignKey( + 'billing_service_plans', + new TableForeignKey({ + name: 'FK_billing_service_plans_service_type_id', + columnNames: ['service_type_id'], + referencedTableName: 'billing_service_types', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await this.dropForeignKeysOnColumn(queryRunner, 'billing_subscription_items', 'service_type_id'); + await queryRunner.changeColumn( + 'billing_subscription_items', + 'service_type_id', + new TableColumn({ + name: 'service_type_id', + type: 'uuid', + isNullable: true, + }), + ); + await queryRunner.createForeignKey( + 'billing_subscription_items', + new TableForeignKey({ + name: 'FK_billing_subscription_items_service_type_id', + columnNames: ['service_type_id'], + referencedTableName: 'billing_service_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM billing_subscription_items WHERE service_type_id IS NULL + `); + await queryRunner.query(` + DELETE FROM billing_service_plans WHERE service_type_id IS NULL + `); + + await this.dropForeignKeysOnColumn(queryRunner, 'billing_subscription_items', 'service_type_id'); + await queryRunner.changeColumn( + 'billing_subscription_items', + 'service_type_id', + new TableColumn({ + name: 'service_type_id', + type: 'uuid', + isNullable: false, + }), + ); + await queryRunner.createForeignKey( + 'billing_subscription_items', + new TableForeignKey({ + name: 'FK_billing_subscription_items_service_type_id', + columnNames: ['service_type_id'], + referencedTableName: 'billing_service_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + + await this.dropForeignKeysOnColumn(queryRunner, 'billing_service_plans', 'service_type_id'); + await queryRunner.changeColumn( + 'billing_service_plans', + 'service_type_id', + new TableColumn({ + name: 'service_type_id', + type: 'uuid', + isNullable: false, + }), + ); + await queryRunner.createForeignKey( + 'billing_service_plans', + new TableForeignKey({ + name: 'FK_billing_service_plans_service_type_id', + columnNames: ['service_type_id'], + referencedTableName: 'billing_service_types', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.dropIndex('billing_service_plans', 'IDX_billing_service_plans_tenant_id'); + + if (await queryRunner.hasColumn('billing_service_plans', 'tenant_id')) { + await queryRunner.dropColumn('billing_service_plans', 'tenant_id'); + } + } + + private async dropForeignKeysOnColumn( + queryRunner: QueryRunner, + tableName: string, + columnName: string, + ): Promise { + const table = await queryRunner.getTable(tableName); + + if (!table) { + return; + } + + for (const fk of table.foreignKeys) { + if (fk.columnNames.includes(columnName)) { + await queryRunner.dropForeignKey(tableName, fk); + } + } + } +} diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index b31ac4e36..1f7e64fd0 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -2214,8 +2214,8 @@ Service type - - Choose a service type + + None (no deployment) Name diff --git a/docs/decabill/features/service-types-and-plans.md b/docs/decabill/features/service-types-and-plans.md index 880017f8b..bd2f2184f 100644 --- a/docs/decabill/features/service-types-and-plans.md +++ b/docs/decabill/features/service-types-and-plans.md @@ -77,32 +77,44 @@ API behavior: ## Service Plans -Service plans belong to a service type and define customer-facing pricing and billing rules. +Service plans define customer-facing pricing and billing rules. They usually belong to a service type (provider-backed or manual). They may also use **`serviceTypeId: null`** for billing-only plans that deploy nothing. + +### Plans without a service type (`null`) + +- API and admin UI use **`null`** (or omit the field on create) for no deployment — not a sentinel string. +- Persistence stores `NULL` in `billing_service_plans.service_type_id` (and on subscription items when ordered); responses expose `null`. +- Admin create form defaults the service type select to **None (no deployment)**; there is no “Choose a service type” placeholder. +- Ordering creates an immediately active subscription item with no cloud provisioning, availability check, addons, or backorders. +- Location/server-type customer selection and `autoRecalculatePriceDaily` are rejected for null-`serviceTypeId` plans. +- Plans carry their own `tenant_id` so tenant isolation works when the service-type join is absent. + +No dedicated webhook events are emitted for plan CRUD or for “non-provision fulfilled” items (same as other non-cloud providers). Customer orders still emit `subscription.created` and related billing events. ### Admin Endpoints -| Method | Path | Purpose | -| ------ | ------------------------------------------------ | --------------------------------------------- | -| GET | `/service-plans` | List service plans | -| POST | `/service-plans` | Create plan (admin) | -| GET | `/service-plans/{id}` | Get plan | -| GET | `/service-plans/{id}/order-provisioning-options` | List customer-selectable provisioning options | -| POST | `/service-plans/{id}` | Update plan (admin) | -| DELETE | `/service-plans/{id}` | Delete plan (admin) | +| Method | Path | Purpose | +| ------ | ------------------------------------------------ | ------------------------------------------------------------ | +| GET | `/service-plans` | List service plans | +| POST | `/service-plans` | Create plan (admin) | +| GET | `/service-plans/{id}` | Get plan | +| GET | `/service-plans/{id}/order-provisioning-options` | List customer-selectable provisioning options | +| POST | `/service-plans/{id}` | Update plan (admin) | +| DELETE | `/service-plans/{id}` | Delete plan (admin); 400 if subscriptions still reference it | ### Plan Fields (Conceptual) +- **`serviceTypeId`** UUID of a catalog service type, or **`null`** for no deployment - Title, description, and active flag - Billing interval (hourly, daily, monthly, **yearly**) - **`billInAdvance`** when true, charge at period start (prepaid); default false (arrear). Incompatible with usage-based metering. See [Advance billing and yearly interval](./advance-billing-and-yearly-interval.md). -- **`autoRecalculatePriceDaily`** when true, nightly job refreshes catalog base price from the provider and migrates eligible subscriptions (default false, opt-in). See [Automatic daily price recalculation](./automatic-price-recalculation.md). +- **`autoRecalculatePriceDaily`** when true, nightly job refreshes catalog base price from the provider and migrates eligible subscriptions (default false, opt-in; not allowed for null-`serviceTypeId` plans). See [Automatic daily price recalculation](./automatic-price-recalculation.md). - **Admin commercial migrate** on plan update, optional request field `migrateExistingSubscriptions` (not stored). When true **and** `basePrice`, `marginPercent`, `marginFixed`, or `taxCategory` actually change, a `plan-price-migrate.unit` job migrates eligible subscriptions with the same settlement, withdrawal restart, and consolidated price-change email as nightly recalc. Unchecked updates affect new orders only. - Base price, margin, and computed customer total -- `providerConfigDefaults` merged with customer `requestedConfig` on order +- `providerConfigDefaults` merged with customer `requestedConfig` on order (empty for null-`serviceTypeId` plans) - For provisioning plans, customers choose from `provisioningOptions` (integrated `agenstra-controller`/`agenstra-manager`/`decabill-billing` and/or custom CloudInit configs). Admins configure these exclusively via **Customer-selectable options** checkboxes in the plan editor; **Product defaults** fields are scoped to the checked options only. New plans default to every integrated stack present in the provider `service` enum (Agenstra Controller, Agenstra Manager, and Decabill Billing when enabled). Existing legacy plans are reconciled by migration `1772000000000_CloudInitAndPlanProvisioningConsolidated`. Integrated service ids were renamed from `controller`/`manager` to `agenstra-controller`/`agenstra-manager` by migration `1775500000000_RenameIntegratedProvisioningServiceIds` (runtime parsers still accept the legacy aliases). - `billing_day_of_month` for subscription period alignment -- `allowCustomerLocationSelection` when geography override is supported -- `allowCustomerServerTypeSelection` and `allowedServerTypes` when server-type override is supported (provider schema `basePriceFromField: 'serverType'`) +- `allowCustomerLocationSelection` when geography override is supported (not for null `serviceTypeId`) +- `allowCustomerServerTypeSelection` and `allowedServerTypes` when server-type override is supported (provider schema `basePriceFromField: 'serverType'`; not for null `serviceTypeId`) - Provider `configSchema.properties` may set `scope: "server"` or `scope: "product"` with optional `productServices` (`agenstra-controller`, `agenstra-manager`, `decabill-billing`) to control the plan editor. Server fields stay under **Provider default config**; product fields appear under **Product defaults** when required by selected customer options. ### Customer Geography Selection diff --git a/docs/decabill/features/subscriptions.md b/docs/decabill/features/subscriptions.md index b4efa3d58..6ed9675db 100644 --- a/docs/decabill/features/subscriptions.md +++ b/docs/decabill/features/subscriptions.md @@ -4,7 +4,7 @@ Order service plans, manage subscription lifecycle, and provision cloud infrastr ## Overview -Subscriptions link a user to a service plan. Plans reference a service type that may include Hetzner or DigitalOcean provisioning. Each subscription can have one or more subscription items representing provisioned or pending instances. +Subscriptions link a user to a service plan. Plans usually reference a service type that may include Hetzner or DigitalOcean provisioning. Plans may also use `serviceTypeId: null` (billing-only, no deployment). Each subscription can have one or more subscription items representing provisioned, pending, or immediately active (non-provisioned) instances. The order flow requires a complete [Customer Profile](./customer-profiles.md) before `POST /subscriptions` is accepted. diff --git a/docs/decabill/features/webhooks.md b/docs/decabill/features/webhooks.md index c3c41bf21..a1c868678 100644 --- a/docs/decabill/features/webhooks.md +++ b/docs/decabill/features/webhooks.md @@ -59,6 +59,8 @@ Events are published from the **billing** service after successful mutations. - `subscription.ssh_access_granted` (metadata only: subscription/item ids, hostname, grantedAt — never the private key) - `addon.activated`, `addon.deactivated`, `addon.provision_failed`, `addon.teardown_failed` +Plans with `serviceTypeId: null` (billing-only, no deployment) do not emit `subscription.provisioned` / `provision_failed`; fulfillment is immediate and silent, same as other non-cloud providers. Plan catalog CRUD does not emit webhooks. Orders still emit `subscription.created`. See [Service Types and Plans](./service-types-and-plans.md#plans-without-a-service-type-null). + Addon payloads include subscription id/number, plan id/name, addon id/key/name, and status timestamps. Config snapshots and secrets are never included. See [Addons](./addons.md). Config-change payloads carry the subscription payload plus `configChangeId`, `appliedSteps` (step keys such as `serverType` or `addonAdd:`), `billingOutcome` (`none` | `charged` | `credited` | `deferred`), and `errorCode`. The requested payload is never included because addon configuration can hold credentials. diff --git a/graph/graph.json b/graph/graph.json index 32df466c8..53da0bdc3 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-08-03T18:42:25.796Z", + "generatedAt": "2026-08-04T14:49:14.978Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -50443,6 +50443,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addons.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/withdrawal-policy.service.ts", @@ -61603,6 +61608,16 @@ "to": "api:HTTP:POST:/admin/billing/webhooks", "type": "documents" }, + { + "from": "concept:decabill-event-catalog", + "to": "api:HTTP:GET:/service-types", + "type": "documents" + }, + { + "from": "concept:decabill-event-catalog", + "to": "api:HTTP:POST:/service-types", + "type": "documents" + }, { "from": "concept:decabill-event-catalog", "to": "api:HTTP:GET:/addons", diff --git a/libs/domains/decabill/backend/feature-billing-manager/README.md b/libs/domains/decabill/backend/feature-billing-manager/README.md index b46bd7867..64dbe511f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/README.md +++ b/libs/domains/decabill/backend/feature-billing-manager/README.md @@ -7,7 +7,7 @@ Backend billing module providing subscription management, backorders, availabili - WebSocket dashboard status stream: see [`spec/asyncapi.yaml`](spec/asyncapi.yaml) (namespace `billing`, permission-locked; mirrors REST subscription ownership). - WebSocket project board stream: see [`spec/asyncapi.yaml`](spec/asyncapi.yaml) (namespace `projects`, room `project:{projectId}`; see [`docs/project-board-realtime.mmd`](docs/project-board-realtime.mmd)). - **Projects:** Customer-assigned work tracking with admin CRUD, milestones, tickets, time entries, KPI summaries, and `POST /admin/billing/projects/{projectId}/bill-time` (see [`docs/project-bill-time.mmd`](docs/project-bill-time.mmd)). -- Service types and plans (admin endpoints), including optional per-plan customer geography selection when the provider schema supports it. +- Service types and plans (admin endpoints), including optional per-plan customer geography selection when the provider schema supports it, and billing-only plans with `serviceTypeId: null` (no deployment). - CloudInit config templates (admin CRUD) and order-fields for custom service plans. - Subscription ordering, cancel, resume, and statutory withdrawal for authenticated users. - Backorder management for provider capacity failures. diff --git a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml index 9c787d1ac..ac583139a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -602,6 +602,12 @@ paths: responses: '204': description: Service plan deleted + '400': + description: Plan cannot be deleted (e.g. still referenced by subscriptions) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized content: @@ -5308,9 +5314,13 @@ components: serviceTypeId: type: string format: uuid + nullable: true + description: > + Service type UUID, or null when the plan has no service type + and deploys nothing. serviceTypeName: type: string - description: Display name of the service type (no config schema) + description: Display name of the service type (no config schema); empty when serviceTypeId is null billingIntervalType: type: string enum: [hour, day, month, year] @@ -5352,12 +5362,15 @@ components: $ref: '#/components/schemas/WithdrawalPolicy' CreateServicePlanDto: type: object - required: [serviceTypeId, name, billingIntervalType, billingIntervalValue] + required: [name, billingIntervalType, billingIntervalValue] properties: serviceTypeId: type: string format: uuid - description: Service type ID + nullable: true + description: > + UUID of an existing service type, or null/omitted for a billing-only plan that deploys + nothing (default in the admin UI). name: type: string description: @@ -5467,7 +5480,11 @@ components: type: object properties: id: { type: string, format: uuid } - serviceTypeId: { type: string, format: uuid } + serviceTypeId: + type: string + format: uuid + nullable: true + description: Service type UUID, or null when the plan has no service type / no deployment name: { type: string } description: { type: string, nullable: true } billingIntervalType: { type: string, enum: [hour, day, month, year] } @@ -5868,11 +5885,15 @@ components: properties: id: { type: string, format: uuid } subscriptionId: { type: string, format: uuid } - serviceTypeId: { type: string, format: uuid } + serviceTypeId: + type: string + format: uuid + nullable: true + description: Service type UUID, or null when the parent plan has no service type serviceTypeName: { type: string, - description: User-facing service type name from the catalog (billing_service_types.name), + description: User-facing service type name from the catalog (billing_service_types.name); empty when serviceTypeId is null, } provisioningStatus: { type: string, enum: [pending, active, failed] } hostname: diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts index 89633493b..deaa512fa 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/controllers/promotions.controller'; export * from './lib/controllers/admin-promotions.controller'; export * from './lib/dto/promotion.dto'; export * from './lib/constants/promotion.constants'; +export * from './lib/constants/service-type-id.constants'; export * from './lib/entities/promotion.entity'; export * from './lib/entities/promotion-redemption.entity'; export * from './lib/entities/invoice-promotion-application.entity'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.spec.ts new file mode 100644 index 000000000..31a02271f --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.spec.ts @@ -0,0 +1,50 @@ +import { + fromApiServiceTypeId, + isNoneServiceTypeId, + isValidApiServiceTypeId, + parseApiServiceTypeId, + toApiServiceTypeId, +} from './service-type-id.constants'; + +describe('service-type-id.constants', () => { + const uuid = '22222222-2222-4222-8222-222222222222'; + + it('isNoneServiceTypeId detects null/undefined/blank', () => { + expect(isNoneServiceTypeId(null)).toBe(true); + expect(isNoneServiceTypeId(undefined)).toBe(true); + expect(isNoneServiceTypeId('')).toBe(true); + expect(isNoneServiceTypeId(' ')).toBe(true); + expect(isNoneServiceTypeId(uuid)).toBe(false); + expect(isNoneServiceTypeId('none')).toBe(false); + }); + + it('toApiServiceTypeId maps unset to null', () => { + expect(toApiServiceTypeId(null)).toBeNull(); + expect(toApiServiceTypeId(undefined)).toBeNull(); + expect(toApiServiceTypeId('')).toBeNull(); + expect(toApiServiceTypeId(uuid)).toBe(uuid); + }); + + it('fromApiServiceTypeId maps unset to null', () => { + expect(fromApiServiceTypeId(null)).toBeNull(); + expect(fromApiServiceTypeId(undefined)).toBeNull(); + expect(fromApiServiceTypeId('')).toBeNull(); + expect(fromApiServiceTypeId(' ')).toBeNull(); + expect(fromApiServiceTypeId(uuid)).toBe(uuid); + }); + + it('isValidApiServiceTypeId accepts null/blank and UUID v4 only', () => { + expect(isValidApiServiceTypeId(null)).toBe(true); + expect(isValidApiServiceTypeId(undefined)).toBe(true); + expect(isValidApiServiceTypeId('')).toBe(true); + expect(isValidApiServiceTypeId(uuid)).toBe(true); + expect(isValidApiServiceTypeId('none')).toBe(false); + expect(isValidApiServiceTypeId('11111111-1111-1111-1111-111111111111')).toBe(false); + }); + + it('parseApiServiceTypeId throws on invalid input', () => { + expect(parseApiServiceTypeId(null)).toBeNull(); + expect(parseApiServiceTypeId(uuid)).toBe(uuid); + expect(() => parseApiServiceTypeId('none')).toThrow(/UUID or null/); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.ts new file mode 100644 index 000000000..761e77977 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/service-type-id.constants.ts @@ -0,0 +1,51 @@ +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * True when there is no service type (null / undefined, or blank string treated as absent). + * Used for billing-only plans that deploy nothing. + */ +export function isNoneServiceTypeId(value: string | null | undefined): boolean { + return value == null || value.trim() === ''; +} + +/** Map a DB UUID (or null) to the API value (null when unset). */ +export function toApiServiceTypeId(dbValue: string | null | undefined): string | null { + if (isNoneServiceTypeId(dbValue)) { + return null; + } + + return dbValue as string; +} + +/** + * Map an API service type id to a DB UUID or null. + * Accepts null / undefined / blank (none) or a UUID v4. + */ +export function fromApiServiceTypeId(apiValue: string | null | undefined): string | null { + if (isNoneServiceTypeId(apiValue)) { + return null; + } + + return (apiValue as string).trim(); +} + +/** Returns true when the API value is null/undefined/blank or a UUID v4. */ +export function isValidApiServiceTypeId(apiValue: string | null | undefined): boolean { + if (isNoneServiceTypeId(apiValue)) { + return true; + } + + return UUID_V4_PATTERN.test((apiValue as string).trim()); +} + +/** + * Parse and validate an API service type id. + * @throws Error with a stable message when invalid (callers typically wrap as BadRequestException). + */ +export function parseApiServiceTypeId(apiValue: string | null | undefined): string | null { + if (!isValidApiServiceTypeId(apiValue)) { + throw new Error('Service type ID must be a UUID or null'); + } + + return fromApiServiceTypeId(apiValue); +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts index ef1b3b51e..d4ed1b3ac 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.ts @@ -84,7 +84,7 @@ export class PricingController { let planPricing = this.pricingService.calculate(plan); - if (serverTypeId) { + if (serverTypeId && plan.serviceTypeId) { const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); const providerDefaults = normalizeStoredProviderDefaults(serviceType.providerDefaults); const priceMonthly = await resolveServerTypePriceMonthly( @@ -100,11 +100,19 @@ export class PricingController { } const selectedAddonIds = [...new Set((dto.addonIds ?? []).filter(Boolean))]; - const addons = await this.addonService.assertAddonIdsForOrder( - plan.serviceTypeId, - parsePlanAllowedAddonIds(plan.providerConfigDefaults), - selectedAddonIds, - ); + + if (plan.serviceTypeId == null && selectedAddonIds.length > 0) { + throw new BadRequestException('Addons are not supported for plans without a service type'); + } + + const addons = + plan.serviceTypeId == null + ? [] + : await this.addonService.assertAddonIdsForOrder( + plan.serviceTypeId, + parsePlanAllowedAddonIds(plan.providerConfigDefaults), + selectedAddonIds, + ); const addonLines = addons.map((addon) => ({ addonId: addon.id, name: addon.name, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.spec.ts index 6e0740b06..227f2d2ac 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.spec.ts @@ -16,6 +16,7 @@ describe('PublicServicePlanOfferingsController', () => { const planRow = { id: '11111111-1111-4111-8111-111111111111', serviceTypeId: '22222222-2222-4222-8222-222222222222', + tenantId: 'default', name: 'Pro', description: 'Full stack', billingIntervalType: BillingIntervalType.MONTH, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.ts index 91e409f42..43f7ad8dd 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/public-service-plan-offerings.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, NotFoundException, ParseIntPipe, Query } from '@nestjs import { PublicServicePlanOfferingDto } from '../dto/public-service-plan-offering.dto'; import { ServicePlanEntity } from '../entities/service-plan.entity'; +import { toApiServiceTypeId } from '../constants/service-type-id.constants'; import { ServicePlansRepository } from '../repositories/service-plans.repository'; import { PricingService } from '../services/pricing.service'; import { ProviderServerTypesService } from '../services/provider-server-types.service'; @@ -132,7 +133,7 @@ export class PublicServicePlanOfferingsController { id: row.id, name: row.name, description: row.description ?? null, - serviceTypeId: row.serviceTypeId, + serviceTypeId: toApiServiceTypeId(row.serviceTypeId), serviceTypeName: row.serviceType?.name ?? '', billingIntervalType: row.billingIntervalType, billingIntervalValue: row.billingIntervalValue, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts index 77418c23b..a78023442 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.spec.ts @@ -13,6 +13,7 @@ import { CloudInitConfigService } from '../services/cloud-init-config.service'; import { WithdrawalPolicyService } from '../services/withdrawal-policy.service'; import { AddonsRepository } from '../repositories/addons.repository'; import { PLAN_PRICE_MIGRATE_ENQUEUE } from '../queue/plan-price-migrate-enqueue.token'; +import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { ServicePlansController } from './service-plans.controller'; @@ -23,6 +24,7 @@ describe('ServicePlansController', () => { const basePlanRow: ServicePlanEntity = { id: '11111111-1111-4111-8111-111111111111', serviceTypeId: '22222222-2222-4222-8222-222222222222', + tenantId: 'default', name: 'Pro', description: 'Desc', billingIntervalType: BillingIntervalType.MONTH, @@ -74,6 +76,9 @@ describe('ServicePlansController', () => { const addonsRepositoryStub = { findByIds: jest.fn().mockResolvedValue([]), }; + const subscriptionsRepositoryStub = { + countByPlanId: jest.fn().mockResolvedValue(0), + }; beforeEach(() => { planPriceMigrateEnqueueStub.enqueueUnit.mockReset(); @@ -95,6 +100,8 @@ describe('ServicePlansController', () => { addonServiceStub.providerSupportsAddons.mockReturnValue(true); addonsRepositoryStub.findByIds.mockReset(); addonsRepositoryStub.findByIds.mockResolvedValue([]); + subscriptionsRepositoryStub.countByPlanId.mockReset(); + subscriptionsRepositoryStub.countByPlanId.mockResolvedValue(0); }); function setupRepositoryMock(mock: Partial>) { @@ -107,6 +114,7 @@ describe('ServicePlansController', () => { { provide: CloudInitConfigService, useValue: cloudInitConfigServiceStub }, { provide: AddonService, useValue: addonServiceStub }, { provide: AddonsRepository, useValue: addonsRepositoryStub }, + { provide: SubscriptionsRepository, useValue: subscriptionsRepositoryStub }, { provide: WithdrawalPolicyService, useValue: new WithdrawalPolicyService() }, { provide: PLAN_PRICE_MIGRATE_ENQUEUE, useValue: planPriceMigrateEnqueueStub }, ], @@ -481,7 +489,7 @@ describe('ServicePlansController', () => { cloudInitConfigServiceStub.getOrderFieldsForPlan.mockResolvedValue(orderFields); const moduleRef = await setupRepositoryMock({ findAll: jest.fn(), - findByIdOrThrow: jest.fn(), + findByIdOrThrow: jest.fn().mockResolvedValue(basePlanRow), create: jest.fn(), update: jest.fn(), delete: jest.fn(), @@ -496,4 +504,127 @@ describe('ServicePlansController', () => { '33333333-3333-4333-8333-333333333333', ); }); + + it('create with null serviceTypeId stores null and skips provider asserts', async () => { + const create = jest.fn().mockImplementation((dto: Partial) => + Promise.resolve({ + ...basePlanRow, + ...dto, + serviceTypeId: dto.serviceTypeId ?? null, + }), + ); + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn(), + create, + update: jest.fn(), + delete: jest.fn(), + }); + const controller = moduleRef.get(ServicePlansController); + + const result = await controller.create({ + serviceTypeId: null, + name: 'Billing only', + billingIntervalType: BillingIntervalType.MONTH, + billingIntervalValue: 1, + basePrice: '25', + } as CreateServicePlanDto); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + serviceTypeId: null, + providerConfigDefaults: {}, + allowCustomerLocationSelection: false, + allowCustomerServerTypeSelection: false, + autoRecalculatePriceDaily: false, + }), + ); + expect(cloudInitConfigServiceStub.assertActiveConfigForPlanDefaults).not.toHaveBeenCalled(); + expect(addonServiceStub.assertAllowedAddonIdsForPlan).not.toHaveBeenCalled(); + expect(serviceTypesRepoStub.findByIdOrThrow).not.toHaveBeenCalled(); + expect(result.serviceTypeId).toBeNull(); + }); + + it('create with null serviceTypeId rejects location selection', async () => { + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }); + const controller = moduleRef.get(ServicePlansController); + + await expect( + controller.create({ + serviceTypeId: null, + name: 'Billing only', + billingIntervalType: BillingIntervalType.MONTH, + billingIntervalValue: 1, + allowCustomerLocationSelection: true, + } as CreateServicePlanDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('get maps null serviceTypeId as null', async () => { + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn().mockResolvedValue({ ...basePlanRow, serviceTypeId: null }), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }); + const controller = moduleRef.get(ServicePlansController); + + const result = await controller.get(basePlanRow.id); + + expect(result.serviceTypeId).toBeNull(); + expect(serviceTypesRepoStub.findByIdOrThrow).not.toHaveBeenCalled(); + }); + + it('listOrderProvisioningOptions returns empty for none plans', async () => { + cloudInitConfigServiceStub.buildOrderProvisioningOptions.mockClear(); + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn().mockResolvedValue({ ...basePlanRow, serviceTypeId: null }), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }); + const controller = moduleRef.get(ServicePlansController); + + await expect(controller.listOrderProvisioningOptions(basePlanRow.id)).resolves.toEqual([]); + expect(cloudInitConfigServiceStub.buildOrderProvisioningOptions).not.toHaveBeenCalled(); + }); + + it('remove blocks delete when subscriptions reference the plan', async () => { + const deleteFn = jest.fn(); + subscriptionsRepositoryStub.countByPlanId.mockResolvedValue(2); + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn().mockResolvedValue(basePlanRow), + create: jest.fn(), + update: jest.fn(), + delete: deleteFn, + }); + const controller = moduleRef.get(ServicePlansController); + + await expect(controller.remove(basePlanRow.id)).rejects.toBeInstanceOf(BadRequestException); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('remove deletes when no subscriptions reference the plan', async () => { + const deleteFn = jest.fn().mockResolvedValue(undefined); + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn().mockResolvedValue(basePlanRow), + create: jest.fn(), + update: jest.fn(), + delete: deleteFn, + }); + const controller = moduleRef.get(ServicePlansController); + + await expect(controller.remove(basePlanRow.id)).resolves.toBeUndefined(); + expect(deleteFn).toHaveBeenCalledWith(basePlanRow.id); + }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts index 4a57638dd..b997a6e75 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts @@ -25,10 +25,12 @@ import { OrderProvisioningOptionDto } from '../dto/order-provisioning-option.dto import { ServicePlanResponseDto } from '../dto/service-plan-response.dto'; import { UpdateServicePlanDto } from '../dto/update-service-plan.dto'; import { ServicePlanEntity } from '../entities/service-plan.entity'; +import { fromApiServiceTypeId, isNoneServiceTypeId, toApiServiceTypeId } from '../constants/service-type-id.constants'; import { TaxCategory } from '../constants/tax-category.constants'; import { AddonsRepository } from '../repositories/addons.repository'; import { ServicePlansRepository } from '../repositories/service-plans.repository'; import { ServiceTypesRepository } from '../repositories/service-types.repository'; +import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { AddonService } from '../services/addon.service'; import { CloudInitConfigService } from '../services/cloud-init-config.service'; import { ProviderRegistryService } from '../services/provider-registry.service'; @@ -41,6 +43,7 @@ import { convertAddonPriceToPlanPeriod } from '../utils/addon-pricing.util'; import { normalizePlanProviderConfigDefaults } from '../utils/cloud-init/plan-provisioning-options.utils'; import { parsePlanAllowedAddonIds } from '../utils/plan-addons.utils'; import { commercialPricingFieldsChanged, snapshotCommercialPricing } from '../utils/plan-commercial-pricing.utils'; +import { isPostgresForeignKeyViolation } from '../utils/postgres-foreign-key-violation.util'; import { effectiveSchemaSupportsLocationSelection } from '../utils/provider-location.utils'; import { effectiveSchemaSupportsServerTypeSelection, @@ -58,6 +61,7 @@ export class ServicePlansController { private readonly cloudInitConfigService: CloudInitConfigService, private readonly addonService: AddonService, private readonly addonsRepository: AddonsRepository, + private readonly subscriptionsRepository: SubscriptionsRepository, private readonly withdrawalPolicyService: WithdrawalPolicyService, @Inject(PLAN_PRICE_MIGRATE_ENQUEUE) private readonly planPriceMigrateEnqueue: PlanPriceMigrateEnqueuePort, @@ -72,8 +76,10 @@ export class ServicePlansController { ): Promise { let rows = await this.servicePlansRepository.findAll(limit ?? 10, offset ?? 0); - if (serviceTypeId) { - rows = rows.filter((row) => row.serviceTypeId === serviceTypeId); + if (serviceTypeId !== undefined) { + const dbTypeId = isNoneServiceTypeId(serviceTypeId) ? null : serviceTypeId.trim(); + + rows = rows.filter((row) => row.serviceTypeId === dbTypeId); } return await Promise.all(rows.map((row) => this.mapToResponse(row))); @@ -86,6 +92,10 @@ export class ServicePlansController { ): Promise { const row = await this.servicePlansRepository.findByIdOrThrow(id); + if (!row.serviceTypeId) { + return []; + } + return this.cloudInitConfigService.buildOrderProvisioningOptions(row.providerConfigDefaults ?? {}); } @@ -93,6 +103,11 @@ export class ServicePlansController { @Get(':id/addons') async listOrderAddons(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { const plan = await this.servicePlansRepository.findByIdOrThrow(id); + + if (!plan.serviceTypeId) { + return []; + } + const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); if (!this.addonService.providerSupportsAddons(serviceType.provider)) { @@ -132,6 +147,12 @@ export class ServicePlansController { @Param('id', new ParseUUIDPipe({ version: '4' })) planId: string, @Param('configId', new ParseUUIDPipe({ version: '4' })) configId: string, ): Promise { + const plan = await this.servicePlansRepository.findByIdOrThrow(planId); + + if (!plan.serviceTypeId) { + return []; + } + return this.cloudInitConfigService.getOrderFieldsForPlan(planId, configId); } @@ -148,25 +169,37 @@ export class ServicePlansController { @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) async create(@Body() dto: CreateServicePlanDto): Promise { - await this.assertAllowLocationAllowed(dto.serviceTypeId, dto.allowCustomerLocationSelection === true); - await this.assertAllowServerTypeAllowed( - dto.serviceTypeId, - dto.allowCustomerServerTypeSelection === true, - dto.allowedServerTypes, - ); - const normalizedDefaults = normalizePlanProviderConfigDefaults(dto.providerConfigDefaults); - const allowCustomerServerTypeSelection = dto.allowCustomerServerTypeSelection === true; + const dbServiceTypeId = fromApiServiceTypeId(dto.serviceTypeId); + const isNone = dbServiceTypeId == null; + + if (isNone) { + this.assertNonePlanConstraints(dto); + } else { + await this.assertAllowLocationAllowed(dbServiceTypeId, dto.allowCustomerLocationSelection === true); + await this.assertAllowServerTypeAllowed( + dbServiceTypeId, + dto.allowCustomerServerTypeSelection === true, + dto.allowedServerTypes, + ); + } + + const normalizedDefaults = isNone ? {} : normalizePlanProviderConfigDefaults(dto.providerConfigDefaults); + const allowCustomerServerTypeSelection = isNone ? false : dto.allowCustomerServerTypeSelection === true; const allowedServerTypes = allowCustomerServerTypeSelection ? normalizeAllowedServerTypes(dto.allowedServerTypes) : []; - await this.cloudInitConfigService.assertActiveConfigForPlanDefaults(dto.serviceTypeId, normalizedDefaults); - await this.addonService.assertAllowedAddonIdsForPlan( - dto.serviceTypeId, - parsePlanAllowedAddonIds(normalizedDefaults), - ); + if (!isNone && dbServiceTypeId) { + await this.cloudInitConfigService.assertActiveConfigForPlanDefaults(dbServiceTypeId, normalizedDefaults); + await this.addonService.assertAllowedAddonIdsForPlan( + dbServiceTypeId, + parsePlanAllowedAddonIds(normalizedDefaults), + ); + } + const row = await this.servicePlansRepository.create({ - serviceTypeId: dto.serviceTypeId, + serviceTypeId: dbServiceTypeId, + tenantId: getTenantIdOrDefault(), name: dto.name, description: dto.description, billingIntervalType: dto.billingIntervalType, @@ -174,7 +207,7 @@ export class ServicePlansController { billingDayOfMonth: dto.billingDayOfMonth, cancelAtPeriodEnd: dto.cancelAtPeriodEnd ?? true, billInAdvance: dto.billInAdvance ?? false, - autoRecalculatePriceDaily: dto.autoRecalculatePriceDaily ?? false, + autoRecalculatePriceDaily: isNone ? false : (dto.autoRecalculatePriceDaily ?? false), minCommitmentDays: dto.minCommitmentDays ?? 0, noticeDays: dto.noticeDays ?? 0, basePrice: dto.basePrice, @@ -182,7 +215,7 @@ export class ServicePlansController { marginFixed: dto.marginFixed, providerConfigDefaults: normalizedDefaults ?? {}, orderingHighlights: dto.orderingHighlights ?? [], - allowCustomerLocationSelection: dto.allowCustomerLocationSelection ?? false, + allowCustomerLocationSelection: isNone ? false : (dto.allowCustomerLocationSelection ?? false), allowCustomerServerTypeSelection, allowedServerTypes, taxCategory: dto.taxCategory ?? TaxCategory.STANDARD, @@ -201,25 +234,34 @@ export class ServicePlansController { @Body() dto: UpdateServicePlanDto, ): Promise { const existing = await this.servicePlansRepository.findByIdOrThrow(id); + const isNone = !existing.serviceTypeId; - if (dto.allowCustomerLocationSelection === true) { - await this.assertAllowLocationAllowed(existing.serviceTypeId, true); + if (isNone) { + this.assertNonePlanUpdateConstraints(dto); } - if (dto.allowCustomerServerTypeSelection === true) { - await this.assertAllowServerTypeAllowed( - existing.serviceTypeId, - true, - dto.allowedServerTypes ?? existing.allowedServerTypes, - ); + if (!isNone && existing.serviceTypeId) { + if (dto.allowCustomerLocationSelection === true) { + await this.assertAllowLocationAllowed(existing.serviceTypeId, true); + } + + if (dto.allowCustomerServerTypeSelection === true) { + await this.assertAllowServerTypeAllowed( + existing.serviceTypeId, + true, + dto.allowedServerTypes ?? existing.allowedServerTypes, + ); + } } - const allowCustomerServerTypeSelection = - dto.allowCustomerServerTypeSelection !== undefined + const allowCustomerServerTypeSelection = isNone + ? false + : dto.allowCustomerServerTypeSelection !== undefined ? dto.allowCustomerServerTypeSelection === true : existing.allowCustomerServerTypeSelection === true; - const allowedServerTypes = - dto.allowedServerTypes !== undefined + const allowedServerTypes = isNone + ? [] + : dto.allowedServerTypes !== undefined ? allowCustomerServerTypeSelection ? normalizeAllowedServerTypes(dto.allowedServerTypes) : [] @@ -227,7 +269,7 @@ export class ServicePlansController { ? normalizeAllowedServerTypes(existing.allowedServerTypes) : []; - if (dto.providerConfigDefaults !== undefined) { + if (!isNone && existing.serviceTypeId && dto.providerConfigDefaults !== undefined) { const normalizedDefaults = normalizePlanProviderConfigDefaults(dto.providerConfigDefaults); await this.cloudInitConfigService.assertActiveConfigForPlanDefaults(existing.serviceTypeId, normalizedDefaults); @@ -251,7 +293,7 @@ export class ServicePlansController { ...(dto.cancelAtPeriodEnd !== undefined ? { cancelAtPeriodEnd: dto.cancelAtPeriodEnd } : {}), ...(dto.billInAdvance !== undefined ? { billInAdvance: dto.billInAdvance } : {}), ...(dto.autoRecalculatePriceDaily !== undefined - ? { autoRecalculatePriceDaily: dto.autoRecalculatePriceDaily } + ? { autoRecalculatePriceDaily: isNone ? false : dto.autoRecalculatePriceDaily } : {}), ...(dto.minCommitmentDays !== undefined ? { minCommitmentDays: dto.minCommitmentDays } : {}), ...(dto.noticeDays !== undefined ? { noticeDays: dto.noticeDays } : {}), @@ -259,16 +301,16 @@ export class ServicePlansController { ...(dto.marginPercent !== undefined ? { marginPercent: dto.marginPercent } : {}), ...(dto.marginFixed !== undefined ? { marginFixed: dto.marginFixed } : {}), ...(dto.providerConfigDefaults !== undefined - ? { providerConfigDefaults: normalizePlanProviderConfigDefaults(dto.providerConfigDefaults) } + ? { + providerConfigDefaults: isNone ? {} : normalizePlanProviderConfigDefaults(dto.providerConfigDefaults), + } : {}), ...(dto.orderingHighlights !== undefined ? { orderingHighlights: dto.orderingHighlights } : {}), ...(dto.allowCustomerLocationSelection !== undefined - ? { allowCustomerLocationSelection: dto.allowCustomerLocationSelection } - : {}), - ...(dto.allowCustomerServerTypeSelection !== undefined - ? { allowCustomerServerTypeSelection: dto.allowCustomerServerTypeSelection } + ? { allowCustomerLocationSelection: isNone ? false : dto.allowCustomerLocationSelection } : {}), - ...(dto.allowedServerTypes !== undefined || dto.allowCustomerServerTypeSelection !== undefined + ...(dto.allowCustomerServerTypeSelection !== undefined || isNone ? { allowCustomerServerTypeSelection } : {}), + ...(dto.allowedServerTypes !== undefined || dto.allowCustomerServerTypeSelection !== undefined || isNone ? { allowedServerTypes } : {}), ...(dto.taxCategory !== undefined ? { taxCategory: dto.taxCategory } : {}), @@ -306,15 +348,38 @@ export class ServicePlansController { @UsersRoles(UserRole.ADMIN) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { - await this.servicePlansRepository.delete(id); + await this.servicePlansRepository.findByIdOrThrow(id); + + const subscriptionCount = await this.subscriptionsRepository.countByPlanId(id); + + if (subscriptionCount > 0) { + throw new BadRequestException( + `Service plan is referenced by ${subscriptionCount} subscription(s) and cannot be deleted`, + ); + } + + try { + await this.servicePlansRepository.delete(id); + } catch (error: unknown) { + if (isPostgresForeignKeyViolation(error)) { + this.logger.warn(`Blocked delete of service plan ${id}: still referenced by subscriptions`); + throw new BadRequestException('Service plan is referenced by subscriptions and cannot be deleted'); + } + + throw error; + } } private async mapToResponse(row: ServicePlanEntity): Promise { - const serviceType = await this.serviceTypesRepository.findByIdOrThrow(row.serviceTypeId); + const withdrawalPolicy = row.serviceTypeId + ? this.withdrawalPolicyService.buildPolicyInfo( + await this.serviceTypesRepository.findByIdOrThrow(row.serviceTypeId), + ) + : this.withdrawalPolicyService.buildPolicyInfo({ disallowStatutoryWithdrawal: false }); return { id: row.id, - serviceTypeId: row.serviceTypeId, + serviceTypeId: toApiServiceTypeId(row.serviceTypeId), name: row.name, description: row.description, billingIntervalType: row.billingIntervalType, @@ -334,13 +399,53 @@ export class ServicePlansController { allowCustomerServerTypeSelection: row.allowCustomerServerTypeSelection === true, allowedServerTypes: normalizeAllowedServerTypes(row.allowedServerTypes), taxCategory: row.taxCategory ?? TaxCategory.STANDARD, - withdrawalPolicy: this.withdrawalPolicyService.buildPolicyInfo(serviceType), + withdrawalPolicy, isActive: row.isActive, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } + private assertNonePlanConstraints(dto: CreateServicePlanDto): void { + if (dto.allowCustomerLocationSelection === true) { + throw new BadRequestException( + 'allowCustomerLocationSelection is not supported when serviceTypeId is null (no deployment)', + ); + } + + if (dto.allowCustomerServerTypeSelection === true) { + throw new BadRequestException( + 'allowCustomerServerTypeSelection is not supported when serviceTypeId is null (no deployment)', + ); + } + + if (dto.autoRecalculatePriceDaily === true) { + throw new BadRequestException( + 'autoRecalculatePriceDaily is not supported when serviceTypeId is null (no deployment)', + ); + } + } + + private assertNonePlanUpdateConstraints(dto: UpdateServicePlanDto): void { + if (dto.allowCustomerLocationSelection === true) { + throw new BadRequestException( + 'allowCustomerLocationSelection is not supported when serviceTypeId is null (no deployment)', + ); + } + + if (dto.allowCustomerServerTypeSelection === true) { + throw new BadRequestException( + 'allowCustomerServerTypeSelection is not supported when serviceTypeId is null (no deployment)', + ); + } + + if (dto.autoRecalculatePriceDaily === true) { + throw new BadRequestException( + 'autoRecalculatePriceDaily is not supported when serviceTypeId is null (no deployment)', + ); + } + } + private async assertAllowLocationAllowed(serviceTypeId: string, allow: boolean): Promise { if (!allow) return; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-plan.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-plan.dto.ts index d92ad0603..e2ef1d8fa 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-plan.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-plan.dto.ts @@ -10,8 +10,10 @@ import { IsObject, IsOptional, IsString, + IsUUID, Max, Min, + ValidateIf, ValidateNested, } from 'class-validator'; @@ -21,9 +23,11 @@ import { BillingIntervalType } from '../entities/service-plan.entity'; import { ServicePlanOrderingHighlightDto } from './service-plan-ordering-highlight.dto'; export class CreateServicePlanDto { - @IsNotEmpty({ message: 'Service type ID is required' }) - @IsString({ message: 'Service type ID must be a string' }) - serviceTypeId!: string; + /** UUID of a catalog service type, or null/omitted for no deployment. */ + @IsOptional() + @ValidateIf((_, value) => value != null && value !== '') + @IsUUID('4', { message: 'Service type ID must be a UUID or null' }) + serviceTypeId?: string | null; @IsNotEmpty({ message: 'Name is required' }) @IsString({ message: 'Name must be a string' }) diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/public-service-plan-offering.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/public-service-plan-offering.dto.ts index 4b6209c66..873ed2e70 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/public-service-plan-offering.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/public-service-plan-offering.dto.ts @@ -7,7 +7,7 @@ export class PublicServicePlanOfferingDto { id!: string; name!: string; description!: string | null; - serviceTypeId!: string; + serviceTypeId!: string | null; serviceTypeName!: string; billingIntervalType!: BillingIntervalType; billingIntervalValue!: number; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts index a08c170b3..8e549cbf7 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-plan-response.dto.ts @@ -5,7 +5,7 @@ import { WithdrawalPolicyDto } from './withdrawal-policy.dto'; export class ServicePlanResponseDto { id!: string; - serviceTypeId!: string; + serviceTypeId!: string | null; name!: string; description?: string; billingIntervalType!: BillingIntervalType; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-item-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-item-response.dto.ts index d316a9fc7..b85bd588a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-item-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/subscription-item-response.dto.ts @@ -4,7 +4,7 @@ export interface SubscriptionItemResponseDto { id: string; subscriptionId: string; - serviceTypeId: string; + serviceTypeId: string | null; /** User-facing service type name from the catalog (billing_service_types.name). */ serviceTypeName: string; provisioningStatus: 'pending' | 'active' | 'failed'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan.entity.ts index 88f8142f4..822238834 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan.entity.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-plan.entity.ts @@ -30,12 +30,17 @@ export class ServicePlanEntity { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; - @Column({ type: 'uuid', name: 'service_type_id' }) - serviceTypeId!: string; + /** Null when the plan has no service type and deploys nothing. */ + @Column({ type: 'uuid', name: 'service_type_id', nullable: true }) + serviceTypeId!: string | null; - @ManyToOne(() => ServiceTypeEntity, { onDelete: 'CASCADE' }) + @ManyToOne(() => ServiceTypeEntity, { onDelete: 'CASCADE', nullable: true }) @JoinColumn({ name: 'service_type_id' }) - serviceType?: ServiceTypeEntity; + serviceType?: ServiceTypeEntity | null; + + /** Tenant ownership (required when serviceTypeId is null; otherwise mirrors the type). */ + @Column({ type: 'varchar', length: 64, name: 'tenant_id', default: 'default' }) + tenantId!: string; @Column({ type: 'varchar', length: 255, name: 'name' }) name!: string; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/subscription-item.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/subscription-item.entity.ts index bd3610ad7..c70a2100e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/subscription-item.entity.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/subscription-item.entity.ts @@ -30,12 +30,13 @@ export class SubscriptionItemEntity { @JoinColumn({ name: 'subscription_id' }) subscription?: SubscriptionEntity; - @Column({ type: 'uuid', name: 'service_type_id' }) - serviceTypeId!: string; + /** Null when the parent plan has no service type. */ + @Column({ type: 'uuid', name: 'service_type_id', nullable: true }) + serviceTypeId!: string | null; - @ManyToOne(() => ServiceTypeEntity, { onDelete: 'RESTRICT' }) + @ManyToOne(() => ServiceTypeEntity, { onDelete: 'RESTRICT', nullable: true }) @JoinColumn({ name: 'service_type_id' }) - serviceType?: ServiceTypeEntity; + serviceType?: ServiceTypeEntity | null; /** Plan/config snapshot; encrypted at rest via AES-256-GCM. */ @Column({ diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.spec.ts index 4cade2027..29ac7c9e7 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.spec.ts @@ -6,14 +6,14 @@ import { ServicePlansRepository } from './service-plans.repository'; describe('ServicePlansRepository', () => { const mockGetOne = jest.fn(); const mockGetMany = jest.fn(); - const mockInnerJoinAndSelect = jest.fn().mockReturnThis(); + const mockLeftJoinAndSelect = jest.fn().mockReturnThis(); const mockWhere = jest.fn().mockReturnThis(); const mockAndWhere = jest.fn().mockReturnThis(); const mockOrderBy = jest.fn().mockReturnThis(); const mockTake = jest.fn().mockReturnThis(); const mockSkip = jest.fn().mockReturnThis(); const createQueryBuilderReturn = { - innerJoinAndSelect: mockInnerJoinAndSelect, + leftJoinAndSelect: mockLeftJoinAndSelect, where: mockWhere, andWhere: mockAndWhere, orderBy: mockOrderBy, @@ -32,12 +32,12 @@ describe('ServicePlansRepository', () => { beforeEach(() => { jest.clearAllMocks(); mockRepository = { - create: jest.fn(), + create: jest.fn((dto) => dto), save: jest.fn(), remove: jest.fn(), createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderReturn), }; - mockInnerJoinAndSelect.mockReturnThis(); + mockLeftJoinAndSelect.mockReturnThis(); mockWhere.mockReturnThis(); mockAndWhere.mockReturnThis(); mockOrderBy.mockReturnThis(); @@ -45,7 +45,7 @@ describe('ServicePlansRepository', () => { mockSkip.mockReturnThis(); }); - it('findById scopes query to tenant service type', async () => { + it('findById scopes query to plan tenant_id', async () => { const plan = { id: 'plan-1' }; mockGetOne.mockResolvedValue(plan); @@ -53,7 +53,8 @@ describe('ServicePlansRepository', () => { const repository = new ServicePlansRepository(mockRepository as never); const result = await runWithTenantId('default', () => repository.findById('plan-1')); - expect(mockAndWhere).toHaveBeenCalledWith('st.tenant_id = :tenantId', { tenantId: 'default' }); + expect(mockLeftJoinAndSelect).toHaveBeenCalledWith('plan.serviceType', 'st'); + expect(mockAndWhere).toHaveBeenCalledWith('plan.tenant_id = :tenantId', { tenantId: 'default' }); expect(result).toEqual(plan); }); @@ -67,7 +68,7 @@ describe('ServicePlansRepository', () => { ); }); - it('findAll applies tenant filter and pagination', async () => { + it('findAll applies plan tenant filter and pagination', async () => { mockGetMany.mockResolvedValue([{ id: 'plan-1' }]); const repository = new ServicePlansRepository(mockRepository as never); @@ -75,7 +76,7 @@ describe('ServicePlansRepository', () => { expect(mockTake).toHaveBeenCalledWith(5); expect(mockSkip).toHaveBeenCalledWith(10); - expect(mockAndWhere).toHaveBeenCalledWith('st.tenant_id = :tenantId', { tenantId: 'acme' }); + expect(mockAndWhere).toHaveBeenCalledWith('plan.tenant_id = :tenantId', { tenantId: 'acme' }); expect(result).toHaveLength(1); }); @@ -88,6 +89,29 @@ describe('ServicePlansRepository', () => { expect(mockAndWhere).toHaveBeenCalledWith('plan.service_type_id = :serviceTypeId', { serviceTypeId: 'type-1' }); }); + it('findActiveWithServiceType filters null service type for empty sentinel', async () => { + mockGetMany.mockResolvedValue([]); + + const repository = new ServicePlansRepository(mockRepository as never); + await runWithTenantId('default', () => repository.findActiveWithServiceType(10, 0, '')); + + expect(mockAndWhere).toHaveBeenCalledWith('plan.service_type_id IS NULL'); + }); + + it('create sets tenantId from context when omitted', async () => { + mockRepository.save.mockImplementation(async (entity) => entity); + + const repository = new ServicePlansRepository(mockRepository as never); + const result = await runWithTenantId('acme', () => + repository.create({ name: 'Billing only', serviceTypeId: null }), + ); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Billing only', serviceTypeId: null, tenantId: 'acme' }), + ); + expect(result).toEqual(expect.objectContaining({ tenantId: 'acme' })); + }); + it('update saves tenant-scoped plan changes', async () => { const existing = { id: 'plan-1', name: 'Old' }; const updated = { ...existing, name: 'New' }; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts index 8135cf672..d3205e8f8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-plans.repository.ts @@ -1,9 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsWhere, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; +import { isNoneServiceTypeId } from '../constants/service-type-id.constants'; import { ServicePlanEntity } from '../entities/service-plan.entity'; -import { applyServiceTypeTenantFilter, getRequiredTenantId } from '../utils/tenant-query.utils'; +import { applyServicePlanTenantFilter, getRequiredTenantId } from '../utils/tenant-query.utils'; @Injectable() export class ServicePlansRepository { @@ -15,9 +16,9 @@ export class ServicePlansRepository { async findByIdOrThrow(id: string): Promise { const entity = await this.repository .createQueryBuilder('plan') - .innerJoinAndSelect('plan.serviceType', 'st') + .leftJoinAndSelect('plan.serviceType', 'st') .where('plan.id = :id', { id }) - .andWhere('st.tenant_id = :tenantId', { tenantId: getRequiredTenantId() }) + .andWhere('plan.tenant_id = :tenantId', { tenantId: getRequiredTenantId() }) .getOne(); if (!entity) { @@ -30,65 +31,56 @@ export class ServicePlansRepository { async findById(id: string): Promise { return await this.repository .createQueryBuilder('plan') - .innerJoinAndSelect('plan.serviceType', 'st') + .leftJoinAndSelect('plan.serviceType', 'st') .where('plan.id = :id', { id }) - .andWhere('st.tenant_id = :tenantId', { tenantId: getRequiredTenantId() }) + .andWhere('plan.tenant_id = :tenantId', { tenantId: getRequiredTenantId() }) .getOne(); } async findAll(limit = 10, offset = 0): Promise { const qb = this.repository .createQueryBuilder('plan') - .innerJoinAndSelect('plan.serviceType', 'st') + .leftJoinAndSelect('plan.serviceType', 'st') .orderBy('plan.createdAt', 'DESC') .take(limit) .skip(offset); - applyServiceTypeTenantFilter(qb, 'st'); + applyServicePlanTenantFilter(qb, 'plan'); return await qb.getMany(); } /** - * Active plans with service type relation for public catalog (no inactive or config filtering here). + * Active plans with optional service type relation for public catalog. + * When filtering by blank serviceTypeId, returns plans with a null service_type_id. */ async findActiveWithServiceType(limit: number, offset: number, serviceTypeId?: string): Promise { const qb = this.repository .createQueryBuilder('plan') - .innerJoinAndSelect('plan.serviceType', 'st') + .leftJoinAndSelect('plan.serviceType', 'st') .where('plan.is_active = :isActive', { isActive: true }) .orderBy('plan.createdAt', 'DESC') .take(limit) .skip(offset); - applyServiceTypeTenantFilter(qb, 'st'); - - const trimmedTypeId = serviceTypeId?.trim(); - - if (trimmedTypeId) { - qb.andWhere('plan.service_type_id = :serviceTypeId', { serviceTypeId: trimmedTypeId }); - } + applyServicePlanTenantFilter(qb, 'plan'); + this.applyServiceTypeIdFilter(qb, serviceTypeId); return await qb.getMany(); } /** - * All active plans with service type (no pagination). Used to pick lowest customer price in application code. + * All active plans with optional service type (no pagination). */ async findAllActiveWithServiceType(serviceTypeId?: string): Promise { const qb = this.repository .createQueryBuilder('plan') - .innerJoinAndSelect('plan.serviceType', 'st') + .leftJoinAndSelect('plan.serviceType', 'st') .where('plan.is_active = :isActive', { isActive: true }) .orderBy('plan.id', 'ASC'); - applyServiceTypeTenantFilter(qb, 'st'); - - const trimmedTypeId = serviceTypeId?.trim(); - - if (trimmedTypeId) { - qb.andWhere('plan.service_type_id = :serviceTypeId', { serviceTypeId: trimmedTypeId }); - } + applyServicePlanTenantFilter(qb, 'plan'); + this.applyServiceTypeIdFilter(qb, serviceTypeId); return await qb.getMany(); } @@ -96,17 +88,21 @@ export class ServicePlansRepository { async findAutoRecalculatePriceDaily(): Promise { const qb = this.repository .createQueryBuilder('plan') - .innerJoinAndSelect('plan.serviceType', 'st') + .leftJoinAndSelect('plan.serviceType', 'st') .where('plan.auto_recalculate_price_daily = :enabled', { enabled: true }) + .andWhere('plan.service_type_id IS NOT NULL') .orderBy('plan.createdAt', 'ASC'); - applyServiceTypeTenantFilter(qb, 'st'); + applyServicePlanTenantFilter(qb, 'plan'); return await qb.getMany(); } async create(dto: Partial): Promise { - const entity = this.repository.create(dto); + const entity = this.repository.create({ + ...dto, + tenantId: dto.tenantId ?? getRequiredTenantId(), + }); return await this.repository.save(entity); } @@ -124,4 +120,24 @@ export class ServicePlansRepository { await this.repository.remove(entity); } + + private applyServiceTypeIdFilter( + qb: ReturnType['createQueryBuilder']>, + serviceTypeId?: string, + ): void { + // Omitted query param → no filter. Blank string → none plans (NULL service_type_id). + if (serviceTypeId === undefined) { + return; + } + + if (isNoneServiceTypeId(serviceTypeId)) { + qb.andWhere('plan.service_type_id IS NULL'); + + return; + } + + const trimmedTypeId = serviceTypeId.trim(); + + qb.andWhere('plan.service_type_id = :serviceTypeId', { serviceTypeId: trimmedTypeId }); + } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts index 172a20807..d6ff7face 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts @@ -234,6 +234,11 @@ export class SubscriptionsRepository { return await qb.getCount(); } + /** All subscriptions referencing a plan (used to block plan delete). */ + async countByPlanId(planId: string): Promise { + return await this.repository.count({ where: { planId } }); + } + async findUpcomingRenewals(withinDays: number, now: Date = new Date(), limit = 100): Promise { const futureDate = new Date(now); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.spec.ts index ada110238..5c46e07c9 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.spec.ts @@ -92,6 +92,25 @@ describe('SubscriptionItemServerService', () => { expect(items[0]?.serviceTypeName).toBe(''); }); + + it('maps null serviceTypeId as null', async () => { + subscriptionItemsRepository.findBySubscription.mockResolvedValue([ + { + id: 'item-1', + subscriptionId: 'sub-1', + serviceTypeId: null, + provisioningStatus: ProvisioningStatus.ACTIVE, + hostname: undefined, + configSnapshot: {}, + }, + ]); + + const items = await service.listItems('sub-1', 'user-1'); + + expect(items[0]?.serviceTypeId).toBeNull(); + expect(items[0]?.serviceTypeName).toBe(''); + expect(items[0]?.service).toBeUndefined(); + }); }); describe('getSshAccessKey', () => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.ts index 4bd649e76..cd5db0a2b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-server.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { toApiServiceTypeId } from '../constants/service-type-id.constants'; import { SubscriptionItemResponseDto, SubscriptionSshAccessKeyResponseDto, @@ -162,23 +163,26 @@ export class SubscriptionItemServerService { private toItemResponse(item: { id: string; subscriptionId: string; - serviceTypeId: string; + serviceTypeId: string | null; serviceType?: { name?: string } | null; provisioningStatus: ProvisioningStatus; hostname?: string; configSnapshot?: Record; sshAccessGrantedAt?: Date | null; }): SubscriptionItemResponseDto { - const service = normalizeCloudInitService(item.configSnapshot?.service as string | undefined); + const hasServiceType = item.serviceTypeId != null; + const service = hasServiceType + ? normalizeCloudInitService(item.configSnapshot?.service as string | undefined) + : undefined; return { id: item.id, subscriptionId: item.subscriptionId, - serviceTypeId: item.serviceTypeId, + serviceTypeId: toApiServiceTypeId(item.serviceTypeId), serviceTypeName: item.serviceType?.name?.trim() || '', provisioningStatus: item.provisioningStatus, hostname: item.hostname, - service, + ...(service ? { service } : {}), sshAccessGranted: item.sshAccessGrantedAt != null, }; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts index c4f941a86..844356655 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts @@ -126,6 +126,18 @@ export class SubscriptionService { } const plan = await this.servicePlansRepository.findByIdOrThrow(planId); + + if (!plan.serviceTypeId) { + return this.createSubscriptionWithoutServiceType( + userId, + plan, + autoBackorder, + promotionCode, + promotionBenefitStartsAt, + addonIds, + ); + } + const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); const selectedAddonIds = [...new Set((addonIds ?? []).filter(Boolean))]; @@ -341,6 +353,90 @@ export class SubscriptionService { return created; } + /** + * Billing-only plans with no service type: no cloud config, availability, or provisioning. + * Creates the subscription and an immediately active item with null serviceTypeId. + */ + private async createSubscriptionWithoutServiceType( + userId: string, + plan: ServicePlanEntity, + autoBackorder: boolean, + promotionCode?: string, + promotionBenefitStartsAt?: string, + addonIds?: string[], + ) { + const selectedAddonIds = [...new Set((addonIds ?? []).filter(Boolean))]; + + if (selectedAddonIds.length > 0) { + throw new BadRequestException('Addons are not supported for plans without a service type'); + } + + if (autoBackorder) { + throw new BadRequestException('Backorders are not supported for plans without a service type'); + } + + const schedule = this.billingScheduleService.calculateSchedule( + plan.billingIntervalType as BillingIntervalType, + plan.billingIntervalValue, + plan.billingDayOfMonth, + ); + const subscription = await this.subscriptionsRepository.create({ + userId, + planId: plan.id, + status: SubscriptionStatus.ACTIVE, + autoBackorder: false, + currentPeriodStart: schedule.currentPeriodStart, + currentPeriodEnd: schedule.currentPeriodEnd, + nextBillingAt: schedule.nextBillingAt, + }); + + const item = await this.subscriptionItemsRepository.create({ + subscriptionId: subscription.id, + serviceTypeId: null, + configSnapshot: {}, + }); + + await this.subscriptionItemsRepository.updateProvisioningStatus(item.id, 'active'); + + if (promotionCode?.trim()) { + try { + await this.promotionRedemptionService.redeem( + userId, + promotionCode.trim(), + subscription.id, + PromotionRedemptionContext.NEW, + { benefitStartsAt: promotionBenefitStartsAt }, + ); + } catch (error) { + await this.subscriptionsRepository.delete(subscription.id); + throw error; + } + } + + const created = await this.subscriptionsRepository.findByIdOrThrow(subscription.id); + + if (plan.billInAdvance === true) { + await this.subscriptionPeriodChargeService.recordOpenPositionForPeriod( + created, + plan, + schedule.currentPeriodEnd, + schedule.currentPeriodStart, + schedule.currentPeriodEnd, + ); + } + + this.billingNotificationPublisher.publishSubscription('subscription.created', created, plan, { + addons: [], + }); + await this.billingEmailPublisher.publishSubscriptionCreated(created, plan.name, { + billInAdvance: plan.billInAdvance === true, + addons: [], + }); + this.customerTrustScoreService.triggerRecomputeForUser(created.userId); + + return created; + } + /** * Provisions the server for a pending subscription item. Invoked asynchronously by the * provisioning coordinator/unit jobs. Idempotent and self-guarding: it skips items that are @@ -707,7 +803,9 @@ export class SubscriptionService { ): Promise<{ subscription: SubscriptionEntity; withdrawalResult?: WithdrawalResultDto }> { const subscription = await this.subscriptionsRepository.findByIdOrThrow(subscriptionId); const plan = await this.servicePlansRepository.findByIdOrThrow(subscription.planId); - const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); + const serviceType = plan.serviceTypeId + ? await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId) + : { disallowStatutoryWithdrawal: false }; const items = await this.subscriptionItemsRepository.findBySubscription(subscriptionId); const decision = this.withdrawalPolicyService.evaluate({ subscriptionStatus: subscription.status, @@ -796,7 +894,7 @@ export class SubscriptionService { async mapToResponse( subscription: SubscriptionEntity, items = [] as Awaited>, - serviceType?: Awaited>, + serviceType?: { disallowStatutoryWithdrawal: boolean }, withdrawalResult?: WithdrawalResultDto, plan?: ServicePlanEntity, ): Promise { @@ -883,11 +981,16 @@ export class SubscriptionService { const planIds = [...new Set(subscriptions.map((s) => s.planId))]; const plansByPlanId = new Map(); - const serviceTypesByPlan = new Map>>(); + const serviceTypesByPlan = new Map< + string, + { disallowStatutoryWithdrawal: boolean } | Awaited> + >(); for (const planId of planIds) { const plan = await this.servicePlansRepository.findByIdOrThrow(planId); - const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); + const serviceType = plan.serviceTypeId + ? await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId) + : { disallowStatutoryWithdrawal: false }; plansByPlanId.set(planId, plan); serviceTypesByPlan.set(planId, serviceType); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.spec.ts new file mode 100644 index 000000000..522e3cfff --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.spec.ts @@ -0,0 +1,16 @@ +import { QueryFailedError } from 'typeorm'; + +import { isPostgresForeignKeyViolation } from './postgres-foreign-key-violation.util'; + +describe('isPostgresForeignKeyViolation', () => { + it('returns true for PostgreSQL 23503', () => { + const error = new QueryFailedError('DELETE', [], { code: '23503' } as never); + + expect(isPostgresForeignKeyViolation(error)).toBe(true); + }); + + it('returns false for other errors', () => { + expect(isPostgresForeignKeyViolation(new Error('boom'))).toBe(false); + expect(isPostgresForeignKeyViolation(new QueryFailedError('DELETE', [], { code: '23505' } as never))).toBe(false); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.ts new file mode 100644 index 000000000..d597515ee --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/postgres-foreign-key-violation.util.ts @@ -0,0 +1,12 @@ +import { QueryFailedError } from 'typeorm'; + +/** PostgreSQL foreign_key_violation. */ +export function isPostgresForeignKeyViolation(error: unknown): boolean { + if (!(error instanceof QueryFailedError)) { + return false; + } + + const driverError = error.driverError as { code?: string } | undefined; + + return driverError?.code === '23503'; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/tenant-query.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/tenant-query.utils.ts index 1933e1838..9b0647414 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/tenant-query.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/tenant-query.utils.ts @@ -13,6 +13,11 @@ export function applyServiceTypeTenantFilter(qb: SelectQueryBuilder, alias return qb.andWhere(`${alias}.tenant_id = :tenantId`, { tenantId: getRequiredTenantId() }); } +/** Filter service plans by their own tenant_id (supports plans with no service type). */ +export function applyServicePlanTenantFilter(qb: SelectQueryBuilder, alias = 'plan'): SelectQueryBuilder { + return qb.andWhere(`${alias}.tenant_id = :tenantId`, { tenantId: getRequiredTenantId() }); +} + export function applyPromotionTenantFilter(qb: SelectQueryBuilder, alias = 'promotion'): SelectQueryBuilder { return qb.andWhere(`${alias}.tenant_id = :tenantId`, { tenantId: getRequiredTenantId() }); } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts index 671892422..53a80a327 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts @@ -18,6 +18,7 @@ export * from './lib/utils/config-change-error.utils'; // Constants export * from './lib/constants/supported-countries'; +export * from './lib/constants/service-type-id.constants'; // Services export * from './lib/services/service-types.service'; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.spec.ts new file mode 100644 index 000000000..643d302b0 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.spec.ts @@ -0,0 +1,12 @@ +import { isNoneServiceTypeId } from './service-type-id.constants'; + +describe('service-type-id.constants (frontend)', () => { + it('detects null/undefined/blank as none', () => { + expect(isNoneServiceTypeId(null)).toBe(true); + expect(isNoneServiceTypeId(undefined)).toBe(true); + expect(isNoneServiceTypeId('')).toBe(true); + expect(isNoneServiceTypeId(' ')).toBe(true); + expect(isNoneServiceTypeId('none')).toBe(false); + expect(isNoneServiceTypeId('22222222-2222-4222-8222-222222222222')).toBe(false); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.ts new file mode 100644 index 000000000..63e7284f9 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/constants/service-type-id.constants.ts @@ -0,0 +1,7 @@ +/** + * True when there is no service type (null / undefined / blank). + * Matches backend `isNoneServiceTypeId` for billing-only plans. + */ +export function isNoneServiceTypeId(value: string | null | undefined): boolean { + return value == null || value.trim() === ''; +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts index fa4e30578..6f0c6284e 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts @@ -309,7 +309,7 @@ export interface ServicePlanOrderingHighlight { export interface ServicePlanResponse { id: string; - serviceTypeId: string; + serviceTypeId: string | null; name: string; description?: string | null; billingIntervalType: BillingIntervalType; @@ -338,7 +338,8 @@ export interface ServicePlanResponse { } export interface CreateServicePlanDto { - serviceTypeId: string; + /** UUID of a service type, or null for no deployment. */ + serviceTypeId?: string | null; name: string; description?: string; billingIntervalType: BillingIntervalType; @@ -490,7 +491,7 @@ export type ProvisioningStatus = 'pending' | 'active' | 'failed'; export interface SubscriptionItemResponse { id: string; subscriptionId: string; - serviceTypeId: string; + serviceTypeId: string | null; /** User-facing service type name from the catalog. */ serviceTypeName?: string; provisioningStatus: ProvisioningStatus; diff --git a/libs/domains/decabill/frontend/data-access-portal/src/lib/types/portal-service-plans.types.ts b/libs/domains/decabill/frontend/data-access-portal/src/lib/types/portal-service-plans.types.ts index 132b107cd..a143cca7f 100644 --- a/libs/domains/decabill/frontend/data-access-portal/src/lib/types/portal-service-plans.types.ts +++ b/libs/domains/decabill/frontend/data-access-portal/src/lib/types/portal-service-plans.types.ts @@ -18,7 +18,7 @@ export interface PublicServicePlanOffering { id: string; name: string; description: string | null; - serviceTypeId: string; + serviceTypeId: string | null; serviceTypeName: string; billingIntervalType: BillingIntervalType; billingIntervalValue: number; diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-promotions-page/admin-promotions-page.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-promotions-page/admin-promotions-page.component.ts index 545802ec0..ff54098d3 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-promotions-page/admin-promotions-page.component.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-promotions-page/admin-promotions-page.component.ts @@ -234,14 +234,14 @@ export class AdminPromotionsPageComponent implements OnInit { planLabel( planId: string, - plans: { id: string; name: string; serviceTypeId: string }[], + plans: { id: string; name: string; serviceTypeId: string | null }[], types: { id: string; name: string }[], ): string { const plan = plans.find((item) => item.id === planId); if (!plan) return planId; - const typeName = types.find((item) => item.id === plan.serviceTypeId)?.name ?? ''; + const typeName = plan.serviceTypeId ? (types.find((item) => item.id === plan.serviceTypeId)?.name ?? '') : ''; return typeName ? `${plan.name} (${typeName})` : plan.name; } diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/service-plans-page/service-plans-page.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/service-plans-page/service-plans-page.component.html index f806cc420..4fb2f4e40 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/service-plans-page/service-plans-page.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/service-plans-page/service-plans-page.component.html @@ -151,12 +151,11 @@
Customer-selectable addons< Cancellation always takes effect at the end of the already-billed period. -
- - -
- Opt in only when required for EU compliance. When enabled, the package price is recalculated nightly at - midnight from the provider catalog. Checkout must disclose this and the statutory withdrawal restart - when the price changes. + @if (!isNoneServiceType(createForm.serviceTypeId)) { +
+ + +
+ Opt in only when required for EU compliance. When enabled, the package price is recalculated nightly + at midnight from the provider catalog. Checkout must disclose this and the statutory withdrawal + restart when the price changes. +
-
+ }
Customer-selectable addons<
-
- - -
- Opt in only when required for EU compliance. When enabled, the package price is recalculated nightly at - midnight from the provider catalog. Checkout must disclose this and the statutory withdrawal restart - when the price changes. + @if (editingPlan && !isNoneServiceType(editingPlan.serviceTypeId)) { +
+ + +
+ Opt in only when required for EU compliance. When enabled, the package price is recalculated nightly + at midnight from the provider catalog. Checkout must disclose this and the statutory withdrawal + restart when the price changes. +
-
+ }
{ - const typeName = serviceTypes.find((type) => type.id === plan.serviceTypeId)?.name ?? ''; + const typeName = isNoneServiceTypeId(plan.serviceTypeId) + ? this.noneServiceTypeLabel + : (serviceTypes.find((type) => type.id === plan.serviceTypeId)?.name ?? ''); return JSON.stringify(plan).toLowerCase().includes(term) || typeName.toLowerCase().includes(term); }); @@ -155,7 +159,11 @@ export class ServicePlansPageComponent implements OnInit { providerLocationCatalog: ProviderLocationCatalog = new Map(); providerLocationsLoading = false; - serviceTypeNameById(types: ServiceTypeResponse[] | null, id: string): string { + serviceTypeNameById(types: ServiceTypeResponse[] | null, id: string | null | undefined): string { + if (isNoneServiceTypeId(id)) { + return this.noneServiceTypeLabel; + } + if (!types) return getUnavailableLabel(); const serviceType = types.find((item) => item.id === id); @@ -163,6 +171,10 @@ export class ServicePlansPageComponent implements OnInit { return serviceType?.name?.trim() || getUnavailableLabel(); } + isNoneServiceType(serviceTypeId: string | null | undefined): boolean { + return isNoneServiceTypeId(serviceTypeId); + } + billingIntervalLabel(plan: ServicePlanResponse): string { return getBillingIntervalLabel(plan.billingIntervalValue, plan.billingIntervalType); } @@ -178,7 +190,7 @@ export class ServicePlansPageComponent implements OnInit { private applyDefaultProvisioningOptionKeys( serviceTypes: ServiceTypeResponse[], providerDetails: ProviderDetail[], - serviceTypeId: string, + serviceTypeId: string | null | undefined, form: 'create' | 'edit', ): void { if (!this.supportsProvisioningOptionsSelection(serviceTypes, providerDetails, serviceTypeId)) { @@ -209,7 +221,7 @@ export class ServicePlansPageComponent implements OnInit { private pruneInvalidProvisioningOptionKeys( serviceTypes: ServiceTypeResponse[], providerDetails: ProviderDetail[], - serviceTypeId: string, + serviceTypeId: string | null | undefined, form: 'create' | 'edit', ): void { const target = form === 'create' ? this.createProvisioningOptionKeys : this.editProvisioningOptionKeys; @@ -267,7 +279,7 @@ export class ServicePlansPageComponent implements OnInit { supportsProvisioningOptionsSelection( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): boolean { const schema = this.getProviderSchema(serviceTypes, providerDetails, serviceTypeId); const serviceEnum = this.getProviderConfigEnum(schema, 'service'); @@ -290,7 +302,7 @@ export class ServicePlansPageComponent implements OnInit { serviceEnumIncludes( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, value: string, ): boolean { const schema = this.getProviderSchema(serviceTypes, providerDetails, serviceTypeId); @@ -334,7 +346,7 @@ export class ServicePlansPageComponent implements OnInit { addons: AddonResponse[] | null, serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): AddonResponse[] { if (!this.providerSupportsAddons(serviceTypes, providerDetails, serviceTypeId)) return []; @@ -350,7 +362,7 @@ export class ServicePlansPageComponent implements OnInit { providerSupportsAddons( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): boolean { const providerId = this.getProviderId(serviceTypes ?? [], serviceTypeId); @@ -421,7 +433,7 @@ export class ServicePlansPageComponent implements OnInit { getProviderSchema( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): ConfigSchemaProperties | null { if (!serviceTypeId?.trim() || !serviceTypes?.length || !providerDetails?.length) return null; @@ -442,7 +454,7 @@ export class ServicePlansPageComponent implements OnInit { supportsCustomerLocationSelection( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): boolean { const full = this.getProviderSchemaFull(serviceTypes, providerDetails, serviceTypeId); const props = full?.['properties'] as ConfigSchemaProperties | undefined; @@ -467,7 +479,7 @@ export class ServicePlansPageComponent implements OnInit { supportsCustomerServerTypeSelection( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): boolean { return this.getBasePriceFromField(serviceTypes, providerDetails, serviceTypeId) === 'serverType'; } @@ -475,7 +487,7 @@ export class ServicePlansPageComponent implements OnInit { getProviderSchemaFull( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): Record | null { if (!serviceTypeId?.trim() || !serviceTypes?.length || !providerDetails?.length) return null; @@ -492,7 +504,7 @@ export class ServicePlansPageComponent implements OnInit { getBasePriceFromField( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): string | null { const schema = this.getProviderSchemaFull(serviceTypes, providerDetails, serviceTypeId); const field = schema?.['basePriceFromField']; @@ -501,7 +513,7 @@ export class ServicePlansPageComponent implements OnInit { } /** Provider id for the given service type. */ - getProviderId(serviceTypes: ServiceTypeResponse[] | null, serviceTypeId: string): string | null { + getProviderId(serviceTypes: ServiceTypeResponse[] | null, serviceTypeId: string | null | undefined): string | null { if (!serviceTypeId?.trim() || !serviceTypes?.length) return null; const st = serviceTypes.find((s) => s.id === serviceTypeId); @@ -750,6 +762,19 @@ export class ServicePlansPageComponent implements OnInit { /** When create form service type changes, init providerConfigDefaults from schema and load server types if needed. */ onCreateServiceTypeIdChange(serviceTypes: ServiceTypeResponse[], providerDetails: ProviderDetail[]): void { + if (isNoneServiceTypeId(this.createForm.serviceTypeId)) { + this.createForm.providerConfigDefaults = {}; + this.createForm.allowCustomerLocationSelection = false; + this.createForm.allowCustomerServerTypeSelection = false; + this.createForm.autoRecalculatePriceDaily = false; + this.createAllowedServerTypes = []; + this.createProvisioningOptionKeys.clear(); + this.currentServerTypes = []; + this.providerLocationCatalog = new Map(); + + return; + } + const schema = this.getProviderSchema(serviceTypes, providerDetails, this.createForm.serviceTypeId); this.createForm.providerConfigDefaults = this.createForm.providerConfigDefaults ?? {}; @@ -824,10 +849,10 @@ export class ServicePlansPageComponent implements OnInit { return Boolean(this.getProviderConfigEnum(schema, 'location') ?? this.getProviderConfigEnum(schema, 'region')); } - private loadProviderLocations(providerId: string, serviceTypeId?: string): void { + private loadProviderLocations(providerId: string, serviceTypeId?: string | null): void { this.providerLocationsLoading = true; this.providerLocationCatalog = new Map(); - this.serviceTypesService.getProviderLocations(providerId, serviceTypeId).subscribe({ + this.serviceTypesService.getProviderLocations(providerId, serviceTypeId ?? undefined).subscribe({ next: (locations: ProviderLocation[]) => { this.providerLocationCatalog = providerLocationCatalogFromList(locations); this.providerLocationsLoading = false; @@ -839,10 +864,10 @@ export class ServicePlansPageComponent implements OnInit { }); } - private loadServerTypes(providerId: string, serviceTypeId?: string): void { + private loadServerTypes(providerId: string, serviceTypeId?: string | null): void { this.serverTypesLoading = true; this.currentServerTypes = []; - this.serviceTypesService.getProviderServerTypes(providerId, serviceTypeId).subscribe({ + this.serviceTypesService.getProviderServerTypes(providerId, serviceTypeId ?? undefined).subscribe({ next: (list) => { this.currentServerTypes = list; this.serverTypesLoading = false; @@ -1099,7 +1124,7 @@ export class ServicePlansPageComponent implements OnInit { private getDefaultCreateForm(): CreateServicePlanDto { return { - serviceTypeId: '', + serviceTypeId: null, name: '', description: '', billingIntervalType: 'month', @@ -1251,27 +1276,32 @@ export class ServicePlansPageComponent implements OnInit { } onSubmitCreate(): void { - if (!this.createForm.serviceTypeId?.trim() || !this.createForm.name?.trim()) return; + // Null serviceTypeId means no deployment; only name is required. + if (!this.createForm.name?.trim()) return; + + const isNone = isNoneServiceTypeId(this.createForm.serviceTypeId); + const serviceTypeId = isNone ? null : this.createForm.serviceTypeId!.trim(); this.typesAndProviders$.pipe(take(1)).subscribe(({ serviceTypes, providerDetails }) => { - this.pruneInvalidProvisioningOptionKeys( - serviceTypes, - providerDetails, - this.createForm.serviceTypeId.trim(), - 'create', - ); + if (!isNone && serviceTypeId) { + this.pruneInvalidProvisioningOptionKeys(serviceTypes, providerDetails, serviceTypeId, 'create'); + } this.cloudInitConfigs$.pipe(take(1)).subscribe((cloudInitConfigs) => { - this.pruneInactiveCustomProvisioningOptionKeys(cloudInitConfigs, 'create'); + if (!isNone) { + this.pruneInactiveCustomProvisioningOptionKeys(cloudInitConfigs, 'create'); + } - const providerConfigDefaults = this.buildProviderConfigDefaultsForSubmit( - this.createForm.providerConfigDefaults, - this.createProvisioningOptionKeys, - ); + const providerConfigDefaults = isNone + ? {} + : this.buildProviderConfigDefaultsForSubmit( + this.createForm.providerConfigDefaults, + this.createProvisioningOptionKeys, + ); const orderingHighlights = this.sanitizeOrderingHighlights(this.createForm.orderingHighlights); this.plansFacade.createServicePlan({ - serviceTypeId: this.createForm.serviceTypeId.trim(), + serviceTypeId, name: this.createForm.name.trim(), description: this.createForm.description?.trim() || undefined, billingIntervalType: this.createForm.billingIntervalType, @@ -1280,7 +1310,7 @@ export class ServicePlansPageComponent implements OnInit { this.createForm.billingDayOfMonth != null ? Number(this.createForm.billingDayOfMonth) : undefined, cancelAtPeriodEnd: this.createForm.cancelAtPeriodEnd ?? true, billInAdvance: this.createForm.billInAdvance === true, - autoRecalculatePriceDaily: this.createForm.autoRecalculatePriceDaily === true, + autoRecalculatePriceDaily: isNone ? false : this.createForm.autoRecalculatePriceDaily === true, minCommitmentDays: Number(this.createForm.minCommitmentDays) || 0, noticeDays: Number(this.createForm.noticeDays) || 0, basePrice: this.createForm.basePrice?.trim() || undefined, @@ -1288,10 +1318,12 @@ export class ServicePlansPageComponent implements OnInit { marginFixed: this.createForm.marginFixed?.trim() || undefined, providerConfigDefaults: Object.keys(providerConfigDefaults).length > 0 ? providerConfigDefaults : undefined, orderingHighlights: orderingHighlights.length > 0 ? orderingHighlights : undefined, - allowCustomerLocationSelection: this.createForm.allowCustomerLocationSelection === true, - allowCustomerServerTypeSelection: this.createForm.allowCustomerServerTypeSelection === true, + allowCustomerLocationSelection: isNone ? false : this.createForm.allowCustomerLocationSelection === true, + allowCustomerServerTypeSelection: isNone ? false : this.createForm.allowCustomerServerTypeSelection === true, allowedServerTypes: - this.createForm.allowCustomerServerTypeSelection === true ? [...this.createAllowedServerTypes] : undefined, + !isNone && this.createForm.allowCustomerServerTypeSelection === true + ? [...this.createAllowedServerTypes] + : undefined, taxCategory: this.createForm.taxCategory ?? 'standard', isActive: this.createForm.isActive ?? true, }); @@ -1302,20 +1334,26 @@ export class ServicePlansPageComponent implements OnInit { onSubmitEdit(): void { if (!this.editForm.id) return; + const isNone = isNoneServiceTypeId(this.editingPlan?.serviceTypeId); + this.typesAndProviders$.pipe(take(1)).subscribe(({ serviceTypes, providerDetails }) => { const serviceTypeId = this.editingPlan?.serviceTypeId?.trim(); - if (serviceTypeId) { + if (serviceTypeId && !isNone) { this.pruneInvalidProvisioningOptionKeys(serviceTypes, providerDetails, serviceTypeId, 'edit'); } this.cloudInitConfigs$.pipe(take(1)).subscribe((cloudInitConfigs) => { - this.editStaleCustomConfigIds = this.pruneInactiveCustomProvisioningOptionKeys(cloudInitConfigs, 'edit'); - - const providerConfigDefaults = this.buildProviderConfigDefaultsForSubmit( - this.editForm.providerConfigDefaults, - this.editProvisioningOptionKeys, - ); + this.editStaleCustomConfigIds = isNone + ? [] + : this.pruneInactiveCustomProvisioningOptionKeys(cloudInitConfigs, 'edit'); + + const providerConfigDefaults = isNone + ? {} + : this.buildProviderConfigDefaultsForSubmit( + this.editForm.providerConfigDefaults, + this.editProvisioningOptionKeys, + ); const orderingHighlights = this.sanitizeOrderingHighlights(this.editForm.orderingHighlights); this.plansFacade.updateServicePlan(this.editForm.id, { @@ -1327,7 +1365,7 @@ export class ServicePlansPageComponent implements OnInit { this.editForm.billingDayOfMonth != null ? Number(this.editForm.billingDayOfMonth) : undefined, cancelAtPeriodEnd: this.editForm.cancelAtPeriodEnd, billInAdvance: this.editForm.billInAdvance === true, - autoRecalculatePriceDaily: this.editForm.autoRecalculatePriceDaily === true, + autoRecalculatePriceDaily: isNone ? false : this.editForm.autoRecalculatePriceDaily === true, minCommitmentDays: Number(this.editForm.minCommitmentDays) ?? 0, noticeDays: Number(this.editForm.noticeDays) ?? 0, basePrice: this.editForm.basePrice?.trim() || undefined, @@ -1335,10 +1373,10 @@ export class ServicePlansPageComponent implements OnInit { marginFixed: this.editForm.marginFixed?.trim() || undefined, providerConfigDefaults: Object.keys(providerConfigDefaults).length > 0 ? providerConfigDefaults : undefined, orderingHighlights, - allowCustomerLocationSelection: this.editForm.allowCustomerLocationSelection, - allowCustomerServerTypeSelection: this.editForm.allowCustomerServerTypeSelection, + allowCustomerLocationSelection: isNone ? false : this.editForm.allowCustomerLocationSelection, + allowCustomerServerTypeSelection: isNone ? false : this.editForm.allowCustomerServerTypeSelection, allowedServerTypes: - this.editForm.allowCustomerServerTypeSelection === true ? [...this.editAllowedServerTypes] : [], + !isNone && this.editForm.allowCustomerServerTypeSelection === true ? [...this.editAllowedServerTypes] : [], taxCategory: this.editForm.taxCategory ?? 'standard', migrateExistingSubscriptions: this.editForm.migrateExistingSubscriptions === true, isActive: this.editForm.isActive, diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts index 482b56d81..2fe00abb0 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.ts @@ -43,6 +43,7 @@ import { type SubscriptionResponse, formatBillingProviderLocationLabel, formatServerTypeOption, + isNoneServiceTypeId, normalizeAllowedServerTypeIds, providerLocationCatalogFromList, type ProviderLocationCatalog, @@ -1358,9 +1359,16 @@ export class SubscriptionsComponent implements OnInit, AfterViewInit { private getProviderSchemaFullForOrder( serviceTypes: ServiceTypeResponse[] | null, providerDetails: ProviderDetail[] | null, - serviceTypeId: string, + serviceTypeId: string | null | undefined, ): Record | null { - if (!serviceTypeId?.trim() || !serviceTypes?.length || !providerDetails?.length) return null; + if ( + !serviceTypeId?.trim() || + isNoneServiceTypeId(serviceTypeId) || + !serviceTypes?.length || + !providerDetails?.length + ) { + return null; + } const serviceType = serviceTypes.find((st) => st.id === serviceTypeId); @@ -1439,14 +1447,16 @@ export class SubscriptionsComponent implements OnInit, AfterViewInit { const serviceType = serviceTypes?.find((st) => st.id === plan.serviceTypeId); if (serviceType?.provider) { - this.serviceTypesService.getProviderLocations(serviceType.provider, plan.serviceTypeId).subscribe({ - next: (locations) => { - this.orderLocationCatalog = providerLocationCatalogFromList(locations); - }, - error: () => { - this.orderLocationCatalog = new Map(); - }, - }); + this.serviceTypesService + .getProviderLocations(serviceType.provider, plan.serviceTypeId ?? undefined) + .subscribe({ + next: (locations) => { + this.orderLocationCatalog = providerLocationCatalogFromList(locations); + }, + error: () => { + this.orderLocationCatalog = new Map(); + }, + }); } }); } @@ -1486,37 +1496,39 @@ export class SubscriptionsComponent implements OnInit, AfterViewInit { this.orderServerTypesLoading = true; const allowed = new Set(normalizeAllowedServerTypeIds(plan.allowedServerTypes)); - this.serviceTypesService.getProviderServerTypes(serviceType.provider, plan.serviceTypeId).subscribe({ - next: (types) => { - if (requestId !== this.orderServerTypesRequestId) { - return; - } - - this.orderServerTypeOptions = types.filter((st) => allowed.has(st.id)); - const defaults = plan.providerConfigDefaults ?? {}; - const fromPlan = defaults['serverType']; - const fromPlanStr = typeof fromPlan === 'string' ? fromPlan : ''; - const options = this.orderServerTypeOptions.map((st) => st.id); + this.serviceTypesService + .getProviderServerTypes(serviceType.provider, plan.serviceTypeId ?? undefined) + .subscribe({ + next: (types) => { + if (requestId !== this.orderServerTypesRequestId) { + return; + } - this.orderProvisioningServerType = options.includes(fromPlanStr) ? fromPlanStr : (options[0] ?? ''); - this.orderServerTypesLoading = false; - this.clampOrderWizardStepIndex(); - this.syncOrderPricingPreview(); - this.cdr.detectChanges(); - }, - error: () => { - if (requestId !== this.orderServerTypesRequestId) { - return; - } + this.orderServerTypeOptions = types.filter((st) => allowed.has(st.id)); + const defaults = plan.providerConfigDefaults ?? {}; + const fromPlan = defaults['serverType']; + const fromPlanStr = typeof fromPlan === 'string' ? fromPlan : ''; + const options = this.orderServerTypeOptions.map((st) => st.id); + + this.orderProvisioningServerType = options.includes(fromPlanStr) ? fromPlanStr : (options[0] ?? ''); + this.orderServerTypesLoading = false; + this.clampOrderWizardStepIndex(); + this.syncOrderPricingPreview(); + this.cdr.detectChanges(); + }, + error: () => { + if (requestId !== this.orderServerTypesRequestId) { + return; + } - this.orderServerTypeOptions = []; - this.orderProvisioningServerType = ''; - this.orderServerTypesLoading = false; - this.clampOrderWizardStepIndex(); - this.syncOrderPricingPreview(); - this.cdr.detectChanges(); - }, - }); + this.orderServerTypeOptions = []; + this.orderProvisioningServerType = ''; + this.orderServerTypesLoading = false; + this.clampOrderWizardStepIndex(); + this.syncOrderPricingPreview(); + this.cdr.detectChanges(); + }, + }); }); }