From d96be5f390f8aba6131bbf860f1b1852296b96a1 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Tue, 25 Aug 2026 22:53:28 +0200 Subject: [PATCH] feat(decabill): multi-provider plans --- ...1776900000000_AddMultiProviderSelection.ts | 55 + .../src/i18n/messages.xlf | 47 +- .../features/customer-provider-selection.md | 71 ++ .../features/dynamic-provider-plugins.md | 8 +- docs/decabill/features/email-notifications.md | 2 +- docs/decabill/features/server-provisioning.md | 2 +- .../features/service-types-and-plans.md | 6 +- graph/graph.json | 312 ++++- .../docs/customer-provider-selection.md | 25 + .../docs/customer-server-type-selection.md | 5 +- .../feature-billing-manager/spec/openapi.yaml | 70 +- .../digital-ocean-provider.constants.ts | 2 + .../hetzner/hetzner-provider.constants.ts | 2 + .../controllers/availability.controller.ts | 24 +- .../controllers/pricing.controller.spec.ts | 45 + .../src/lib/controllers/pricing.controller.ts | 15 +- ...-service-plan-offerings.controller.spec.ts | 2 + ...ublic-service-plan-offerings.controller.ts | 8 +- .../service-plans.controller.spec.ts | 107 ++ .../controllers/service-plans.controller.ts | 162 ++- .../service-types.controller.spec.ts | 40 +- .../controllers/service-types.controller.ts | 140 ++- .../dto/config-change-preview-response.dto.ts | 2 + .../src/lib/dto/create-service-plan.dto.ts | 9 + .../src/lib/dto/create-service-type.dto.ts | 19 +- .../src/lib/dto/provider-detail.dto.ts | 6 + .../dto/public-service-plan-offering.dto.ts | 4 + .../src/lib/dto/service-plan-response.dto.ts | 7 + .../src/lib/dto/service-type-response.dto.ts | 5 +- .../src/lib/dto/update-service-plan.dto.ts | 9 + .../src/lib/dto/update-service-type.dto.ts | 10 +- .../src/lib/entities/service-plan.entity.ts | 8 + .../src/lib/entities/service-type.entity.ts | 12 +- .../billing-notification.events.spec.ts | 2 + .../billing-notification.events.ts | 2 + .../billing-notification.publisher.ts | 42 + .../search/billing-search-document.mapper.ts | 5 +- .../src/lib/services/addon.service.spec.ts | 49 + .../src/lib/services/addon.service.ts | 45 +- .../lib/services/backorder.service.spec.ts | 6 + .../src/lib/services/backorder.service.ts | 115 +- .../lib/services/cloud-init-config.service.ts | 4 +- .../lib/services/meter-collect.job-handler.ts | 6 +- .../src/lib/services/meter.service.ts | 4 +- .../service-plan-price-recalc.service.ts | 3 +- .../subscription-config-change.job-handler.ts | 7 +- ...subscription-config-change.service.spec.ts | 20 + .../subscription-config-change.service.ts | 10 +- .../subscription-item-server.service.ts | 7 +- .../subscription-item-update.job-handler.ts | 3 +- .../services/subscription-teardown.service.ts | 26 +- .../lib/services/subscription.service.spec.ts | 40 + .../src/lib/services/subscription.service.ts | 127 +- .../lib/utils/provider-env-defaults.utils.ts | 33 +- .../lib/utils/provider-location.utils.spec.ts | 41 +- .../src/lib/utils/provider-location.utils.ts | 81 ++ .../utils/provider-selection.utils.spec.ts | 192 +++ .../src/lib/utils/provider-selection.utils.ts | 260 ++++ .../utils/provider-server-type.utils.spec.ts | 31 + .../lib/utils/provider-server-type.utils.ts | 58 + .../data-access-billing-console/src/index.ts | 1 + .../services/service-plans.service.spec.ts | 2 + .../service-plans.effects.spec.ts | 2 + .../service-plans.facade.spec.ts | 2 + .../service-plans.reducer.spec.ts | 4 + .../service-plans.selectors.spec.ts | 4 + .../service-types.effects.spec.ts | 1 + .../service-types.facade.spec.ts | 1 + .../service-types.reducer.spec.ts | 2 + .../service-types.selectors.spec.ts | 2 + .../src/lib/types/billing.types.ts | 29 +- .../src/lib/types/config-change.types.ts | 2 + .../utils/provider-selection.utils.spec.ts | 54 + .../src/lib/utils/provider-selection.utils.ts | 60 + ...lic-service-plan-offerings.service.spec.ts | 2 + .../service-plans.effects.spec.ts | 2 + .../service-plans.facade.spec.ts | 2 + .../service-plans.reducer.spec.ts | 2 + .../service-plans.selectors.spec.ts | 4 + .../lib/types/portal-service-plans.types.ts | 4 + .../utils/public-offering-price.utils.spec.ts | 2 + .../service-plans-page.component.html | 841 +++++++++---- .../service-plans-page.component.ts | 1109 +++++++++++++++-- .../service-types-page.component.html | 218 +++- .../service-types-page.component.ts | 249 +++- .../subscriptions.component.html | 44 + .../subscriptions/subscriptions.component.ts | 369 +++++- 87 files changed, 4811 insertions(+), 663 deletions(-) create mode 100644 apps/decabill/backend-billing-manager/src/migrations/1776900000000_AddMultiProviderSelection.ts create mode 100644 docs/decabill/features/customer-provider-selection.md create mode 100644 libs/domains/decabill/backend/feature-billing-manager/docs/customer-provider-selection.md create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.spec.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.ts create mode 100644 libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.spec.ts create mode 100644 libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.ts diff --git a/apps/decabill/backend-billing-manager/src/migrations/1776900000000_AddMultiProviderSelection.ts b/apps/decabill/backend-billing-manager/src/migrations/1776900000000_AddMultiProviderSelection.ts new file mode 100644 index 000000000..c5ab4e935 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1776900000000_AddMultiProviderSelection.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMultiProviderSelection1776900000000 implements MigrationInterface { + name = 'AddMultiProviderSelection1776900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "billing_service_types" + ADD COLUMN "allowed_providers" jsonb NOT NULL DEFAULT '[]'::jsonb + `); + await queryRunner.query(` + UPDATE "billing_service_types" + SET "allowed_providers" = jsonb_build_array("provider") + WHERE "provider" IS NOT NULL AND TRIM("provider") <> '' + AND ("allowed_providers" IS NULL OR "allowed_providers" = '[]'::jsonb) + `); + await queryRunner.query(` + ALTER TABLE "billing_service_types" + ALTER COLUMN "provider" DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "billing_service_plans" + ADD COLUMN "allow_customer_provider_selection" boolean NOT NULL DEFAULT false + `); + await queryRunner.query(` + ALTER TABLE "billing_service_plans" + ADD COLUMN "allowed_providers" jsonb NOT NULL DEFAULT '[]'::jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "billing_service_plans" DROP COLUMN "allowed_providers" + `); + await queryRunner.query(` + ALTER TABLE "billing_service_plans" DROP COLUMN "allow_customer_provider_selection" + `); + await queryRunner.query(` + UPDATE "billing_service_types" + SET "provider" = COALESCE( + NULLIF(TRIM("provider"), ''), + NULLIF(TRIM("allowed_providers"->>0), ''), + 'hetzner' + ) + WHERE "provider" IS NULL OR TRIM("provider") = '' + `); + await queryRunner.query(` + ALTER TABLE "billing_service_types" + ALTER COLUMN "provider" SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "billing_service_types" DROP COLUMN "allowed_providers" + `); + } +} diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index e57b678f7..8d3d72bbb 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -2952,6 +2952,30 @@ 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. + + Allow provider selection + + + Customers may choose a cloud provider from the selected options when ordering. + + + Allowed providers + + + Hold Ctrl/Cmd to select. First selected is the default provider at checkout. + + + Plan provider + + + Customers cannot choose a provider. This provider is used for all orders on this plan. + + + Choose a default server type for each provider. Used when the customer picks that provider (or omits server type at checkout). + + + Choose a default location or region for each provider. + Active @@ -3082,7 +3106,16 @@ Description - Provider + Providers + + + None + + + Hold Ctrl/Cmd to select multiple compatible providers. First selected is primary. Choose None for no cloud provider. + + + (+) Loading providers... @@ -3102,6 +3135,9 @@ Overrides platform environment variables for this service type. Leave blank to use the environment default. + + Defaults for + Using environment default @@ -3672,6 +3708,15 @@ Plan & addons + + Server & region + + + Provider + + + Choose which cloud provider provisions this subscription. + Server type diff --git a/docs/decabill/features/customer-provider-selection.md b/docs/decabill/features/customer-provider-selection.md new file mode 100644 index 000000000..da74b1008 --- /dev/null +++ b/docs/decabill/features/customer-provider-selection.md @@ -0,0 +1,71 @@ +# Customer provider selection + +## Purpose + +Service types can expose one or more **interchangeable cloud providers** that share a registry `compatibilityGroup`. Service plans may further restrict that set and opt customers into choosing a provider at checkout (same pattern as [customer server type selection](../../libs/domains/decabill/backend/feature-billing-manager/docs/customer-server-type-selection.md)). + +## Provider compatibility + +`GET /service-types/providers` returns `compatibilityGroup` on each `ProviderDetail`: + +- First-party Hetzner and DigitalOcean use `host-cloud-init`. +- Missing or empty group means the provider is only compatible with itself (fail closed). +- Dynamic metadata plugins should set `compatibilityGroup` when they are interchangeable with another provider. + +## Service type fields + +| Field | Meaning | +| ------------------ | ----------------------------------------------------------------------- | +| `provider` | Primary provider id (first of `allowedProviders`); `null` when **None** | +| `allowedProviders` | Ordered interchangeable provider ids; empty = None (no cloud provider) | + +Admin UI rules: + +- Multi-select with a persistent **None** option. +- Selecting None clears all providers and clears compatibility filtering. +- Selecting a real provider unselects None and disables incompatible options (same `compatibilityGroup`). +- Unselecting the last real provider auto-selects None. +- `configSchema` is refreshed from the primary provider’s registry metadata when the primary changes. +- Platform `providerDefaults` accept env keys for **all** selected providers. + +Webhook (catalog, no email template): `service_type.allowed_providers_changed`. + +## Service plan fields + +| Field | Meaning | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowCustomerProviderSelection` | When true, checkout may send `requestedConfig.provider` | +| `allowedProviders` | Subset of the service type allowlist. With customer selection on: ≥2. With it off and multiple type providers: exactly 1 (admin pin). With a single type provider: that provider is assumed. | + +Rules: + +- Not allowed when `serviceTypeId` is null or the service type has no providers. +- When the flag is true, the plan allowlist is a customer-facing subset (all type providers selected by default in admin UI). The first entry is the checkout default if the customer omits `provider`. +- When the flag is false and the service type has multiple providers, admin must pin exactly one provider on the plan; orders always use that provider. +- When the flag is false and the service type has a single provider, that provider is used automatically (no admin pick needed). +- Legacy rows with customer selection off and an empty plan allowlist still fall back to the service type primary until edited. +- With customer provider selection and server-type pricing, set `providerConfigDefaults.serverTypeByProvider` so each allowed provider has its own default server type. +- With customer provider selection and geography fields, set `providerConfigDefaults.geographyByProvider` the same way (location/region ids per provider). + +Webhook: `service_plan.allowed_providers_changed`. + +## Order API (`POST /subscriptions`) + +1. If `allowCustomerProviderSelection` is false, `provider` is stripped from `requestedConfig`. +2. If true, client-supplied `provider` is kept (or the plan default is used) and must be in `plan.allowedProviders`. +3. The resolved provider is written to `configSnapshot.provider` and drives availability, geography, server-type catalogs, pricing snapshots, and provisioning. +4. Config schema validation uses the **resolved** provider’s registered schema (Hetzner `location` vs DigitalOcean `region`). + +## Availability (`POST /availability/check`) + +Resolves the provider from the service type allowlist and optional `requestedConfig.provider`. There is no hard-coded Hetzner fallback; unresolved provider returns 400. + +## Public catalog + +`PublicServicePlanOffering` includes `allowCustomerProviderSelection` and `allowedProviders` for checkout UIs. + +## Related code + +- `src/lib/utils/provider-selection.utils.ts` +- Service type / plan controllers, `subscription.service.ts`, `backorder.service.ts`, `availability.controller.ts` +- Migration `1776900000000_AddMultiProviderSelection` diff --git a/docs/decabill/features/dynamic-provider-plugins.md b/docs/decabill/features/dynamic-provider-plugins.md index b91ab5f6a..39d5b0e21 100644 --- a/docs/decabill/features/dynamic-provider-plugins.md +++ b/docs/decabill/features/dynamic-provider-plugins.md @@ -27,7 +27,13 @@ Provider metadata capability flags (all **fail closed** when omitted, treated as | `supportsServerTypeUpgrade` | In-place resize to a more expensive server type works | | `supportsServerTypeDowngrade` | In-place resize to a cheaper server type works | -See [Addons](./addons.md) and [Subscription Config Change](./subscription-config-change.md). Operators shipping dynamic providers must set the resize flags explicitly; built-in Hetzner/DigitalOcean already register both. +Optional metadata for interchangeable providers on one service type: + +| Field | Meaning | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `compatibilityGroup` | Shared group id (e.g. `host-cloud-init` for built-in Hetzner + DigitalOcean). Omitted/empty ⇒ provider is only compatible with itself. | + +See [Customer provider selection](./customer-provider-selection.md), [Addons](./addons.md) and [Subscription Config Change](./subscription-config-change.md). Operators shipping dynamic providers must set the resize flags explicitly; built-in Hetzner/DigitalOcean already register both. Addon modules may declare `configFields` (CloudInit-style env metadata). Decabill persists that list onto the catalog addon’s `configSchema` at create/update; admins set encrypted defaults only. At order time, customer `addonConfigs` merge with defaults and random fills into `configSnapshot` for `provision` / `teardown`. diff --git a/docs/decabill/features/email-notifications.md b/docs/decabill/features/email-notifications.md index 58c81725d..5a2bff68a 100644 --- a/docs/decabill/features/email-notifications.md +++ b/docs/decabill/features/email-notifications.md @@ -67,4 +67,4 @@ If no company name is resolved, header and footer are omitted. On Decabill, leav ## Future (webhook-only today) -`payment.initiated`, `invoice.created`, `invoice.overdue`, `subscription.updated`, project/milestone/ticket/time_entry CRUD, `datev_export.*`, `addon.container_manager.collection_failed` +`payment.initiated`, `invoice.created`, `invoice.overdue`, `subscription.updated`, project/milestone/ticket/time_entry CRUD, `datev_export.*`, `addon.container_manager.collection_failed`, `service_type.allowed_providers_changed`, `service_plan.allowed_providers_changed` diff --git a/docs/decabill/features/server-provisioning.md b/docs/decabill/features/server-provisioning.md index 5b4796df4..7ba393773 100644 --- a/docs/decabill/features/server-provisioning.md +++ b/docs/decabill/features/server-provisioning.md @@ -15,7 +15,7 @@ When a [Subscription](./subscriptions.md) order includes a provisioning-enabled ## Supported Providers -First-party Hetzner and DigitalOcean are **contributor Nest modules** under `contributors/hetzner` and `contributors/digital-ocean`. Each registers provider metadata and runtime hooks (`provision`, lifecycle, catalog) in `onModuleInit`. Host code dispatches through `ProvisioningDispatchService` and `ProviderCatalogDispatchService` (unknown provider ids or missing hooks **fail closed**). +First-party Hetzner and DigitalOcean are **contributor Nest modules** under `contributors/hetzner` and `contributors/digital-ocean`. Each registers provider metadata and runtime hooks (`provision`, lifecycle, catalog) in `onModuleInit`. Both share `compatibilityGroup: host-cloud-init` so a service type may offer them as interchangeable options (see [Customer provider selection](./customer-provider-selection.md)). Host code dispatches through `ProvisioningDispatchService` and `ProviderCatalogDispatchService` (unknown provider ids or missing hooks **fail closed**). #### Hetzner Cloud diff --git a/docs/decabill/features/service-types-and-plans.md b/docs/decabill/features/service-types-and-plans.md index c236ebcb3..07f01b2c9 100644 --- a/docs/decabill/features/service-types-and-plans.md +++ b/docs/decabill/features/service-types-and-plans.md @@ -8,7 +8,7 @@ Service types define which provisioning provider (if any) backs a product. Servi ## Service Types -A service type links a product name to a provider id (for example `hetzner`, `digital-ocean`) or no provider for non-infrastructure plans. +A service type links a product name to one or more interchangeable provider ids (for example `hetzner`, `digital-ocean`) that share a registry `compatibilityGroup`, or to **None** (no cloud provider) for non-infrastructure types. See [Customer provider selection](./customer-provider-selection.md). ### Admin Endpoints @@ -25,10 +25,11 @@ A service type links a product name to a provider id (for example `hetzner`, `di `GET /service-types/providers` returns registered provisioning providers with: - Provider id and display name +- Optional `compatibilityGroup` for interchangeable multi-provider service types - Optional `configSchema` for admin UI and subscription validation - Dynamic metadata from `DYNAMIC_BILLING_PROVIDER_METADATA` plugins -First-party Hetzner and DigitalOcean register as **contributor Nest modules** at startup (metadata + provisioning hooks). Additional providers can be registered via [Dynamic Provider Plugins](./dynamic-provider-plugins.md) (`DYNAMIC_BILLING_PROVIDER_METADATA` or full `DYNAMIC_BILLING_PROVIDER_MODULES` with optional `nestModule`). +First-party Hetzner and DigitalOcean register as **contributor Nest modules** at startup (metadata + provisioning hooks) with `compatibilityGroup: host-cloud-init`. Additional providers can be registered via [Dynamic Provider Plugins](./dynamic-provider-plugins.md) (`DYNAMIC_BILLING_PROVIDER_METADATA` or full `DYNAMIC_BILLING_PROVIDER_MODULES` with optional `nestModule`). ### Config Schema @@ -117,6 +118,7 @@ No dedicated webhook events are emitted for plan CRUD or for “non-provision fu - `billing_day_of_month` for subscription period alignment - `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`) +- `allowCustomerProviderSelection` and `allowedProviders` when the service type has multiple interchangeable providers; plan allowlist must be a subset of the type’s `allowedProviders` (see [Customer provider selection](./customer-provider-selection.md)) - 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/graph/graph.json b/graph/graph.json index 1b05fd46e..5f582b9de 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-08-21T12:40:24.251Z", + "generatedAt": "2026-08-25T17:44:36.644Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -5689,6 +5689,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/docs/customer-provider-selection.md", + "type": "readme", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/docs/customer-provider-selection.md", + "languageOrKind": "md", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/docs/customer-server-type-selection.md", "type": "readme", @@ -12614,6 +12623,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/decabill/features/customer-provider-selection.md", + "type": "doc", + "attrs": { + "path": "docs/decabill/features/customer-provider-selection.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/decabill/features/customer-trust-score.md", "type": "doc", @@ -18420,6 +18437,15 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_plan.allowed_providers_changed", + "type": "webhook-event", + "attrs": { + "eventName": "service_plan.allowed_providers_changed", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_attached", "type": "webhook-event", @@ -18456,6 +18482,15 @@ "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" } }, + { + "id": "webhook-event:decabill-backend-feature-billing-manager:service_type.allowed_providers_changed", + "type": "webhook-event", + "attrs": { + "eventName": "service_type.allowed_providers_changed", + "projectName": "decabill-backend-feature-billing-manager", + "catalogPath": "libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts" + } + }, { "id": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_attached", "type": "webhook-event", @@ -26961,6 +26996,86 @@ "domain": "decabill" } }, + { + "id": "concept:decabill-customer-provider-selection", + "type": "concept", + "attrs": { + "title": "Customer provider selection", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "customer-provider-selection", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-provider-compatibility", + "type": "concept", + "attrs": { + "title": "Provider compatibility", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "provider-compatibility", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-service-type-fields", + "type": "concept", + "attrs": { + "title": "Service type fields", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "service-type-fields", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-service-plan-fields", + "type": "concept", + "attrs": { + "title": "Service plan fields", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "service-plan-fields", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-order-api-post-subscriptions", + "type": "concept", + "attrs": { + "title": "Order API (`POST /subscriptions`)", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "order-api-post-subscriptions", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-availability-post-availability-check", + "type": "concept", + "attrs": { + "title": "Availability (`POST /availability/check`)", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "availability-post-availability-check", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-public-catalog", + "type": "concept", + "attrs": { + "title": "Public catalog", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "public-catalog", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-related-code", + "type": "concept", + "attrs": { + "title": "Related code", + "docPath": "docs/decabill/features/customer-provider-selection.md", + "sectionAnchor": "related-code", + "domain": "decabill" + } + }, { "id": "concept:decabill-customer-trust-score", "type": "concept", @@ -27451,16 +27566,6 @@ "domain": "decabill" } }, - { - "id": "concept:decabill-public-catalog", - "type": "concept", - "attrs": { - "title": "Public Catalog", - "docPath": "docs/decabill/features/multi-tenancy.md", - "sectionAnchor": "public-catalog", - "domain": "decabill" - } - }, { "id": "concept:decabill-background-jobs", "type": "concept", @@ -34318,6 +34423,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/docs/customer-location-selection.md", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/docs/customer-provider-selection.md", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/docs/customer-server-type-selection.md", @@ -40373,6 +40483,11 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:search.reindex.started", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_plan.allowed_providers_changed", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:service_plan.meter_attached", @@ -40393,6 +40508,11 @@ "to": "webhook-event:decabill-backend-feature-billing-manager:service_plan.price_recalculated", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "webhook-event:decabill-backend-feature-billing-manager:service_type.allowed_providers_changed", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "webhook-event:decabill-backend-feature-billing-manager:service_type.meter_attached", @@ -46153,6 +46273,51 @@ "to": "concept:decabill-related-documentation", "type": "contains" }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-customer-provider-selection", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-purpose", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-provider-compatibility", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-service-type-fields", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-service-plan-fields", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-order-api-post-subscriptions", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-availability-post-availability-check", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-public-catalog", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "concept:decabill-related-code", + "type": "contains" + }, { "from": "file:docs/decabill/features/customer-trust-score.md", "to": "concept:decabill-customer-trust-score", @@ -54978,6 +55143,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/availability.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/service-types.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/backorders.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.ts", @@ -55788,6 +55958,16 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/cloud-init-dispatch.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-catalog-dispatch.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-admin.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts", @@ -57448,6 +57628,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/cloud-init-dispatch.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/provider-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tax-calculation.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tax-rate-config.service.ts", @@ -66243,6 +66428,51 @@ "to": "api:HTTP:POST:/customer-profile", "type": "documents" }, + { + "from": "concept:decabill-provider-compatibility", + "to": "api:HTTP:GET:/service-types", + "type": "documents" + }, + { + "from": "concept:decabill-provider-compatibility", + "to": "api:HTTP:POST:/service-types", + "type": "documents" + }, + { + "from": "concept:decabill-provider-compatibility", + "to": "api:HTTP:GET:/service-types/providers", + "type": "documents" + }, + { + "from": "concept:decabill-order-api-post-subscriptions", + "to": "api:HTTP:POST:/auth/register", + "type": "documents" + }, + { + "from": "concept:decabill-order-api-post-subscriptions", + "to": "api:HTTP:GET:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-order-api-post-subscriptions", + "to": "api:HTTP:POST:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-availability-post-availability-check", + "to": "api:HTTP:POST:/availability/check", + "type": "documents" + }, + { + "from": "concept:decabill-public-catalog", + "to": "api:HTTP:GET:/public/service-plan-offerings", + "type": "documents" + }, + { + "from": "concept:decabill-public-catalog", + "to": "api:HTTP:GET:/public/service-plan-offerings/cheapest", + "type": "documents" + }, { "from": "concept:decabill-scope", "to": "api:HTTP:GET:/customer-profile", @@ -66573,16 +66803,6 @@ "to": "api:HTTP:POST:/admin/billing/invoices/{invoiceRefId}/issue", "type": "documents" }, - { - "from": "concept:decabill-public-catalog", - "to": "api:HTTP:GET:/public/service-plan-offerings", - "type": "documents" - }, - { - "from": "concept:decabill-public-catalog", - "to": "api:HTTP:GET:/public/service-plan-offerings/cheapest", - "type": "documents" - }, { "from": "concept:decabill-kill-switch", "to": "api:HTTP:POST:/auth/register", @@ -69888,6 +70108,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "file:docs/decabill/features/customer-provider-selection.md", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "file:docs/decabill/features/customer-trust-score.md", "to": "domain:decabill", @@ -73653,6 +73878,46 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "concept:decabill-customer-provider-selection", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-provider-compatibility", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-service-type-fields", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-service-plan-fields", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-order-api-post-subscriptions", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-availability-post-availability-check", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-public-catalog", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-related-code", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "concept:decabill-customer-trust-score", "to": "domain:decabill", @@ -73898,11 +74163,6 @@ "to": "domain:decabill", "type": "belongs_to" }, - { - "from": "concept:decabill-public-catalog", - "to": "domain:decabill", - "type": "belongs_to" - }, { "from": "concept:decabill-background-jobs", "to": "domain:decabill", diff --git a/libs/domains/decabill/backend/feature-billing-manager/docs/customer-provider-selection.md b/libs/domains/decabill/backend/feature-billing-manager/docs/customer-provider-selection.md new file mode 100644 index 000000000..c8e260309 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/docs/customer-provider-selection.md @@ -0,0 +1,25 @@ +# Customer provider selection + +Library-level notes for Decabill billing manager. Product docs: [customer-provider-selection.md](../../../../../../docs/decabill/features/customer-provider-selection.md). + +## Helpers + +`src/lib/utils/provider-selection.utils.ts`: + +- `normalizeAllowedProviders` / `resolvePrimaryProvider` +- `resolveServiceTypeAllowedProviders` / `resolvePlanAllowedProviders` +- `resolveEffectiveProvider` / `stripProviderFromRequestedConfig` / `assertProviderAllowed` +- `assertProvidersCompatible` / `providersAreCompatible` / `HOST_CLOUD_INIT_COMPATIBILITY_GROUP` + +## Persistence + +- `billing_service_types.provider` nullable; `allowed_providers` jsonb +- `billing_service_plans.allow_customer_provider_selection`; `allowed_providers` jsonb +- Migration: `apps/decabill/backend-billing-manager/src/migrations/1776900000000_AddMultiProviderSelection.ts` + +## Notifications + +Webhook-only events in `BILLING_NOTIFICATION_EVENTS`: + +- `service_type.allowed_providers_changed` +- `service_plan.allowed_providers_changed` diff --git a/libs/domains/decabill/backend/feature-billing-manager/docs/customer-server-type-selection.md b/libs/domains/decabill/backend/feature-billing-manager/docs/customer-server-type-selection.md index 168558478..3c9d46ae7 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/docs/customer-server-type-selection.md +++ b/libs/domains/decabill/backend/feature-billing-manager/docs/customer-server-type-selection.md @@ -15,15 +15,18 @@ Provisioning plans (Hetzner, DigitalOcean) use `basePriceFromField: 'serverType' - The billing console shows “Allow server type selection” only when the merged provider schema has **`basePriceFromField === 'serverType'`**. - When the flag is **false**, `allowedServerTypes` is ignored/cleared on save; a single server type is selected in `providerConfigDefaults.serverType` and `basePrice` follows that type. - When the flag is **true**, admins multi-select `allowedServerTypes` (minimum one). `providerConfigDefaults.serverType` is set to the first selected type (checkout default). `basePrice` is synced from that default type’s `priceMonthly`. +- When **customer provider selection** is also enabled (multiple plan providers), admins set **`providerConfigDefaults.serverTypeByProvider`** (`{ [providerId]: serverTypeId }`). Order/pricing resolve the default for the effective provider from that map (falling back to top-level `serverType`). The admin UI shows one default select per provider. - The API returns **400** if `allowCustomerServerTypeSelection: true` is sent when the effective schema does not support server-type selection, or when `allowedServerTypes` is empty or contains invalid values. ## Order API (`POST /subscriptions`) 1. If **`allowCustomerServerTypeSelection` is false**, `serverType` is removed from `requestedConfig` before merging with `providerConfigDefaults`. -2. If **true**, client-supplied `serverType` is kept; when omitted, the plan default (`providerConfigDefaults.serverType`) is used. +2. If **true**, client-supplied `serverType` is kept; when omitted, the plan default for the effective provider is used (`serverTypeByProvider[provider]` or `providerConfigDefaults.serverType`). 3. The resolved `serverType` must be listed in `plan.allowedServerTypes` when the flag is true. 4. The resolved infrastructure base price (`priceMonthly` for the chosen type) is snapshotted in subscription item `configSnapshot` as **`billingBasePrice`** so recurring billing matches checkout pricing. +When customer provider selection is enabled and server type selection is **false**, `serverType` is still taken from `serverTypeByProvider` for the resolved provider (not a single global default that may belong to another cloud). + ## Pricing preview (`POST /pricing/preview`) When `requestedConfig.serverType` is set (or the plan default when customer selection is enabled), the preview resolves `priceMonthly` via `ProviderServerTypesService` and passes it to `pricingService.calculate(plan, basePriceOverride)`. 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 88bb0e32c..4754cbc96 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -6398,6 +6398,12 @@ components: displayName: type: string description: Human-readable display name (e.g. Hetzner Cloud-Init). + compatibilityGroup: + type: string + description: | + Compatibility group for interchangeable providers on one service type. + Providers sharing the same group may be selected together (e.g. host-cloud-init for Hetzner and DigitalOcean). + Omitted or empty means the provider is only compatible with itself. configSchema: type: object description: | @@ -6503,7 +6509,7 @@ components: description: ISO country code when provided by the provider API CreateServiceTypeDto: type: object - required: [key, name, provider] + required: [key, name] properties: key: type: string @@ -6516,7 +6522,16 @@ components: description: Optional description provider: type: string - description: Provider identifier (e.g. hetzner, digital-ocean) + nullable: true + description: | + Primary provider id (first of allowedProviders). Null/empty with empty allowedProviders means None (no cloud provider). + When omitted, derived from allowedProviders[0]. + allowedProviders: + type: array + items: { type: string } + description: | + Interchangeable provider ids (order preserved; first is primary). Empty means None. + All entries must share the same compatibilityGroup from GET /service-types/providers. configSchema: type: object description: JSON schema for provider configuration @@ -6540,6 +6555,12 @@ components: type: string provider: type: string + nullable: true + description: Primary provider id; null/empty with empty allowedProviders means None. + allowedProviders: + type: array + items: { type: string } + description: Interchangeable provider ids (first is primary). Empty means None. configSchema: type: object isActive: @@ -6559,7 +6580,14 @@ components: key: { type: string } name: { type: string } description: { type: string, nullable: true } - provider: { type: string } + provider: + type: string + nullable: true + description: Primary provider id; null when None (no cloud provider). + allowedProviders: + type: array + items: { type: string } + description: Interchangeable provider ids (first is primary). Empty means None. configSchema: { type: object } isActive: { type: boolean } disallowStatutoryWithdrawal: { type: boolean } @@ -6759,6 +6787,13 @@ components: allowCustomerServerTypeSelection: type: boolean description: When true, authenticated checkout may send serverType in subscription requestedConfig; value must be in allowedServerTypes on the plan. + allowCustomerProviderSelection: + type: boolean + description: When true, authenticated checkout may send provider in subscription requestedConfig; value must be in allowedProviders on the plan. + allowedProviders: + type: array + items: { type: string } + description: Provider ids available for customer selection when allowCustomerProviderSelection is true. withdrawalPolicy: $ref: '#/components/schemas/WithdrawalPolicy' CreateServicePlanDto: @@ -6832,6 +6867,18 @@ components: type: array items: { type: string } description: Server type ids customers may choose at checkout when allowCustomerServerTypeSelection is true. + allowCustomerProviderSelection: + type: boolean + description: > + When true, the service type must have at least two allowed providers and allowedProviders must list + at least two provider ids that are a subset of the service type allowedProviders. + allowedProviders: + type: array + items: { type: string } + description: > + When allowCustomerProviderSelection is true: subset of service type providers (at least two). + When false and the service type has multiple providers: exactly one pinned provider for the plan. + When the service type has a single provider: that provider is assumed (may be omitted). taxCategory: type: string enum: [standard, reduced] @@ -6874,6 +6921,10 @@ components: allowedServerTypes: type: array items: { type: string } + allowCustomerProviderSelection: { type: boolean } + allowedProviders: + type: array + items: { type: string } taxCategory: type: string enum: [standard, reduced] @@ -6919,6 +6970,15 @@ components: type: array items: { type: string } description: Server type ids available for customer selection when allowCustomerServerTypeSelection is true. + allowCustomerProviderSelection: + type: boolean + description: When true, POST /subscriptions may include provider in requestedConfig; value must be listed in allowedProviders. + allowedProviders: + type: array + items: { type: string } + description: > + Effective plan provider set. With customer selection: options at checkout. + Without customer selection: single pinned provider when the service type has multiple providers. taxCategory: type: string enum: [standard, reduced] @@ -7217,6 +7277,10 @@ components: - CONFIG_CHANGE_FAILED reason: { type: string, nullable: true } hasPendingChange: { type: boolean } + provider: + type: string + nullable: true + description: Effective cloud provider for this subscription (configSnapshot, then service-type primary). currentServerType: { type: string, nullable: true } allowedServerTypes: type: array diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/digital-ocean/digital-ocean-provider.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/digital-ocean/digital-ocean-provider.constants.ts index b64f57f98..98f56c320 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/digital-ocean/digital-ocean-provider.constants.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/digital-ocean/digital-ocean-provider.constants.ts @@ -1,5 +1,6 @@ import { applyProviderConfigFieldScopes } from '../../utils/provider-config-schema.utils'; import { DIGITALOCEAN_ENV_DEFAULT_FIELDS } from '../../utils/provider-env-defaults.utils'; +import { HOST_CLOUD_INIT_COMPATIBILITY_GROUP } from '../../utils/provider-selection.utils'; import { HOST_PROVIDER_CONFIG_PROPERTIES } from '../shared/host-provider-config.properties'; @@ -28,6 +29,7 @@ export const DIGITAL_OCEAN_CONFIG_SCHEMA: Record = { export const DIGITAL_OCEAN_PROVIDER_METADATA = { id: DIGITAL_OCEAN_PROVIDER_ID, displayName: 'DigitalOcean Cloud-Init', + compatibilityGroup: HOST_CLOUD_INIT_COMPATIBILITY_GROUP, configSchema: DIGITAL_OCEAN_CONFIG_SCHEMA, envDefaultFields: DIGITALOCEAN_ENV_DEFAULT_FIELDS, supportsAddons: true, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/hetzner/hetzner-provider.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/hetzner/hetzner-provider.constants.ts index c2ee7d052..402fdaa0b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/hetzner/hetzner-provider.constants.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/hetzner/hetzner-provider.constants.ts @@ -1,5 +1,6 @@ import { applyProviderConfigFieldScopes } from '../../utils/provider-config-schema.utils'; import { HETZNER_ENV_DEFAULT_FIELDS } from '../../utils/provider-env-defaults.utils'; +import { HOST_CLOUD_INIT_COMPATIBILITY_GROUP } from '../../utils/provider-selection.utils'; import { HOST_PROVIDER_CONFIG_PROPERTIES } from '../shared/host-provider-config.properties'; @@ -29,6 +30,7 @@ export const HETZNER_CONFIG_SCHEMA: Record = { export const HETZNER_PROVIDER_METADATA = { id: HETZNER_PROVIDER_ID, displayName: 'Hetzner Cloud-Init', + compatibilityGroup: HOST_CLOUD_INIT_COMPATIBILITY_GROUP, configSchema: HETZNER_CONFIG_SCHEMA, envDefaultFields: HETZNER_ENV_DEFAULT_FIELDS, supportsAddons: true, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts index 7cb553dd4..e88298b5b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/availability.controller.ts @@ -1,18 +1,36 @@ import { RequireScopes } from '@forepath/identity/backend'; -import { Body, Controller, Post } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Post } from '@nestjs/common'; import { AvailabilityCheckDto } from '../dto/availability-check.dto'; import { AvailabilityResponseDto } from '../dto/availability-response.dto'; +import { ServiceTypesRepository } from '../repositories/service-types.repository'; import { AvailabilityService } from '../services/availability.service'; +import { resolveEffectiveProvider, resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; @Controller('availability') @RequireScopes('subscriptions:write') export class AvailabilityController { - constructor(private readonly availabilityService: AvailabilityService) {} + constructor( + private readonly availabilityService: AvailabilityService, + private readonly serviceTypesRepository: ServiceTypesRepository, + ) {} @Post('check') async check(@Body() dto: AvailabilityCheckDto): Promise { - const provider = (dto.requestedConfig?.provider as string | undefined) ?? 'hetzner'; + const serviceType = await this.serviceTypesRepository.findByIdOrThrow(dto.serviceTypeId); + const typeAllowed = resolveServiceTypeAllowedProviders(serviceType); + + // AvailabilityCheckDto has no planId; treat the service-type allowlist as selectable. + const provider = resolveEffectiveProvider( + serviceType, + { allowCustomerProviderSelection: true, allowedProviders: typeAllowed }, + dto.requestedConfig, + ); + + if (!provider) { + throw new BadRequestException('No provider is configured for this service type'); + } + const response = await this.availabilityService.checkAvailability(provider, dto.region, dto.serverType); return { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.spec.ts index 783f974cb..63ca7046b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/pricing.controller.spec.ts @@ -137,6 +137,51 @@ describe('PricingController', () => { expect(result.taxCategory).toBe('standard'); }); + it('resolves catalog price from requested provider when customer provider selection is enabled', async () => { + findPlanById.mockResolvedValue({ + ...planRow, + allowCustomerServerTypeSelection: true, + allowCustomerProviderSelection: true, + allowedServerTypes: ['cx11', 's-1vcpu-1gb'], + allowedProviders: ['hetzner', 'digital-ocean'], + providerConfigDefaults: { serverType: 'cx11' }, + }); + findServiceTypeById.mockResolvedValue({ + id: '22222222-2222-4222-8222-222222222222', + provider: 'hetzner', + allowedProviders: ['hetzner', 'digital-ocean'], + providerDefaults: { + HETZNER_API_TOKEN: 'tenant-token', + DIGITALOCEAN_API_TOKEN: 'do-token', + }, + }); + getServerTypes.mockImplementation(async (providerId: string) => { + if (providerId === 'digital-ocean') { + return [{ id: 's-1vcpu-1gb', priceMonthly: 6 }]; + } + + return [ + { id: 'cx11', priceMonthly: 4.15 }, + { id: 'cpx11', priceMonthly: 6.49 }, + ]; + }); + + const result = await controller.preview( + { + planId: planRow.id, + requestedConfig: { provider: 'digital-ocean', serverType: 's-1vcpu-1gb' }, + }, + authReq as never, + ); + + expect(getServerTypes).toHaveBeenCalledWith('digital-ocean', { + HETZNER_API_TOKEN: 'tenant-token', + DIGITALOCEAN_API_TOKEN: 'do-token', + }); + expect(calculate).toHaveBeenCalledWith(expect.objectContaining({ allowCustomerProviderSelection: true }), 6); + expect(result.totalPrice).toBe(6); + }); + it('ignores provisioning default server type when customer selection is disabled', async () => { findPlanById.mockResolvedValue({ ...planRow, 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 5e9e7b24d..c084c8628 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 @@ -18,6 +18,8 @@ import { mergeOrderAddonIds, parsePlanAllowedAddonIds } from '../utils/plan-addo import { normalizeStoredProviderDefaults } from '../utils/provider-env-defaults.utils'; import { enrichPricingWithTax } from '../utils/pricing-tax.utils'; import { resolvePlanTaxCategory } from '../utils/plan-tax.utils'; +import { resolveEffectiveProvider } from '../utils/provider-selection.utils'; +import { resolveDefaultServerTypeForProvider } from '../utils/provider-server-type.utils'; import { resolveServerTypePriceMonthly } from '../utils/server-type-billing.utils'; @Controller('pricing') @@ -73,23 +75,26 @@ export class PricingController { if (allowCustomerServerTypeSelection) { const requestedServerType = dto.requestedConfig?.['serverType']; + const serviceType = plan.serviceTypeId + ? await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId) + : null; + const provider = serviceType ? resolveEffectiveProvider(serviceType, plan, dto.requestedConfig) : null; serverTypeId = typeof requestedServerType === 'string' && requestedServerType.trim() ? requestedServerType.trim() - : typeof plan.providerConfigDefaults?.['serverType'] === 'string' - ? String(plan.providerConfigDefaults['serverType']).trim() - : ''; + : (resolveDefaultServerTypeForProvider(plan.providerConfigDefaults, provider) ?? ''); } let planPricing = this.pricingService.calculate(plan); if (serverTypeId && plan.serviceTypeId) { const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); + const provider = resolveEffectiveProvider(serviceType, plan, dto.requestedConfig); const providerDefaults = normalizeStoredProviderDefaults(serviceType.providerDefaults); const priceMonthly = await resolveServerTypePriceMonthly( this.providerServerTypesService, - serviceType.provider, + provider, serverTypeId, providerDefaults, ); @@ -112,6 +117,8 @@ export class PricingController { plan.serviceTypeId, parsePlanAllowedAddonIds(plan.providerConfigDefaults), selectedAddonIds, + dto.requestedConfig, + plan, ); const addonLines = addons.map((addon) => ({ addonId: addon.id, 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 227f2d2ac..14ba8be0b 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 @@ -97,6 +97,8 @@ describe('PublicServicePlanOfferingsController', () => { orderingHighlights: [{ icon: 'check', text: 'Included' }], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, 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 43f7ad8dd..5f2a76c21 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 @@ -14,6 +14,7 @@ import { normalizeStoredProviderDefaults } from '../utils/provider-env-defaults. import { enrichPricingWithTax } from '../utils/pricing-tax.utils'; import { resolvePlanTaxCategory } from '../utils/plan-tax.utils'; import { normalizeAllowedServerTypes } from '../utils/provider-server-type.utils'; +import { normalizeAllowedProviders, resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; import { resolveLowestServerTypePriceMonthly } from '../utils/server-type-billing.utils'; const DEFAULT_LIMIT = 50; @@ -98,11 +99,14 @@ export class PublicServicePlanOfferingsController { ); const allowCustomerServerTypeSelection = row.allowCustomerServerTypeSelection === true; const allowedServerTypes = normalizeAllowedServerTypes(row.allowedServerTypes); + const allowCustomerProviderSelection = row.allowCustomerProviderSelection === true; + const allowedProviders = normalizeAllowedProviders(row.allowedProviders); let totalPriceFrom: number | undefined; let totalGrossFrom: number | undefined; if (allowCustomerServerTypeSelection && allowedServerTypes.length > 0) { - const provider = row.serviceType?.provider; + const provider = + resolveServiceTypeAllowedProviders(row.serviceType ?? {})[0] ?? row.serviceType?.provider ?? null; const providerDefaults = normalizeStoredProviderDefaults(row.serviceType?.providerDefaults); const lowestBase = await resolveLowestServerTypePriceMonthly( this.providerServerTypesService, @@ -147,6 +151,8 @@ export class PublicServicePlanOfferingsController { orderingHighlights: row.orderingHighlights ?? [], allowCustomerLocationSelection: row.allowCustomerLocationSelection === true, allowCustomerServerTypeSelection, + allowCustomerProviderSelection, + allowedProviders, withdrawalPolicy: this.withdrawalPolicyService.buildPolicyInfo({ disallowStatutoryWithdrawal: row.serviceType?.disallowStatutoryWithdrawal ?? false, }), 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 23016969d..2cf3d2919 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 @@ -16,6 +16,7 @@ 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 { ContainerManagerCatalogService } from '../contributors/container-manager/services/container-manager-catalog.service'; +import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { ServicePlansController } from './service-plans.controller'; @@ -45,6 +46,8 @@ describe('ServicePlansController', () => { allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, allowedServerTypes: [], + allowCustomerProviderSelection: false, + allowedProviders: [], taxCategory: TaxCategory.STANDARD, isActive: true, createdAt: new Date('2024-01-01T00:00:00.000Z'), @@ -59,6 +62,7 @@ describe('ServicePlansController', () => { findByIdOrThrow: jest.fn().mockResolvedValue({ id: basePlanRow.serviceTypeId, provider: 'hetzner', + allowedProviders: ['hetzner'], configSchema: schemaWithRegionEnum, disallowStatutoryWithdrawal: false, }), @@ -84,6 +88,9 @@ describe('ServicePlansController', () => { const containerManagerCatalogServiceStub = { applyIntegratedPlanDefaults: jest.fn(async (defaults: Record | undefined) => defaults ?? {}), }; + const notificationPublisherStub = { + publishServicePlanAllowedProvidersChanged: jest.fn(), + }; beforeEach(() => { planPriceMigrateEnqueueStub.enqueueUnit.mockReset(); @@ -92,6 +99,7 @@ describe('ServicePlansController', () => { serviceTypesRepoStub.findByIdOrThrow.mockResolvedValue({ id: basePlanRow.serviceTypeId, provider: 'hetzner', + allowedProviders: ['hetzner'], configSchema: schemaWithRegionEnum, disallowStatutoryWithdrawal: false, }); @@ -111,6 +119,7 @@ describe('ServicePlansController', () => { containerManagerCatalogServiceStub.applyIntegratedPlanDefaults.mockImplementation( async (defaults: Record | undefined) => defaults ?? {}, ); + notificationPublisherStub.publishServicePlanAllowedProvidersChanged.mockReset(); }); function setupRepositoryMock(mock: Partial>) { @@ -137,6 +146,7 @@ describe('ServicePlansController', () => { { provide: SubscriptionsRepository, useValue: subscriptionsRepositoryStub }, { provide: WithdrawalPolicyService, useValue: new WithdrawalPolicyService() }, { provide: ContainerManagerCatalogService, useValue: containerManagerCatalogServiceStub }, + { provide: BillingNotificationPublisher, useValue: notificationPublisherStub }, { provide: PLAN_PRICE_MIGRATE_ENQUEUE, useValue: planPriceMigrateEnqueueStub }, ], }).compile(); @@ -526,6 +536,101 @@ describe('ServicePlansController', () => { ); }); + it('create rejects allowCustomerProviderSelection with a single provider', async () => { + serviceTypesRepoStub.findByIdOrThrow.mockResolvedValueOnce({ + id: basePlanRow.serviceTypeId, + provider: 'hetzner', + allowedProviders: ['hetzner', 'digital-ocean'], + configSchema: schemaWithRegionEnum, + disallowStatutoryWithdrawal: false, + }); + 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: basePlanRow.serviceTypeId, + name: 'Basic', + billingIntervalType: BillingIntervalType.MONTH, + billingIntervalValue: 1, + allowCustomerProviderSelection: true, + allowedProviders: ['hetzner'], + } as CreateServicePlanDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('create rejects allowCustomerProviderSelection when service type has fewer than two providers', async () => { + serviceTypesRepoStub.findByIdOrThrow.mockResolvedValueOnce({ + id: basePlanRow.serviceTypeId, + provider: 'hetzner', + allowedProviders: ['hetzner'], + configSchema: schemaWithRegionEnum, + disallowStatutoryWithdrawal: false, + }); + 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: basePlanRow.serviceTypeId, + name: 'Basic', + billingIntervalType: BillingIntervalType.MONTH, + billingIntervalValue: 1, + allowCustomerProviderSelection: true, + allowedProviders: ['hetzner', 'digital-ocean'], + } as CreateServicePlanDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('create passes allowCustomerProviderSelection with at least two providers', async () => { + serviceTypesRepoStub.findByIdOrThrow.mockResolvedValueOnce({ + id: basePlanRow.serviceTypeId, + provider: 'hetzner', + allowedProviders: ['hetzner', 'digital-ocean'], + configSchema: schemaWithRegionEnum, + disallowStatutoryWithdrawal: false, + }); + const create = jest + .fn() + .mockImplementation((dto: Partial) => Promise.resolve({ ...basePlanRow, ...dto })); + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn(), + create, + update: jest.fn(), + delete: jest.fn(), + }); + const controller = moduleRef.get(ServicePlansController); + + await controller.create({ + serviceTypeId: basePlanRow.serviceTypeId, + name: 'Basic', + billingIntervalType: BillingIntervalType.MONTH, + billingIntervalValue: 1, + allowCustomerProviderSelection: true, + allowedProviders: ['hetzner', 'digital-ocean'], + } as CreateServicePlanDto); + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + allowCustomerProviderSelection: true, + allowedProviders: ['hetzner', 'digital-ocean'], + }), + ); + }); + it('create with null serviceTypeId stores null and skips provider asserts', async () => { const create = jest.fn().mockImplementation((dto: Partial) => Promise.resolve({ @@ -557,6 +662,8 @@ describe('ServicePlansController', () => { providerConfigDefaults: {}, allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], autoRecalculatePriceDaily: false, }), ); 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 26d04775f..5fb6ba1c8 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 @@ -27,6 +27,7 @@ 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 { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { AddonsRepository } from '../repositories/addons.repository'; import { ServicePlansRepository } from '../repositories/service-plans.repository'; import { ServiceTypesRepository } from '../repositories/service-types.repository'; @@ -49,6 +50,11 @@ import { parsePlanAllowedAddonIds, parsePlanMandatoryAddonIds } from '../utils/p 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 { + allowedProvidersEqual, + normalizeAllowedProviders, + resolveServiceTypeAllowedProviders, +} from '../utils/provider-selection.utils'; import { effectiveSchemaSupportsServerTypeSelection, normalizeAllowedServerTypes, @@ -69,6 +75,7 @@ export class ServicePlansController { private readonly subscriptionsRepository: SubscriptionsRepository, private readonly withdrawalPolicyService: WithdrawalPolicyService, private readonly containerManagerCatalogService: ContainerManagerCatalogService, + private readonly notificationPublisher: BillingNotificationPublisher, @Inject(PLAN_PRICE_MIGRATE_ENQUEUE) private readonly planPriceMigrateEnqueue: PlanPriceMigrateEnqueuePort, ) {} @@ -166,8 +173,9 @@ export class ServicePlansController { } const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); + const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? null; - if (!this.addonService.providerSupportsAddons(serviceType.provider)) { + if (!primaryProvider || !this.addonService.providerSupportsAddons(primaryProvider)) { return []; } @@ -182,9 +190,7 @@ export class ServicePlansController { const compatible = addons .filter((addon) => addon.isActive) - .filter( - (addon) => addon.compatibleProviders.length === 0 || addon.compatibleProviders.includes(serviceType.provider), - ); + .filter((addon) => addon.compatibleProviders.length === 0 || addon.compatibleProviders.includes(primaryProvider)); return await Promise.all( compatible.map(async (addon) => ({ @@ -254,6 +260,14 @@ export class ServicePlansController { const allowedServerTypes = allowCustomerServerTypeSelection ? normalizeAllowedServerTypes(dto.allowedServerTypes) : []; + const allowCustomerProviderSelection = isNone ? false : dto.allowCustomerProviderSelection === true; + const allowedProviders = isNone + ? [] + : await this.resolvePlanProvidersForPersist( + dbServiceTypeId as string, + allowCustomerProviderSelection, + dto.allowedProviders, + ); if (!isNone && dbServiceTypeId) { await this.cloudInitConfigService.assertActiveConfigForPlanDefaults(dbServiceTypeId, normalizedDefaults); @@ -285,10 +299,23 @@ export class ServicePlansController { allowCustomerLocationSelection: isNone ? false : (dto.allowCustomerLocationSelection ?? false), allowCustomerServerTypeSelection, allowedServerTypes, + allowCustomerProviderSelection, + allowedProviders, taxCategory: dto.taxCategory ?? TaxCategory.STANDARD, isActive: dto.isActive ?? true, }); + this.notificationPublisher.publishServicePlanAllowedProvidersChanged({ + servicePlanId: row.id, + servicePlanName: row.name, + tenantId: row.tenantId ?? getTenantIdOrDefault(), + serviceTypeId: row.serviceTypeId, + previousAllowCustomerProviderSelection: false, + previousAllowedProviders: [], + nextAllowCustomerProviderSelection: allowCustomerProviderSelection, + nextAllowedProviders: allowedProviders, + }); + return await this.mapToResponse(row); } @@ -335,6 +362,22 @@ export class ServicePlansController { : allowCustomerServerTypeSelection ? normalizeAllowedServerTypes(existing.allowedServerTypes) : []; + const allowCustomerProviderSelection = isNone + ? false + : dto.allowCustomerProviderSelection !== undefined + ? dto.allowCustomerProviderSelection === true + : existing.allowCustomerProviderSelection === true; + const providerSelectionTouched = + dto.allowCustomerProviderSelection !== undefined || dto.allowedProviders !== undefined || isNone; + const allowedProviders = isNone + ? [] + : providerSelectionTouched + ? await this.resolvePlanProvidersForPersist( + existing.serviceTypeId as string, + allowCustomerProviderSelection, + dto.allowedProviders !== undefined ? dto.allowedProviders : existing.allowedProviders, + ) + : normalizeAllowedProviders(existing.allowedProviders); if (!isNone && existing.serviceTypeId && dto.providerConfigDefaults !== undefined) { const normalizedDefaultsRaw = normalizePlanProviderConfigDefaults(dto.providerConfigDefaults); @@ -396,10 +439,34 @@ export class ServicePlansController { ...(dto.allowedServerTypes !== undefined || dto.allowCustomerServerTypeSelection !== undefined || isNone ? { allowedServerTypes } : {}), + ...(dto.allowCustomerProviderSelection !== undefined || isNone ? { allowCustomerProviderSelection } : {}), + ...(providerSelectionTouched ? { allowedProviders } : {}), ...(dto.taxCategory !== undefined ? { taxCategory: dto.taxCategory } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), }); + const previousAllowCustomerProviderSelection = existing.allowCustomerProviderSelection === true; + const previousAllowedProviders = normalizeAllowedProviders(existing.allowedProviders); + const selectionFieldsTouched = + dto.allowCustomerProviderSelection !== undefined || dto.allowedProviders !== undefined || isNone; + const selectionChanged = + selectionFieldsTouched && + (previousAllowCustomerProviderSelection !== allowCustomerProviderSelection || + !allowedProvidersEqual(previousAllowedProviders, allowedProviders)); + + if (selectionChanged) { + this.notificationPublisher.publishServicePlanAllowedProvidersChanged({ + servicePlanId: row.id, + servicePlanName: row.name, + tenantId: row.tenantId ?? getTenantIdOrDefault(), + serviceTypeId: row.serviceTypeId, + previousAllowCustomerProviderSelection, + previousAllowedProviders, + nextAllowCustomerProviderSelection: allowCustomerProviderSelection, + nextAllowedProviders: allowedProviders, + }); + } + if (shouldMigrate && previousPricing) { const changeId = randomUUID(); const runDate = new Intl.DateTimeFormat('en-CA', { @@ -481,6 +548,8 @@ export class ServicePlansController { allowCustomerLocationSelection: row.allowCustomerLocationSelection === true, allowCustomerServerTypeSelection: row.allowCustomerServerTypeSelection === true, allowedServerTypes: normalizeAllowedServerTypes(row.allowedServerTypes), + allowCustomerProviderSelection: row.allowCustomerProviderSelection === true, + allowedProviders: normalizeAllowedProviders(row.allowedProviders), taxCategory: row.taxCategory ?? TaxCategory.STANDARD, withdrawalPolicy, meters: await this.meterService.listEffectivePlanMeters(row.id, row.serviceTypeId), @@ -503,6 +572,12 @@ export class ServicePlansController { ); } + if (dto.allowCustomerProviderSelection === true) { + throw new BadRequestException( + 'allowCustomerProviderSelection 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)', @@ -523,6 +598,12 @@ export class ServicePlansController { ); } + if (dto.allowCustomerProviderSelection === true) { + throw new BadRequestException( + 'allowCustomerProviderSelection 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)', @@ -534,7 +615,10 @@ export class ServicePlansController { if (!allow) return; const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); - const providerDetail = this.providerRegistry.getProviders().find((p) => p.id === serviceType.provider); + const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? null; + const providerDetail = primaryProvider + ? this.providerRegistry.getProviders().find((p) => p.id === primaryProvider) + : undefined; if (!effectiveSchemaSupportsLocationSelection(serviceType.configSchema, providerDetail?.configSchema)) { throw new BadRequestException( @@ -551,7 +635,10 @@ export class ServicePlansController { if (!allow) return; const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); - const providerDetail = this.providerRegistry.getProviders().find((p) => p.id === serviceType.provider); + const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? null; + const providerDetail = primaryProvider + ? this.providerRegistry.getProviders().find((p) => p.id === primaryProvider) + : undefined; if (!effectiveSchemaSupportsServerTypeSelection(serviceType.configSchema, providerDetail?.configSchema)) { throw new BadRequestException( @@ -567,4 +654,67 @@ export class ServicePlansController { ); } } + + /** + * Validates and normalizes plan allowedProviders for persistence. + * - Customer selection on: require ≥2 type providers and a subset of ≥2. + * - Customer selection off + multiple type providers: require exactly one pinned provider. + * - Customer selection off + single type provider: pin that provider automatically. + * - No type providers: empty list; customer selection not allowed. + */ + private async resolvePlanProvidersForPersist( + serviceTypeId: string, + allowCustomerProviderSelection: boolean, + planAllowedProviders: string[] | undefined, + ): Promise { + const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); + const typeAllowed = resolveServiceTypeAllowedProviders(serviceType); + const normalized = normalizeAllowedProviders(planAllowedProviders).filter((id) => typeAllowed.includes(id)); + + if (typeAllowed.length === 0) { + if (allowCustomerProviderSelection) { + throw new BadRequestException( + 'allowCustomerProviderSelection requires the service type to have at least two allowed providers', + ); + } + + return []; + } + + if (allowCustomerProviderSelection) { + if (typeAllowed.length < 2) { + throw new BadRequestException( + 'allowCustomerProviderSelection requires the service type to have at least two allowed providers', + ); + } + + if (normalized.length < 2) { + throw new BadRequestException( + 'allowedProviders must contain at least two providers when customer selection is enabled', + ); + } + + const invalid = normalizeAllowedProviders(planAllowedProviders).filter((id) => !typeAllowed.includes(id)); + + if (invalid.length > 0) { + throw new BadRequestException( + `allowedProviders must be a subset of the service type allowedProviders (invalid: ${invalid.join(', ')})`, + ); + } + + return normalized; + } + + if (typeAllowed.length === 1) { + return [typeAllowed[0]]; + } + + if (normalized.length !== 1) { + throw new BadRequestException( + 'allowedProviders must contain exactly one provider when customer selection is disabled and the service type has multiple providers', + ); + } + + return normalized; + } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts index 2104dfbd3..0c1dc8a6d 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.spec.ts @@ -1,6 +1,7 @@ import { Test } from '@nestjs/testing'; import { ServiceTypeEntity } from '../entities/service-type.entity'; +import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { ServiceTypesRepository } from '../repositories/service-types.repository'; import { MeterService } from '../services/meter.service'; import { ProviderRegistryService } from '../services/provider-registry.service'; @@ -23,6 +24,13 @@ describe('ServiceTypesController', () => { updateServiceTypeMeter: jest.fn(), detachServiceTypeMeter: jest.fn(), }; + const notificationPublisherStub = { + publishServiceTypeAllowedProvidersChanged: jest.fn(), + }; + const providerRegistryStub = { + getProviders: jest.fn().mockReturnValue([]), + getProvider: jest.fn((id: string) => ({ id, compatibilityGroup: 'host-cloud-init' })), + }; const mockServiceTypeRow: ServiceTypeEntity = { id: '11111111-1111-4111-8111-111111111111', @@ -30,6 +38,7 @@ describe('ServiceTypesController', () => { tenantId: 'default', name: 'Hetzner', provider: 'hetzner', + allowedProviders: ['hetzner'], configSchema: {}, isActive: true, disallowStatutoryWithdrawal: false, @@ -39,9 +48,17 @@ describe('ServiceTypesController', () => { }; const meterProvider = { provide: MeterService, useValue: mockMeterService }; + const notificationProvider = { + provide: BillingNotificationPublisher, + useValue: notificationPublisherStub, + }; beforeEach(() => { jest.clearAllMocks(); + providerRegistryStub.getProvider.mockImplementation((id: string) => ({ + id, + compatibilityGroup: 'host-cloud-init', + })); }); describe('getProviderServerTypes', () => { @@ -54,10 +71,11 @@ describe('ServiceTypesController', () => { controllers: [ServiceTypesController], providers: [ { provide: ServiceTypesRepository, useValue: {} }, - { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, + { provide: ProviderRegistryService, useValue: providerRegistryStub }, { provide: ProviderServerTypesService, useValue: serverTypesService }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, meterProvider, + notificationProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -78,10 +96,11 @@ describe('ServiceTypesController', () => { controllers: [ServiceTypesController], providers: [ { provide: ServiceTypesRepository, useValue: serviceTypesRepository }, - { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, + { provide: ProviderRegistryService, useValue: providerRegistryStub }, { provide: ProviderServerTypesService, useValue: serverTypesService }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, meterProvider, + notificationProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -104,10 +123,11 @@ describe('ServiceTypesController', () => { controllers: [ServiceTypesController], providers: [ { provide: ServiceTypesRepository, useValue: {} }, - { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, + { provide: ProviderRegistryService, useValue: providerRegistryStub }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: locationsService }, meterProvider, + notificationProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -125,6 +145,7 @@ describe('ServiceTypesController', () => { ]; const providerRegistry = { getProviders: jest.fn().mockReturnValue(providerDetails), + getProvider: jest.fn((id: string) => ({ id, compatibilityGroup: 'host-cloud-init' })), }; const moduleRef = await Test.createTestingModule({ controllers: [ServiceTypesController], @@ -134,6 +155,7 @@ describe('ServiceTypesController', () => { { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, meterProvider, + notificationProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -156,7 +178,10 @@ describe('ServiceTypesController', () => { }, }, ]; - const providerRegistry = { getProviders: jest.fn().mockReturnValue(providerDetails) }; + const providerRegistry = { + getProviders: jest.fn().mockReturnValue(providerDetails), + getProvider: jest.fn((id: string) => ({ id, compatibilityGroup: 'host-cloud-init' })), + }; const moduleRef = await Test.createTestingModule({ controllers: [ServiceTypesController], providers: [ @@ -165,6 +190,7 @@ describe('ServiceTypesController', () => { { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, meterProvider, + notificationProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -191,10 +217,11 @@ describe('ServiceTypesController', () => { controllers: [ServiceTypesController], providers: [ { provide: ServiceTypesRepository, useValue: serviceTypesRepository }, - { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, + { provide: ProviderRegistryService, useValue: providerRegistryStub }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, meterProvider, + notificationProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); @@ -228,10 +255,11 @@ describe('ServiceTypesController', () => { controllers: [ServiceTypesController], providers: [ { provide: ServiceTypesRepository, useValue: serviceTypesRepository }, - { provide: ProviderRegistryService, useValue: { getProviders: jest.fn() } }, + { provide: ProviderRegistryService, useValue: providerRegistryStub }, { provide: ProviderServerTypesService, useValue: mockProviderServerTypes }, { provide: ProviderLocationsService, useValue: mockProviderLocations }, meterProvider, + notificationProvider, ], }).compile(); const controller = moduleRef.get(ServiceTypesController); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts index 1747b9cba..e6c730d2b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-types.controller.ts @@ -1,5 +1,7 @@ import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { getTenantIdOrDefault } from '@forepath/shared/backend'; import { + BadRequestException, Body, Controller, Delete, @@ -22,18 +24,26 @@ import { ServerTypeDto } from '../dto/server-type.dto'; import { ServiceTypeResponseDto } from '../dto/service-type-response.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceTypeEntity } from '../entities/service-type.entity'; +import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { ServiceTypesRepository } from '../repositories/service-types.repository'; import { MeterService } from '../services/meter.service'; import { ProviderRegistryService } from '../services/provider-registry.service'; import { ProviderLocationsService } from '../services/provider-locations.service'; import { ProviderServerTypesService } from '../services/provider-server-types.service'; import { - getProviderEnvDefaultFieldKeys, - getProviderEnvDefaultFields, + getProvidersEnvDefaultFieldKeys, + getProvidersEnvDefaultFields, maskProviderDefaultsForResponse, normalizeStoredProviderDefaults, sanitizeProviderDefaults, } from '../utils/provider-env-defaults.utils'; +import { + allowedProvidersEqual, + assertProvidersCompatible, + normalizeAllowedProviders, + resolvePrimaryProvider, + resolveServiceTypeAllowedProviders, +} from '../utils/provider-selection.utils'; @Controller('service-types') export class ServiceTypesController { @@ -43,6 +53,7 @@ export class ServiceTypesController { private readonly providerServerTypesService: ProviderServerTypesService, private readonly providerLocationsService: ProviderLocationsService, private readonly meterService: MeterService, + private readonly notificationPublisher: BillingNotificationPublisher, ) {} /** @@ -59,8 +70,9 @@ export class ServiceTypesController { if (serviceTypeId) { const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); + const allowed = resolveServiceTypeAllowedProviders(serviceType); - if (serviceType.provider !== providerId) { + if (!allowed.includes(providerId)) { providerDefaults = {}; } else { providerDefaults = normalizeStoredProviderDefaults(serviceType.providerDefaults); @@ -84,8 +96,9 @@ export class ServiceTypesController { if (serviceTypeId) { const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); + const allowed = resolveServiceTypeAllowedProviders(serviceType); - if (serviceType.provider !== providerId) { + if (!allowed.includes(providerId)) { providerDefaults = {}; } else { providerDefaults = normalizeStoredProviderDefaults(serviceType.providerDefaults); @@ -96,7 +109,7 @@ export class ServiceTypesController { } /** - * Get all registered provider details (id, displayName, configSchema). + * Get all registered provider details (id, displayName, configSchema, compatibilityGroup). * Used by clients to build provider selectors and validate provider-specific config. */ @RequireScopes('subscriptions:read') @@ -130,19 +143,30 @@ export class ServiceTypesController { @KeycloakRoles(UserRole.ADMIN) @UsersRoles(UserRole.ADMIN) async create(@Body() dto: CreateServiceTypeDto): Promise { - const providerDefaults = this.resolveProviderDefaultsForPersist(dto.provider, dto.providerDefaults, undefined); + const { provider, allowedProviders, configSchema } = this.resolveProvidersForPersist(dto); + const providerDefaults = this.resolveProviderDefaultsForPersist(allowedProviders, dto.providerDefaults, undefined); const row = await this.serviceTypesRepository.create({ key: dto.key, name: dto.name, description: dto.description, - provider: dto.provider, - configSchema: dto.configSchema ?? {}, + provider, + allowedProviders, + configSchema, isActive: dto.isActive ?? true, disallowStatutoryWithdrawal: dto.disallowStatutoryWithdrawal ?? false, providerDefaults, }); await this.meterService.syncServiceTypeProviderMeters(row); + this.notificationPublisher.publishServiceTypeAllowedProvidersChanged({ + serviceTypeId: row.id, + serviceTypeKey: row.key, + tenantId: row.tenantId ?? getTenantIdOrDefault(), + previousPrimary: null, + previousAllowedProviders: [], + nextPrimary: provider, + nextAllowedProviders: allowedProviders, + }); return this.mapToResponse(row); } @@ -205,18 +229,40 @@ export class ServiceTypesController { @Body() dto: UpdateServiceTypeDto, ): Promise { const existing = await this.serviceTypesRepository.findByIdOrThrow(id); - const provider = dto.provider ?? existing.provider; + const previousAllowed = resolveServiceTypeAllowedProviders(existing); + const previousPrimary = existing.provider?.trim() || previousAllowed[0] || null; + const providersTouched = dto.allowedProviders !== undefined || dto.provider !== undefined; + const resolved = providersTouched + ? this.resolveProvidersForPersist({ + provider: dto.provider !== undefined ? dto.provider : existing.provider, + allowedProviders: dto.allowedProviders !== undefined ? dto.allowedProviders : previousAllowed, + configSchema: dto.configSchema, + }) + : { + provider: existing.provider, + allowedProviders: previousAllowed, + configSchema: dto.configSchema ?? existing.configSchema ?? {}, + }; + const providersChanged = + previousPrimary !== resolved.provider || !allowedProvidersEqual(previousAllowed, resolved.allowedProviders); const providerDefaults = this.resolveProviderDefaultsForPersist( - provider, + resolved.allowedProviders, dto.providerDefaults, normalizeStoredProviderDefaults(existing.providerDefaults), - dto.provider !== undefined && dto.provider !== existing.provider, + providersChanged, ); const row = await this.serviceTypesRepository.update(id, { name: dto.name, description: dto.description, - provider: dto.provider, - configSchema: dto.configSchema, + ...(providersTouched + ? { + provider: resolved.provider, + allowedProviders: resolved.allowedProviders, + configSchema: resolved.configSchema, + } + : dto.configSchema !== undefined + ? { configSchema: dto.configSchema } + : {}), isActive: dto.isActive, disallowStatutoryWithdrawal: dto.disallowStatutoryWithdrawal, ...(providerDefaults !== undefined ? { providerDefaults } : {}), @@ -224,6 +270,18 @@ export class ServiceTypesController { await this.meterService.syncServiceTypeProviderMeters(row); + if (providersChanged) { + this.notificationPublisher.publishServiceTypeAllowedProvidersChanged({ + serviceTypeId: row.id, + serviceTypeKey: row.key, + tenantId: row.tenantId ?? getTenantIdOrDefault(), + previousPrimary, + previousAllowedProviders: previousAllowed, + nextPrimary: resolved.provider, + nextAllowedProviders: resolved.allowedProviders, + }); + } + return this.mapToResponse(row); } @@ -236,19 +294,61 @@ export class ServiceTypesController { await this.serviceTypesRepository.delete(id); } + private resolveProvidersForPersist(dto: { + provider?: string | null; + allowedProviders?: string[]; + configSchema?: Record; + }): { provider: string | null; allowedProviders: string[]; configSchema: Record } { + let allowedProviders = normalizeAllowedProviders(dto.allowedProviders); + + if (allowedProviders.length === 0) { + const legacy = typeof dto.provider === 'string' ? dto.provider.trim() : ''; + + if (legacy) { + allowedProviders = [legacy]; + } + } + + // Non-empty allowlist wins over a null/empty provider field (primary is derived from the list). + // None is represented only by an empty allowlist (and null primary). + + const compatibilityError = assertProvidersCompatible(allowedProviders, (id) => + this.providerRegistry.getProvider(id), + ); + + if (compatibilityError) { + throw new BadRequestException(compatibilityError); + } + + const provider = resolvePrimaryProvider(allowedProviders); + let configSchema = dto.configSchema ?? {}; + + if (provider) { + const registered = this.providerRegistry.getProvider(provider)?.configSchema; + + if (registered && (!dto.configSchema || Object.keys(dto.configSchema).length === 0)) { + configSchema = { ...registered }; + } + } else { + configSchema = dto.configSchema ?? {}; + } + + return { provider, allowedProviders, configSchema }; + } + private resolveProviderDefaultsForPersist( - providerId: string, + allowedProviders: string[], input: Record | undefined, existing: Record | undefined, - providerChanged = false, + providersChanged = false, ): Record | undefined { - const allowedKeys = getProviderEnvDefaultFieldKeys(providerId); + const allowedKeys = getProvidersEnvDefaultFieldKeys(allowedProviders); if (input !== undefined) { return sanitizeProviderDefaults(input, allowedKeys); } - if (providerChanged && existing) { + if (providersChanged && existing) { return sanitizeProviderDefaults(existing, allowedKeys); } @@ -256,10 +356,11 @@ export class ServiceTypesController { } private mapToResponse(row: ServiceTypeEntity): ServiceTypeResponseDto { + const allowedProviders = resolveServiceTypeAllowedProviders(row); const providerDefaults = normalizeStoredProviderDefaults(row.providerDefaults); const { providerDefaultsConfigured } = maskProviderDefaultsForResponse( providerDefaults, - getProviderEnvDefaultFields(row.provider), + getProvidersEnvDefaultFields(allowedProviders), ); return { @@ -267,7 +368,8 @@ export class ServiceTypesController { key: row.key, name: row.name, description: row.description, - provider: row.provider, + provider: row.provider?.trim() ? row.provider : (allowedProviders[0] ?? null), + allowedProviders, configSchema: row.configSchema ?? {}, isActive: row.isActive, disallowStatutoryWithdrawal: row.disallowStatutoryWithdrawal, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/config-change-preview-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/config-change-preview-response.dto.ts index b3159699c..ef6c846b2 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/config-change-preview-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/config-change-preview-response.dto.ts @@ -6,6 +6,8 @@ export class ConfigChangeEligibilityDto { reasonCode?: ConfigChangeErrorCode; reason?: string; hasPendingChange!: boolean; + /** Effective cloud provider for this subscription (configSnapshot, then service-type primary). */ + provider?: string; currentServerType?: string; allowedServerTypes!: string[]; supportsServerTypeUpgrade!: boolean; 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 e2ef1d8fa..0b74f9e61 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 @@ -114,6 +114,15 @@ export class CreateServicePlanDto { @IsString({ each: true, message: 'Each allowed server type must be a string' }) allowedServerTypes?: string[]; + @IsOptional() + @IsBoolean({ message: 'allowCustomerProviderSelection must be a boolean' }) + allowCustomerProviderSelection?: boolean; + + @IsOptional() + @IsArray({ message: 'allowedProviders must be an array' }) + @IsString({ each: true, message: 'Each allowed provider must be a string' }) + allowedProviders?: string[]; + @IsOptional() @IsEnum(TaxCategory, { message: 'taxCategory must be standard or reduced' }) taxCategory?: TaxCategory; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-type.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-type.dto.ts index 0e5932008..a098bcb61 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-type.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/create-service-type.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator'; +import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject, ValidateIf } from 'class-validator'; export class CreateServiceTypeDto { @IsNotEmpty({ message: 'Key is required' }) @@ -13,9 +13,22 @@ export class CreateServiceTypeDto { @IsString({ message: 'Description must be a string' }) description?: string; - @IsNotEmpty({ message: 'Provider is required' }) + /** + * Primary provider id (first of allowedProviders). Null/empty with empty allowedProviders means None. + * When omitted, derived from allowedProviders[0]. + */ + @IsOptional() + @ValidateIf((_, value) => value != null && value !== '') @IsString({ message: 'Provider must be a string' }) - provider!: string; + provider?: string | null; + + /** + * Interchangeable provider ids (order preserved; first is primary). Empty means None. + */ + @IsOptional() + @IsArray({ message: 'allowedProviders must be an array' }) + @IsString({ each: true, message: 'Each allowed provider must be a string' }) + allowedProviders?: string[]; @IsOptional() @IsObject({ message: 'Config schema must be an object' }) diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts index d588032ad..cf5e16960 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/provider-detail.dto.ts @@ -22,6 +22,12 @@ export class ProviderDetailDto { */ displayName!: string; + /** + * Compatibility group for interchangeable providers on one service type. + * Providers without a group are only compatible with themselves. + */ + compatibilityGroup?: string; + /** * Optional JSON schema for provider-specific configuration when creating subscriptions. */ 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 873ed2e70..aa453bc04 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 @@ -33,5 +33,9 @@ export class PublicServicePlanOfferingDto { allowCustomerLocationSelection!: boolean; /** When true, checkout UIs may offer server type selection from allowed types. */ allowCustomerServerTypeSelection!: boolean; + /** When true, checkout UIs may offer provider selection from allowedProviders. */ + allowCustomerProviderSelection!: boolean; + /** Checkout options when selection is allowed; otherwise the pinned plan provider (length 0–1). */ + allowedProviders!: string[]; withdrawalPolicy!: WithdrawalPolicyDto; } 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 251ae84f9..077b22c1e 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 @@ -33,6 +33,13 @@ export class ServicePlanResponseDto { allowCustomerServerTypeSelection!: boolean; /** Server type ids customers may select when allowCustomerServerTypeSelection is true. */ allowedServerTypes!: string[]; + /** When true, customers may choose provider from allowedProviders at checkout. */ + allowCustomerProviderSelection!: boolean; + /** + * With customer selection: checkout options. + * Without: single pinned provider when the service type has multiple providers. + */ + allowedProviders!: string[]; taxCategory!: TaxCategory; withdrawalPolicy!: WithdrawalPolicyDto; meters!: AttachedMeterResponseDto[]; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-type-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-type-response.dto.ts index ddd3f0af1..01d9f1f2e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-type-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/service-type-response.dto.ts @@ -3,7 +3,10 @@ export class ServiceTypeResponseDto { key!: string; name!: string; description?: string; - provider!: string; + /** Primary provider id; null when None (no cloud provider). */ + provider!: string | null; + /** Interchangeable provider ids (first is primary). Empty means None. */ + allowedProviders!: string[]; configSchema!: Record; isActive!: boolean; disallowStatutoryWithdrawal!: boolean; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-plan.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-plan.dto.ts index a28dd55e8..9757025ad 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-plan.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-plan.dto.ts @@ -113,6 +113,15 @@ export class UpdateServicePlanDto { @IsString({ each: true, message: 'Each allowed server type must be a string' }) allowedServerTypes?: string[]; + @IsOptional() + @IsBoolean({ message: 'allowCustomerProviderSelection must be a boolean' }) + allowCustomerProviderSelection?: boolean; + + @IsOptional() + @IsArray({ message: 'allowedProviders must be an array' }) + @IsString({ each: true, message: 'Each allowed provider must be a string' }) + allowedProviders?: string[]; + @IsOptional() @IsEnum(TaxCategory, { message: 'taxCategory must be standard or reduced' }) taxCategory?: TaxCategory; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-type.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-type.dto.ts index 934d8d41f..485273c77 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-type.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/update-service-type.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsBoolean, IsObject, IsOptional, IsString, ValidateIf } from 'class-validator'; export class UpdateServiceTypeDto { @IsOptional() @@ -10,8 +10,14 @@ export class UpdateServiceTypeDto { description?: string; @IsOptional() + @ValidateIf((_, value) => value != null && value !== '') @IsString({ message: 'Provider must be a string' }) - provider?: string; + provider?: string | null; + + @IsOptional() + @IsArray({ message: 'allowedProviders must be an array' }) + @IsString({ each: true, message: 'Each allowed provider must be a string' }) + allowedProviders?: string[]; @IsOptional() @IsObject({ message: 'Config schema must be an object' }) 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 822238834..e737f6fd2 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 @@ -104,6 +104,14 @@ export class ServicePlanEntity { @Column({ type: 'jsonb', name: 'allowed_server_types', default: () => "'[]'::jsonb" }) allowedServerTypes!: string[]; + /** When true, customers may choose provider from allowedProviders at checkout. */ + @Column({ type: 'boolean', name: 'allow_customer_provider_selection', default: false }) + allowCustomerProviderSelection!: boolean; + + /** Provider ids customers may select when allowCustomerProviderSelection is true (subset of service type). */ + @Column({ type: 'jsonb', name: 'allowed_providers', default: () => "'[]'::jsonb" }) + allowedProviders!: string[]; + @Column({ type: 'enum', enum: TaxCategory, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type.entity.ts index d93aa932a..6c71b8a56 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type.entity.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type.entity.ts @@ -19,8 +19,16 @@ export class ServiceTypeEntity { @Column({ type: 'text', nullable: true, name: 'description' }) description?: string; - @Column({ type: 'varchar', length: 100, name: 'provider' }) - provider!: string; + /** Primary provider id (first of allowedProviders); null when None / no cloud provider. */ + @Column({ type: 'varchar', length: 100, name: 'provider', nullable: true }) + provider!: string | null; + + /** + * Interchangeable provider ids for this service type (order preserved; first is primary). + * Empty means None (no cloud provider). + */ + @Column({ type: 'jsonb', name: 'allowed_providers', default: () => "'[]'::jsonb" }) + allowedProviders!: string[]; @Column({ type: 'jsonb', name: 'config_schema', default: () => "'{}'::jsonb" }) configSchema!: Record; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts index 6447c7322..8c0736e51 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.spec.ts @@ -58,6 +58,8 @@ describe('BILLING_NOTIFICATION_EVENTS', () => { 'service_type.meter_attached', 'service_type.meter_updated', 'service_type.meter_detached', + 'service_type.allowed_providers_changed', + 'service_plan.allowed_providers_changed', 'usage.recorded', 'usage.updated', 'usage.deleted', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts index 41f1cfd12..5a6d4d909 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.events.ts @@ -60,6 +60,8 @@ export const BILLING_NOTIFICATION_EVENTS = [ 'service_type.meter_attached', 'service_type.meter_updated', 'service_type.meter_detached', + 'service_type.allowed_providers_changed', + 'service_plan.allowed_providers_changed', 'usage.recorded', 'usage.updated', 'usage.deleted', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts index 05246fbfc..506835eab 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/notifications/billing-notification.publisher.ts @@ -414,6 +414,48 @@ export class BillingNotificationPublisher implements IIdentityNotificationPublis }); } + publishServiceTypeAllowedProvidersChanged(payload: { + serviceTypeId: string; + serviceTypeKey: string; + tenantId: string; + previousPrimary: string | null; + previousAllowedProviders: string[]; + nextPrimary: string | null; + nextAllowedProviders: string[]; + }): void { + this.publish('service_type.allowed_providers_changed', { + serviceTypeId: payload.serviceTypeId, + serviceTypeKey: payload.serviceTypeKey, + tenantId: payload.tenantId, + previousPrimary: payload.previousPrimary, + previousAllowedProviders: payload.previousAllowedProviders, + nextPrimary: payload.nextPrimary, + nextAllowedProviders: payload.nextAllowedProviders, + }); + } + + publishServicePlanAllowedProvidersChanged(payload: { + servicePlanId: string; + servicePlanName: string; + tenantId: string; + serviceTypeId: string | null; + previousAllowCustomerProviderSelection: boolean; + previousAllowedProviders: string[]; + nextAllowCustomerProviderSelection: boolean; + nextAllowedProviders: string[]; + }): void { + this.publish('service_plan.allowed_providers_changed', { + servicePlanId: payload.servicePlanId, + servicePlanName: payload.servicePlanName, + tenantId: payload.tenantId, + serviceTypeId: payload.serviceTypeId, + previousAllowCustomerProviderSelection: payload.previousAllowCustomerProviderSelection, + previousAllowedProviders: payload.previousAllowedProviders, + nextAllowCustomerProviderSelection: payload.nextAllowCustomerProviderSelection, + nextAllowedProviders: payload.nextAllowedProviders, + }); + } + publishSubscriptionPriceChanged( subscription: SubscriptionEntity, plan: SubscriptionPlanBillingFields, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/search/billing-search-document.mapper.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/search/billing-search-document.mapper.ts index fe6b9a1a0..711c6b8e7 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/search/billing-search-document.mapper.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/search/billing-search-document.mapper.ts @@ -17,6 +17,7 @@ import type { ProjectTimeEntryEntity } from '../projects/entities/project-time-e import type { ProjectEntity } from '../projects/entities/project.entity'; import type { BillingSearchDocument, BillingSearchEntityType } from './billing-search.types'; +import { resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; function text(value: string | number | null | undefined): string | undefined { if (value === null || value === undefined) { @@ -139,11 +140,13 @@ export function mapServicePlanToSearchDocument(plan: ServicePlanEntity): Billing } export function mapServiceTypeToSearchDocument(serviceType: ServiceTypeEntity): BillingSearchDocument { + const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? null; + return baseDoc('service-types', serviceType.id, serviceType.tenantId, { key: text(serviceType.key), name: text(serviceType.name), description: text(serviceType.description), - provider: text(serviceType.provider), + provider: text(primaryProvider), // Intentionally omit providerDefaults / configSchema secrets }); } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.spec.ts index 068f0cb26..1dfba42c5 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.spec.ts @@ -201,6 +201,55 @@ describe('AddonService', () => { await expect(service.assertAddonIdsForOrder('st-1', ['addon-1'], ['addon-1'])).rejects.toThrow(BadRequestException); }); + it('rejects order addons incompatible with the effective plan provider', async () => { + serviceTypesRepository.findByIdOrThrow.mockResolvedValue({ + id: 'st-1', + provider: 'hetzner', + allowedProviders: ['hetzner', 'digital-ocean'], + }); + providerRegistry.getProviders.mockReturnValue([ + { id: 'hetzner', displayName: 'Hetzner', supportsAddons: true }, + { id: 'digital-ocean', displayName: 'DigitalOcean', supportsAddons: true }, + ]); + addonsRepository.findByIds.mockResolvedValue([ + { id: 'addon-1', key: 'av', isActive: true, compatibleProviders: ['hetzner'] }, + ]); + + await expect( + service.assertAddonIdsForOrder( + 'st-1', + ['addon-1'], + ['addon-1'], + { provider: 'digital-ocean' }, + { allowCustomerProviderSelection: true, allowedProviders: ['hetzner', 'digital-ocean'] }, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('uses pinned plan provider for order addon checks when customer selection is off', async () => { + const addon = { id: 'addon-1', key: 'av', isActive: true, compatibleProviders: ['digital-ocean'] }; + serviceTypesRepository.findByIdOrThrow.mockResolvedValue({ + id: 'st-1', + provider: 'hetzner', + allowedProviders: ['hetzner', 'digital-ocean'], + }); + providerRegistry.getProviders.mockReturnValue([ + { id: 'hetzner', displayName: 'Hetzner', supportsAddons: true }, + { id: 'digital-ocean', displayName: 'DigitalOcean', supportsAddons: true }, + ]); + addonsRepository.findByIds.mockResolvedValue([addon]); + + await expect( + service.assertAddonIdsForOrder( + 'st-1', + ['addon-1'], + ['addon-1'], + { provider: 'hetzner' }, + { allowCustomerProviderSelection: false, allowedProviders: ['digital-ocean'] }, + ), + ).resolves.toEqual([addon]); + }); + it('validateCreatePayload rejects negative base price and invalid scripts', () => { expect(() => service.validateCreatePayload({ diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts index 1df81b32a..a6149ab1f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/addon.service.ts @@ -18,6 +18,7 @@ import { } from '../utils/addon-config.utils'; import { assertNonNegativeAddonPrice } from '../utils/addon-pricing.util'; import { parsePlanMandatoryAddonIds, planReferencesAddonId } from '../utils/plan-addons.utils'; +import { resolveEffectiveProvider, resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; import { AddonModuleRegistryService } from './addon-module-registry.service'; import { ProviderRegistryService } from './provider-registry.service'; @@ -144,8 +145,14 @@ export class AddonService { return getAddonOrderFields(parseAddonConfigFields(addon.configSchema) as never); } - providerSupportsAddons(providerId: string): boolean { - const detail = this.providerRegistry.getProviders().find((p) => p.id === providerId); + providerSupportsAddons(providerId: string | null | undefined): boolean { + const trimmed = providerId?.trim(); + + if (!trimmed) { + return false; + } + + const detail = this.providerRegistry.getProviders().find((p) => p.id === trimmed); return detail?.supportsAddons === true; } @@ -160,10 +167,11 @@ export class AddonService { } const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); + const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? null; - if (!this.providerSupportsAddons(serviceType.provider)) { + if (!this.providerSupportsAddons(primaryProvider)) { throw new BadRequestException( - `Provider "${serviceType.provider}" does not support addons; remove allowedAddonIds from the plan`, + `Provider "${primaryProvider ?? 'none'}" does not support addons; remove allowedAddonIds from the plan`, ); } @@ -186,8 +194,12 @@ export class AddonService { throw new BadRequestException(`Addon "${addon.key}" is not active`); } - if (addon.compatibleProviders.length > 0 && !addon.compatibleProviders.includes(serviceType.provider)) { - throw new BadRequestException(`Addon "${addon.key}" is not compatible with provider "${serviceType.provider}"`); + if ( + primaryProvider && + addon.compatibleProviders.length > 0 && + !addon.compatibleProviders.includes(primaryProvider) + ) { + throw new BadRequestException(`Addon "${addon.key}" is not compatible with provider "${primaryProvider}"`); } } } @@ -207,15 +219,24 @@ export class AddonService { serviceTypeId: string, planAllowedAddonIds: string[], requestedAddonIds: string[], + requestedConfig?: Record, + plan?: { + allowCustomerProviderSelection?: boolean | null; + allowedProviders?: string[] | null; + }, ): Promise { if (requestedAddonIds.length === 0) { return []; } const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); + const providerToCheck = + resolveEffectiveProvider(serviceType, plan ?? {}, requestedConfig) ?? + resolveServiceTypeAllowedProviders(serviceType)[0] ?? + null; - if (!this.providerSupportsAddons(serviceType.provider)) { - throw new BadRequestException(`Provider "${serviceType.provider}" does not support addons`); + if (!this.providerSupportsAddons(providerToCheck)) { + throw new BadRequestException(`Provider "${providerToCheck ?? 'none'}" does not support addons`); } const allowed = new Set(planAllowedAddonIds); @@ -236,6 +257,14 @@ export class AddonService { if (!addon.isActive) { throw new BadRequestException(`Addon "${addon.key}" is not active`); } + + if ( + providerToCheck && + addon.compatibleProviders.length > 0 && + !addon.compatibleProviders.includes(providerToCheck) + ) { + throw new BadRequestException(`Addon "${addon.key}" is not compatible with provider "${providerToCheck}"`); + } } return addons; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.spec.ts index 76dcd8c54..0f2e0f6e8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.spec.ts @@ -29,6 +29,8 @@ describe('BackorderService cancel', () => { {} as never, {} as never, {} as never, + { requiresProvisioning: jest.fn().mockReturnValue(false) } as never, + { getProvider: jest.fn() } as never, ); beforeEach(() => { @@ -83,6 +85,8 @@ describe('BackorderService retry guard', () => { {} as never, {} as never, {} as never, + { requiresProvisioning: jest.fn().mockReturnValue(false) } as never, + { getProvider: jest.fn() } as never, ); beforeEach(() => { @@ -132,6 +136,8 @@ describe('BackorderService server type validation', () => { {} as never, {} as never, {} as never, + { requiresProvisioning: jest.fn().mockReturnValue(false) } as never, + { getProvider: jest.fn().mockReturnValue(undefined) } as never, ); beforeEach(() => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.ts index 1f951f727..d60c8e2ab 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/backorder.service.ts @@ -18,14 +18,26 @@ import { import { validateConfigSchema } from '../utils/config-validation.utils'; import { mirrorGeographyInConfig, + readRequestedGeography, + resolveDefaultGeographyForProvider, resolveProvisioningRegion, + stripGeographyByProviderFromConfig, stripGeographyFromRequestedConfig, } from '../utils/provider-location.utils'; import { assertServerTypeAllowed, normalizeAllowedServerTypes, + resolveDefaultServerTypeForProvider, + stripServerTypeByProviderFromConfig, stripServerTypeFromRequestedConfig, } from '../utils/provider-server-type.utils'; +import { + assertProviderAllowed, + resolveEffectiveProvider, + resolvePlanAllowedProviders, + resolveServiceTypeAllowedProviders, + stripProviderFromRequestedConfig, +} from '../utils/provider-selection.utils'; import { BILLING_BASE_PRICE_CONFIG_KEY, resolvePeriodTotalPrice, @@ -41,6 +53,8 @@ import { CloudflareDnsService } from './cloudflare-dns.service'; import { HostnameReservationService } from './hostname-reservation.service'; import { ProviderServerTypesService } from './provider-server-types.service'; import { PricingService } from './pricing.service'; +import { ProviderCatalogDispatchService } from './provider-catalog-dispatch.service'; +import { ProviderRegistryService } from './provider-registry.service'; import { ProvisioningDispatchService } from './provisioning-dispatch.service'; import { TaxCalculationService } from './tax-calculation.service'; import { InvoiceTaxContextService } from './invoice-tax-context.service'; @@ -68,6 +82,8 @@ export class BackorderService { private readonly invoiceTaxContextService: InvoiceTaxContextService, private readonly subscriptionPeriodChargeService: SubscriptionPeriodChargeService, private readonly cloudInitDispatchService: CloudInitDispatchService, + private readonly providerCatalogDispatchService: ProviderCatalogDispatchService, + private readonly providerRegistry: ProviderRegistryService, ) {} async create(data: { @@ -123,18 +139,25 @@ export class BackorderService { const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); const allowCustomerLocationSelection = plan.allowCustomerLocationSelection === true; const allowCustomerServerTypeSelection = plan.allowCustomerServerTypeSelection === true; + const allowCustomerProviderSelection = plan.allowCustomerProviderSelection === true; let sanitizedSnapshot = allowCustomerLocationSelection ? { ...(backorder.requestedConfigSnapshot ?? {}) } : stripGeographyFromRequestedConfig(backorder.requestedConfigSnapshot); sanitizedSnapshot = allowCustomerServerTypeSelection ? sanitizedSnapshot : stripServerTypeFromRequestedConfig(sanitizedSnapshot); + sanitizedSnapshot = allowCustomerProviderSelection + ? sanitizedSnapshot + : stripProviderFromRequestedConfig(sanitizedSnapshot); const baseConfig = plan.providerConfigDefaults ?? {}; const effectiveConfig: Record = { ...(baseConfig || {}), ...sanitizedSnapshot, }; + stripServerTypeByProviderFromConfig(effectiveConfig); + stripGeographyByProviderFromConfig(effectiveConfig); + try { const selection = resolveOrderProvisioningSelection(plan.providerConfigDefaults ?? {}, sanitizedSnapshot); @@ -143,21 +166,49 @@ export class BackorderService { throw new BadRequestException((error as Error).message); } - const provider = serviceType.provider; + if (allowCustomerProviderSelection) { + const requestedProvider = + typeof sanitizedSnapshot['provider'] === 'string' ? sanitizedSnapshot['provider'].trim() : ''; + + if (requestedProvider) { + const allowedError = assertProviderAllowed(requestedProvider, resolvePlanAllowedProviders(plan, serviceType)); + + if (allowedError) { + throw new BadRequestException(allowedError); + } + } + } - if (provider === 'hetzner' || provider === 'digital-ocean') { - const regionResolved = resolveProvisioningRegion(effectiveConfig, provider); + const provider = resolveEffectiveProvider(serviceType, plan, sanitizedSnapshot); + const primaryForProvisioningCheck = resolveServiceTypeAllowedProviders(serviceType)[0] ?? undefined; - mirrorGeographyInConfig(effectiveConfig, regionResolved); + if (!provider && this.providerCatalogDispatchService.requiresProvisioning(primaryForProvisioningCheck)) { + throw new BadRequestException('provider could not be resolved for this service type'); } - if (!effectiveConfig.serverType) { - effectiveConfig.serverType = provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'; + if (provider) { + effectiveConfig.provider = provider; } + if (provider && this.providerCatalogDispatchService.requiresProvisioning(provider)) { + const planDefaultGeo = resolveDefaultGeographyForProvider(baseConfig, provider); + const requestedGeo = readRequestedGeography(sanitizedSnapshot); + const geography = allowCustomerLocationSelection + ? requestedGeo || planDefaultGeo || resolveProvisioningRegion({}, provider) + : planDefaultGeo || resolveProvisioningRegion({}, provider); + + mirrorGeographyInConfig(effectiveConfig, geography); + } + + const planDefaultServerType = resolveDefaultServerTypeForProvider(baseConfig, provider); + const requestedServerType = + typeof sanitizedSnapshot['serverType'] === 'string' ? sanitizedSnapshot['serverType'].trim() : ''; + if (allowCustomerServerTypeSelection) { const allowed = normalizeAllowedServerTypes(plan.allowedServerTypes); - const resolvedServerType = String(effectiveConfig.serverType); + const resolvedServerType = String( + requestedServerType || planDefaultServerType || (provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'), + ); const serverTypeError = assertServerTypeAllowed(resolvedServerType, allowed); if (serverTypeError) { @@ -165,9 +216,13 @@ export class BackorderService { } effectiveConfig.serverType = resolvedServerType.trim(); + } else if (provider) { + effectiveConfig.serverType = planDefaultServerType ?? (provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'); } - const validationErrors = validateConfigSchema(serviceType.configSchema, effectiveConfig); + const schemaForValidation = + (provider ? this.providerRegistry.getProvider(provider)?.configSchema : undefined) ?? serviceType.configSchema; + const validationErrors = validateConfigSchema(schemaForValidation, effectiveConfig); if (validationErrors.length > 0) { throw new BadRequestException(validationErrors.join('; ')); @@ -201,11 +256,11 @@ export class BackorderService { effectiveConfig.env = resolvedCustomEnv; } - const region = resolveProvisioningRegion(effectiveConfig, provider); + const region = provider ? resolveProvisioningRegion(effectiveConfig, provider) : ''; const serverType = effectiveConfig.serverType as string; const providerDefaults = normalizeStoredProviderDefaults(serviceType.providerDefaults); - if (provider === 'hetzner' || provider === 'digital-ocean') { + if (provider && this.providerCatalogDispatchService.requiresProvisioning(provider)) { if (allowCustomerServerTypeSelection) { const billingBasePrice = await resolveServerTypePriceMonthly( this.providerServerTypesService, @@ -218,21 +273,21 @@ export class BackorderService { effectiveConfig[BILLING_BASE_PRICE_CONFIG_KEY] = billingBasePrice; } } - } - const availability = await this.availabilityService.checkAvailability( - provider, - region, - serverType, - providerDefaults, - ); + const availability = await this.availabilityService.checkAvailability( + provider, + region, + serverType, + providerDefaults, + ); - if (!availability.isAvailable) { - return await this.backordersRepository.update(backorderId, { - status: BackorderStatus.RETRYING, - failureReason: availability.reason, - preferredAlternatives: availability.alternatives ?? {}, - }); + if (!availability.isAvailable) { + return await this.backordersRepository.update(backorderId, { + status: BackorderStatus.RETRYING, + failureReason: availability.reason, + preferredAlternatives: availability.alternatives ?? {}, + }); + } } const schedule = this.billingScheduleService.calculateSchedule( @@ -255,9 +310,9 @@ export class BackorderService { configSnapshot: { ...effectiveConfig }, }); - if (serviceType.provider === 'hetzner' || serviceType.provider === 'digital-ocean') { + if (provider && this.providerCatalogDispatchService.requiresProvisioning(provider)) { let hostname: string | null = null; - const credentials = getProvisioningCredentials(serviceType.provider, serviceType.providerDefaults); + const credentials = getProvisioningCredentials(provider, serviceType.providerDefaults); try { hostname = await this.hostnameReservationService.reserveHostname(baseItem.id); @@ -281,22 +336,18 @@ export class BackorderService { firewallId: effectiveConfig.firewallId as number | undefined, userData, }; - const provisioned = await this.provisioningDispatchService.provision( - serviceType.provider, - provisioningConfig, - credentials, - ); + const provisioned = await this.provisioningDispatchService.provision(provider, provisioningConfig, credentials); if (provisioned?.serverId) { await this.subscriptionItemsRepository.updateProviderReference(baseItem.id, provisioned.serverId); await this.subscriptionItemsRepository.updateProvisioningStatus(baseItem.id, 'active'); const serverInfo = await this.provisioningDispatchService.getServerInfo( - serviceType.provider, + provider, provisioned.serverId, credentials, ); const publicIp = await this.provisioningDispatchService.ensurePublicIpForDns( - serviceType.provider, + provider, provisioned.serverId, serverInfo, credentials, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/cloud-init-config.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/cloud-init-config.service.ts index a121e475f..799f6eb3d 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/cloud-init-config.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/cloud-init-config.service.ts @@ -31,6 +31,7 @@ import { sanitizeCloudInitServiceTabs, type CloudInitConfigServiceTabDefinition, } from '../utils/service-detail-tabs.utils'; +import { resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; import { ProviderCatalogDispatchService } from './provider-catalog-dispatch.service'; @@ -395,7 +396,8 @@ export class CloudInitConfigService { ): Promise { const options = parsePlanProvisioningOptions(providerConfigDefaults); const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); - const requiresProvisioning = this.providerCatalogDispatchService.requiresProvisioning(serviceType.provider); + const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? undefined; + const requiresProvisioning = this.providerCatalogDispatchService.requiresProvisioning(primaryProvider); if (requiresProvisioning && options.length === 0) { throw new BadRequestException('At least one provisioning option is required for this service type'); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts index e5bd01e35..e3c43bad3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter-collect.job-handler.ts @@ -12,6 +12,8 @@ import { SubscriptionItemsRepository } from '../repositories/subscription-items. import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { UsageRecordsRepository } from '../repositories/usage-records.repository'; +import { resolveItemProvider } from '../utils/provider-selection.utils'; + import { AddonModuleRegistryService } from './addon-module-registry.service'; import { MeterService } from './meter.service'; import { ProviderModuleRegistryService } from './provider-module-registry.service'; @@ -105,7 +107,7 @@ export class MeterCollectJobHandler { return; } - const providerId = item.serviceType?.provider?.trim(); + const providerId = resolveItemProvider(item)?.trim(); if (!providerId) { return; @@ -244,7 +246,7 @@ export class MeterCollectJobHandler { continue; } - const providerId = item?.serviceType?.provider ?? ''; + const providerId = (item ? resolveItemProvider(item) : null) ?? ''; for (const window of dueWindows) { const samples = await module.collectMeters({ diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts index a7405679f..e8d1fe694 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/meter.service.ts @@ -16,6 +16,7 @@ import { ServicePlanMetersRepository } from '../repositories/service-plan-meters import { ServiceTypeMetersRepository } from '../repositories/service-type-meters.repository'; import { UsageRecordsRepository } from '../repositories/usage-records.repository'; import { resolveEffectiveUnitPriceNet } from '../utils/meter-aggregation.util'; +import { resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { mapMeterToSearchDocument } from '../search/billing-search-document.mapper'; import { BillingSearchIndexService } from '../search/billing-search-index.service'; @@ -156,7 +157,8 @@ export class MeterService { } async syncServiceTypeProviderMeters(serviceType: ServiceTypeEntity): Promise { - const provider = this.providerRegistry.getProvider(serviceType.provider); + const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? undefined; + const provider = primaryProvider ? this.providerRegistry.getProvider(primaryProvider) : undefined; const declared = provider?.meters ?? []; const declaredKeys = new Set(declared.map((item) => item.key.trim())); const existing = await this.serviceTypeMetersRepository.findByServiceTypeId(serviceType.id); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/service-plan-price-recalc.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/service-plan-price-recalc.service.ts index bf15cd85a..6839f2b05 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/service-plan-price-recalc.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/service-plan-price-recalc.service.ts @@ -10,6 +10,7 @@ import { SubscriptionAddonsRepository } from '../repositories/subscription-addon import { SubscriptionItemsRepository } from '../repositories/subscription-items.repository'; import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { normalizeStoredProviderDefaults } from '../utils/provider-env-defaults.utils'; +import { resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; import { roundMoney } from '../utils/promotion-advantage.util'; import { PRICE_RECALC_ADJUSTMENT_KINDS, @@ -181,7 +182,7 @@ export class ServicePlanPriceRecalcService { } async processPlan(plan: ServicePlanEntity, runDate: string, changedAt: Date): Promise { - const provider = plan.serviceType?.provider?.trim(); + const provider = resolveServiceTypeAllowedProviders(plan.serviceType ?? {})[0] ?? undefined; const providerDefaults = normalizeStoredProviderDefaults(plan.serviceType?.providerDefaults); const defaultServerType = this.resolvePlanPricingServerType(plan); const currentPlanBase = this.parseNumeric(plan.basePrice); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.job-handler.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.job-handler.ts index 216a6128e..fd7f5789c 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.job-handler.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.job-handler.ts @@ -12,6 +12,7 @@ import { SubscriptionConfigChangesRepository } from '../repositories/subscriptio import { SubscriptionItemsRepository } from '../repositories/subscription-items.repository'; import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { getProvisioningCredentials, normalizeStoredProviderDefaults } from '../utils/provider-env-defaults.utils'; +import { resolveItemProvider } from '../utils/provider-selection.utils'; import { BILLING_BASE_PRICE_CONFIG_KEY, resolveServerTypePriceMonthly } from '../utils/server-type-billing.utils'; import { AddonLifecycleService } from './addon-lifecycle.service'; @@ -272,7 +273,7 @@ export class SubscriptionConfigChangeJobHandler { return; } - const provider = item.serviceType?.provider?.trim(); + const provider = resolveItemProvider(item)?.trim(); if (!provider || !item.providerReference) { throw new Error(`Subscription item ${item.id} has no provisioned server to resize`); @@ -331,7 +332,7 @@ export class SubscriptionConfigChangeJobHandler { appliedSteps: Set; }): Promise { const { change, claimGeneration, subscription, plan, item, appliedSteps } = params; - const provider = item.serviceType?.provider?.trim() ?? ''; + const provider = resolveItemProvider(item)?.trim() ?? ''; for (const addonId of change.requestedPayload?.removeAddonIds ?? []) { const step = `${ADDON_REMOVE_STEP_PREFIX}${addonId}`; @@ -363,7 +364,7 @@ export class SubscriptionConfigChangeJobHandler { appliedSteps: Set; }): Promise { const { change, claimGeneration, subscription, plan, item, appliedSteps } = params; - const provider = item.serviceType?.provider?.trim() ?? ''; + const provider = resolveItemProvider(item)?.trim() ?? ''; const addonConfigs = change.requestedPayload?.addonConfigs; for (const addonId of change.requestedPayload?.addAddonIds ?? []) { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.spec.ts index e538d3ced..561f5fbe8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.spec.ts @@ -116,12 +116,32 @@ describe('SubscriptionConfigChangeService', () => { const eligibility = await service.getEligibility('sub-1', 'user-1'); expect(eligibility.canRequestChange).toBe(true); + expect(eligibility.provider).toBe('hetzner'); expect(eligibility.currentServerType).toBe('cx11'); expect(eligibility.allowedServerTypes).toEqual(['cx11', 'cx21', 'cpx11']); expect(eligibility.availableAddonIds).toEqual(['addon-1']); expect(eligibility.activeAddonIds).toEqual(['addon-2']); }); + it('getEligibility prefers configSnapshot.provider over service type primary', async () => { + subscriptionItemsRepository.findBySubscription.mockResolvedValue([ + { + id: 'item-1', + provisioningStatus: 'active', + configSnapshot: { provider: 'digital-ocean', serverType: 's-1vcpu-1gb', billingBasePrice: 5 }, + serviceType: { + provider: 'hetzner', + allowedProviders: ['hetzner', 'digital-ocean'], + providerDefaults: {}, + }, + }, + ]); + + const eligibility = await service.getEligibility('sub-1', 'user-1'); + + expect(eligibility.provider).toBe('digital-ocean'); + }); + it('getEligibility rejects subscriptions that are not active', async () => { subscriptionsRepository.findByIdOrThrow.mockResolvedValue({ id: 'sub-1', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.ts index a20ea7e0c..81e83d383 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.ts @@ -36,6 +36,7 @@ import { convertAddonPriceToPlanPeriod } from '../utils/addon-pricing.util'; import { parsePlanAllowedAddonIds, parsePlanMandatoryAddonIds } from '../utils/plan-addons.utils'; import { roundMoney } from '../utils/promotion-advantage.util'; import { normalizeStoredProviderDefaults } from '../utils/provider-env-defaults.utils'; +import { resolveItemProvider } from '../utils/provider-selection.utils'; import { assertServerTypeAllowed, normalizeAllowedServerTypes } from '../utils/provider-server-type.utils'; import { resolveServerTypePriceMonthly, @@ -194,7 +195,7 @@ export class SubscriptionConfigChangeService { ACTIVE_ADDON_STATUSES.has(row.status), ); const latestChange = await this.configChangesRepository.findLatestForSubscription(subscriptionId); - const serviceType = items.find((item) => item.serviceType)?.serviceType; + const itemWithServiceType = items.find((item) => item.serviceType); return { subscription, @@ -202,8 +203,8 @@ export class SubscriptionConfigChangeService { items, activeAddons, latestChange, - provider: serviceType?.provider, - providerDefaults: normalizeStoredProviderDefaults(serviceType?.providerDefaults), + provider: itemWithServiceType ? (resolveItemProvider(itemWithServiceType) ?? undefined) : undefined, + providerDefaults: normalizeStoredProviderDefaults(itemWithServiceType?.serviceType?.providerDefaults), currentServerType: this.resolveCurrentServerType(items), }; } @@ -245,6 +246,7 @@ export class SubscriptionConfigChangeService { reasonCode, reason, hasPendingChange, + provider: context.provider, currentServerType: context.currentServerType, allowedServerTypes: context.plan.allowCustomerServerTypeSelection === true @@ -260,7 +262,7 @@ export class SubscriptionConfigChangeService { /** Cloud servers must finish first-time provisioning before mid-life config changes. */ private hasIncompleteInitialServerProvisioning(items: SubscriptionItemEntity[]): boolean { return items.some((item) => { - const provider = item.serviceType?.provider?.trim(); + const provider = resolveItemProvider(item)?.trim(); if (!this.providerCatalogDispatchService.requiresProvisioning(provider)) { return false; 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 1b17d9bdb..782a05465 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 @@ -23,6 +23,7 @@ import { } from '../utils/cloud-init/integrated-provisioning-service'; import { CONTAINER_MANAGER_MODULE_KEY } from '../utils/plan-addons.utils'; import { getProvisioningCredentials } from '../utils/provider-env-defaults.utils'; +import { resolveItemProvider } from '../utils/provider-selection.utils'; import { ServerInfo } from '../utils/provisioning.utils'; import { appendServiceTabs, @@ -317,7 +318,7 @@ export class SubscriptionItemServerService { }; const hostname = item.hostname; const hostnameFqdn = hostname ? this.cloudflareDnsService.getFqdn(hostname) : undefined; - const provider = item.serviceType?.provider ?? null; + const provider = resolveItemProvider(item); let serverInfo = item.serverInfoSnapshot ? mapServerInfoSnapshotToResponse(item.serverInfoSnapshot, hostname, hostnameFqdn) : undefined; @@ -457,7 +458,7 @@ export class SubscriptionItemServerService { private async fetchLiveServerInfo( item: Awaited> & object, ): Promise { - const provider = item.serviceType?.provider; + const provider = resolveItemProvider(item); if (!provider || !item.providerReference) { return undefined; @@ -534,7 +535,7 @@ export class SubscriptionItemServerService { this.assertProvisioned(item.providerReference, item.provisioningStatus); - if (!item.serviceType?.provider) { + if (!resolveItemProvider(item)) { throw new BadRequestException('Service type has no provider'); } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-update.job-handler.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-update.job-handler.ts index 9e0a5a11d..1f6b3e6d5 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-update.job-handler.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-item-update.job-handler.ts @@ -4,6 +4,7 @@ import { SubscriptionItemsRepository } from '../repositories/subscription-items. import { CloudInitServiceType, normalizeCloudInitService } from '../utils/cloud-init/cloud-init-dispatch.utils'; import { canonicalizeIntegratedProvisioningService } from '../utils/cloud-init/integrated-provisioning-service'; import { getProvisioningCredentials } from '../utils/provider-env-defaults.utils'; +import { resolveItemProvider } from '../utils/provider-selection.utils'; import { IntegratedStackRegistryService } from './integrated-stack-registry.service'; import { ProvisioningDispatchService } from './provisioning-dispatch.service'; @@ -36,7 +37,7 @@ export class SubscriptionItemUpdateJobHandler { throw new Error(`Subscription item ${subscriptionItemId} not found`); } - const provider = item.serviceType?.provider; + const provider = resolveItemProvider(item); if (!provider || !item.providerReference || !item.sshPrivateKey) { return; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-teardown.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-teardown.service.ts index bc9273dc4..52796c78b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-teardown.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-teardown.service.ts @@ -7,6 +7,7 @@ import { ServicePlansRepository } from '../repositories/service-plans.repository import { SubscriptionItemsRepository } from '../repositories/subscription-items.repository'; import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { getProvisioningCredentials } from '../utils/provider-env-defaults.utils'; +import { resolveItemProvider as resolveSubscriptionItemProvider } from '../utils/provider-selection.utils'; import { BillingNotificationPublisher } from '../notifications/billing-notification.publisher'; import { CloudflareDnsService } from './cloudflare-dns.service'; @@ -120,8 +121,10 @@ export class SubscriptionTeardownService { const providerByItemId = new Map(); for (const item of items) { - if (item.serviceType?.provider) { - providerByItemId.set(item.id, item.serviceType.provider); + const provider = this.resolveItemProvider(item); + + if (provider) { + providerByItemId.set(item.id, provider); } } @@ -149,14 +152,12 @@ export class SubscriptionTeardownService { } } - if (item.providerReference && item.serviceType?.provider) { + const provider = this.resolveItemProvider(item); + + if (item.providerReference && provider && item.serviceType) { try { - const credentials = getProvisioningCredentials(item.serviceType.provider, item.serviceType.providerDefaults); - await this.provisioningDispatchService.deprovision( - item.serviceType.provider, - item.providerReference, - credentials, - ); + const credentials = getProvisioningCredentials(provider, item.serviceType.providerDefaults); + await this.provisioningDispatchService.deprovision(provider, item.providerReference, credentials); await this.subscriptionItemsRepository.clearProviderReference(item.id); this.billingNotificationPublisher.publish( 'subscription.service.removed', @@ -204,4 +205,11 @@ export class SubscriptionTeardownService { await this.billingEmailPublisher.publishSubscriptionCanceled(canceled, plan.name); } } + + private resolveItemProvider(item: { + configSnapshot?: Record | null; + serviceType?: { provider?: string | null; allowedProviders?: string[] | null } | null; + }): string | null { + return resolveSubscriptionItemProvider(item); + } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts index bb7825dc4..087177fec 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription.service.spec.ts @@ -212,6 +212,7 @@ describe('SubscriptionService', () => { { buildSubscriptionMeterSummaries: jest.fn().mockResolvedValue([]) } as never, { scheduleUpsert: jest.fn(), scheduleDelete: jest.fn() } as never, cloudInitDispatchService as never, + { getProvider: jest.fn().mockReturnValue(undefined) } as never, ); beforeEach(() => { @@ -1289,6 +1290,45 @@ describe('SubscriptionService', () => { expect(cloudflareDnsService.createARecord).not.toHaveBeenCalled(); }); + it('provisions using configSnapshot.provider over service type primary', async () => { + (itemsRepository.findByIdWithRelations as jest.Mock).mockResolvedValue({ + id: 'item-1', + serviceTypeId: 'stype-1', + provisioningStatus: 'pending', + providerReference: undefined, + configSnapshot: { + ...controllerProvisioningDefaults, + provider: 'digital-ocean', + region: 'fra1', + serverType: 's-1vcpu-1gb', + }, + subscription: { + id: 'sub-1', + userId: 'user-1', + planId: 'plan-1', + status: SubscriptionStatus.ACTIVE, + autoBackorder: false, + }, + serviceType: { + id: 'stype-1', + provider: 'hetzner', + allowedProviders: ['hetzner', 'digital-ocean'], + }, + }); + (provisioningService.provision as jest.Mock).mockResolvedValue({ serverId: 'srv-do-1' }); + + await service.provisionSubscriptionItem('item-1'); + + expect(provisioningService.provision).toHaveBeenCalledWith( + 'digital-ocean', + expect.objectContaining({ + serverType: 's-1vcpu-1gb', + location: 'fra1', + }), + expect.any(Object), + ); + }); + it('skips SSH readiness for non-server providers', async () => { (itemsRepository.findByIdWithRelations as jest.Mock).mockResolvedValue({ id: 'item-1', 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 7d2bbc96e..8b5811c43 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 @@ -22,14 +22,27 @@ import { import { validateConfigSchema } from '../utils/config-validation.utils'; import { mirrorGeographyInConfig, + readRequestedGeography, + resolveDefaultGeographyForProvider, resolveProvisioningRegion, + stripGeographyByProviderFromConfig, stripGeographyFromRequestedConfig, } from '../utils/provider-location.utils'; import { assertServerTypeAllowed, normalizeAllowedServerTypes, + resolveDefaultServerTypeForProvider, + stripServerTypeByProviderFromConfig, stripServerTypeFromRequestedConfig, } from '../utils/provider-server-type.utils'; +import { + assertProviderAllowed, + resolveEffectiveProvider, + resolveItemProvider, + resolvePlanAllowedProviders, + resolveServiceTypeAllowedProviders, + stripProviderFromRequestedConfig, +} from '../utils/provider-selection.utils'; import { BILLING_BASE_PRICE_CONFIG_KEY, buildBackorderRequestedConfigSnapshot, @@ -57,6 +70,7 @@ import { HostnameReservationService } from './hostname-reservation.service'; import { ProviderServerTypesService } from './provider-server-types.service'; import { PricingService } from './pricing.service'; import { ProviderCatalogDispatchService } from './provider-catalog-dispatch.service'; +import { ProviderRegistryService } from './provider-registry.service'; import { ProvisioningDispatchService } from './provisioning-dispatch.service'; import { PromotionRedemptionService } from './promotion-redemption.service'; import { SshExecutorService } from './ssh-executor.service'; @@ -118,6 +132,7 @@ export class SubscriptionService { private readonly meterBillingService: MeterBillingService, private readonly billingSearchIndexService: BillingSearchIndexService, private readonly cloudInitDispatchService: CloudInitDispatchService, + private readonly providerRegistry: ProviderRegistryService, ) {} async createSubscription( @@ -160,21 +175,30 @@ export class SubscriptionService { plan.serviceTypeId, parsePlanAllowedAddonIds(plan.providerConfigDefaults), selectedAddonIds, + requestedConfig, + plan, ); const allowCustomerLocationSelection = plan.allowCustomerLocationSelection === true; const allowCustomerServerTypeSelection = plan.allowCustomerServerTypeSelection === true; + const allowCustomerProviderSelection = plan.allowCustomerProviderSelection === true; let sanitizedRequested = allowCustomerLocationSelection ? { ...(requestedConfig ?? {}) } : stripGeographyFromRequestedConfig(requestedConfig); sanitizedRequested = allowCustomerServerTypeSelection ? sanitizedRequested : stripServerTypeFromRequestedConfig(sanitizedRequested); + sanitizedRequested = allowCustomerProviderSelection + ? sanitizedRequested + : stripProviderFromRequestedConfig(sanitizedRequested); const baseConfig = plan.providerConfigDefaults ?? {}; const effectiveConfig: Record = { ...(baseConfig || {}), ...sanitizedRequested, }; + stripServerTypeByProviderFromConfig(effectiveConfig); + stripGeographyByProviderFromConfig(effectiveConfig); + try { const selection = resolveOrderProvisioningSelection(baseConfig, sanitizedRequested); @@ -183,27 +207,57 @@ export class SubscriptionService { throw new BadRequestException((error as Error).message); } - const provider = serviceType.provider; + if (allowCustomerProviderSelection) { + const requestedProvider = + typeof sanitizedRequested['provider'] === 'string' ? sanitizedRequested['provider'].trim() : ''; + + if (requestedProvider) { + const allowedError = assertProviderAllowed(requestedProvider, resolvePlanAllowedProviders(plan, serviceType)); + + if (allowedError) { + throw new BadRequestException(allowedError); + } + } + } + + const provider = resolveEffectiveProvider(serviceType, plan, sanitizedRequested); + const primaryForProvisioningCheck = resolveServiceTypeAllowedProviders(serviceType)[0] ?? undefined; + + if (!provider && this.providerCatalogDispatchService.requiresProvisioning(primaryForProvisioningCheck)) { + throw new BadRequestException('provider could not be resolved for this service type'); + } + + if (provider) { + effectiveConfig.provider = provider; + } - if (this.providerCatalogDispatchService.requiresProvisioning(provider)) { - const regionResolved = resolveProvisioningRegion(effectiveConfig, provider); + if (this.providerCatalogDispatchService.requiresProvisioning(provider ?? undefined)) { + const planDefaultGeo = resolveDefaultGeographyForProvider(baseConfig, provider); + const requestedGeo = readRequestedGeography(sanitizedRequested); + const geography = allowCustomerLocationSelection + ? requestedGeo || planDefaultGeo || resolveProvisioningRegion({}, provider!) + : planDefaultGeo || resolveProvisioningRegion({}, provider!); - mirrorGeographyInConfig(effectiveConfig, regionResolved); + mirrorGeographyInConfig(effectiveConfig, geography); } - const validationErrors = validateConfigSchema(serviceType.configSchema, effectiveConfig); + const schemaForValidation = + (provider ? this.providerRegistry.getProvider(provider)?.configSchema : undefined) ?? serviceType.configSchema; + const validationErrors = validateConfigSchema(schemaForValidation, effectiveConfig); if (validationErrors.length > 0) { throw new BadRequestException(validationErrors.join('; ')); } - if (this.providerCatalogDispatchService.requiresProvisioning(provider)) { + if (this.providerCatalogDispatchService.requiresProvisioning(provider ?? undefined)) { + const planDefaultServerType = resolveDefaultServerTypeForProvider(baseConfig, provider); + const requestedServerType = + typeof sanitizedRequested['serverType'] === 'string' ? sanitizedRequested['serverType'].trim() : ''; + if (allowCustomerServerTypeSelection) { const allowed = normalizeAllowedServerTypes(plan.allowedServerTypes); const resolvedServerType = String( - effectiveConfig.serverType ?? - baseConfig['serverType'] ?? - (provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'), + requestedServerType || planDefaultServerType || (provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'), ); const serverTypeError = assertServerTypeAllowed(resolvedServerType, allowed); @@ -212,8 +266,8 @@ export class SubscriptionService { } effectiveConfig.serverType = resolvedServerType.trim(); - } else if (!effectiveConfig.serverType) { - effectiveConfig.serverType = provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'; + } else { + effectiveConfig.serverType = planDefaultServerType ?? (provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'); } } @@ -245,12 +299,13 @@ export class SubscriptionService { effectiveConfig.env = resolvedCustomEnv; } - const region = resolveProvisioningRegion(effectiveConfig, provider); - const serverType = - (effectiveConfig.serverType as string | undefined) ?? (provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'); const providerDefaults = normalizeStoredProviderDefaults(serviceType.providerDefaults); - if (this.providerCatalogDispatchService.requiresProvisioning(provider)) { + if (provider && this.providerCatalogDispatchService.requiresProvisioning(provider)) { + const region = resolveProvisioningRegion(effectiveConfig, provider); + const serverType = + (effectiveConfig.serverType as string | undefined) ?? (provider === 'digital-ocean' ? 's-1vcpu-1gb' : 'cx11'); + if (allowCustomerServerTypeSelection) { const billingBasePrice = await resolveServerTypePriceMonthly( this.providerServerTypesService, @@ -263,28 +318,28 @@ export class SubscriptionService { effectiveConfig[BILLING_BASE_PRICE_CONFIG_KEY] = billingBasePrice; } } - } - const availability = await this.availabilityService.checkAvailability( - provider, - region, - serverType, - providerDefaults, - ); + const availability = await this.availabilityService.checkAvailability( + provider, + region, + serverType, + providerDefaults, + ); - if (!availability.isAvailable) { - if (autoBackorder) { - await this.backorderService.create({ - userId, - serviceTypeId: plan.serviceTypeId, - planId, - requestedConfigSnapshot: buildBackorderRequestedConfigSnapshot(sanitizedRequested, effectiveConfig), - providerErrors: { reason: availability.reason }, - preferredAlternatives: availability.alternatives ?? {}, - }); - } + if (!availability.isAvailable) { + if (autoBackorder) { + await this.backorderService.create({ + userId, + serviceTypeId: plan.serviceTypeId, + planId, + requestedConfigSnapshot: buildBackorderRequestedConfigSnapshot(sanitizedRequested, effectiveConfig), + providerErrors: { reason: availability.reason }, + preferredAlternatives: availability.alternatives ?? {}, + }); + } - throw new BadRequestException(availability.reason || 'Configuration not available'); + throw new BadRequestException(availability.reason || 'Configuration not available'); + } } const schedule = this.billingScheduleService.calculateSchedule( @@ -480,7 +535,7 @@ export class SubscriptionService { } const subscription = item.subscription; - const provider = item.serviceType?.provider; + const provider = resolveItemProvider(item); if (!subscription || subscription.status !== SubscriptionStatus.ACTIVE) { this.logger.log(`Skipping provisioning for item ${itemId}; subscription is not active`); @@ -488,7 +543,7 @@ export class SubscriptionService { return; } - if (!this.providerCatalogDispatchService.requiresProvisioning(provider)) { + if (!provider || !this.providerCatalogDispatchService.requiresProvisioning(provider)) { // Nothing to provision for non-server providers; treat the item as fulfilled. await this.subscriptionItemsRepository.updateProvisioningStatus(itemId, 'active'); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-env-defaults.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-env-defaults.utils.ts index fcf4e9a1a..e70eb9b7a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-env-defaults.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-env-defaults.utils.ts @@ -17,14 +17,43 @@ export interface ProvisioningCredentials { apiToken?: string; } -export function getProviderEnvDefaultFields(providerId: string): ProviderEnvDefaultField[] { +export function getProviderEnvDefaultFields(providerId: string | null | undefined): ProviderEnvDefaultField[] { + if (!providerId?.trim()) { + return []; + } + return PROVIDER_ENV_FIELDS[providerId] ?? []; } -export function getProviderEnvDefaultFieldKeys(providerId: string): string[] { +/** + * Union of env default fields across one or more providers (deduped by envKey, order preserved). + */ +export function getProvidersEnvDefaultFields(providerIds: Array): ProviderEnvDefaultField[] { + const seen = new Set(); + const out: ProviderEnvDefaultField[] = []; + + for (const providerId of providerIds) { + for (const field of getProviderEnvDefaultFields(providerId)) { + if (seen.has(field.envKey)) { + continue; + } + + seen.add(field.envKey); + out.push(field); + } + } + + return out; +} + +export function getProviderEnvDefaultFieldKeys(providerId: string | null | undefined): string[] { return getProviderEnvDefaultFields(providerId).map((field) => field.envKey); } +export function getProvidersEnvDefaultFieldKeys(providerIds: Array): string[] { + return getProvidersEnvDefaultFields(providerIds).map((field) => field.envKey); +} + export function sanitizeProviderDefaults( input: Record | undefined | null, allowedKeys: string[], diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.spec.ts index 3a264418f..29bed2f3a 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.spec.ts @@ -3,8 +3,12 @@ import { getGeographyEnumFromSchema, getGeographyFieldKeyFromSchema, mirrorGeographyInConfig, + normalizeGeographyByProvider, providerConfigSchemaSupportsLocationSelection, + readRequestedGeography, + resolveDefaultGeographyForProvider, resolveProvisioningRegion, + stripGeographyByProviderFromConfig, stripGeographyFromRequestedConfig, } from './provider-location.utils'; @@ -66,14 +70,39 @@ describe('provider-location.utils', () => { expect(out).toEqual({ x: 1 }); }); - it('effectiveSchemaSupportsLocationSelection uses provider schema when service type schema is empty', () => { - expect(effectiveSchemaSupportsLocationSelection({}, schemaWithRegion)).toBe(true); - expect(effectiveSchemaSupportsLocationSelection({ properties: {} }, schemaWithRegion)).toBe(true); + it('effectiveSchemaSupportsLocationSelection falls back to provider schema', () => { + const st = { properties: { region: { type: 'string', enum: ['a'] } } }; + + expect(effectiveSchemaSupportsLocationSelection({}, st)).toBe(true); }); - it('effectiveSchemaSupportsLocationSelection prefers service type schema when present', () => { - const st = { properties: { region: { type: 'string', enum: ['a'] } } }; + it('resolveDefaultGeographyForProvider prefers per-provider map', () => { + expect( + resolveDefaultGeographyForProvider( + { + location: 'fsn1', + geographyByProvider: { hetzner: 'nbg1', 'digital-ocean': 'fra1' }, + }, + 'digital-ocean', + ), + ).toBe('fra1'); + }); + + it('normalizeGeographyByProvider and stripGeographyByProviderFromConfig', () => { + expect(normalizeGeographyByProvider({ hetzner: ' fsn1 ', '': 'x' })).toEqual({ hetzner: 'fsn1' }); + + const config: Record = { + location: 'fsn1', + geographyByProvider: { hetzner: 'fsn1' }, + }; + + stripGeographyByProviderFromConfig(config); + expect(config).toEqual({ location: 'fsn1' }); + }); - expect(effectiveSchemaSupportsLocationSelection(st, undefined)).toBe(true); + it('readRequestedGeography prefers region then location', () => { + expect(readRequestedGeography({ region: 'fra1', location: 'fsn1' })).toBe('fra1'); + expect(readRequestedGeography({ location: 'nbg1' })).toBe('nbg1'); + expect(readRequestedGeography({})).toBe(''); }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.ts index f24f2e8fe..2e70dcb17 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-location.utils.ts @@ -102,6 +102,87 @@ export function mirrorGeographyInConfig(config: Record, value: config['location'] = value; } +/** Admin-only map on providerConfigDefaults: provider id → default geography (location/region) id. */ +export const GEOGRAPHY_BY_PROVIDER_KEY = 'geographyByProvider'; + +/** + * Remove admin-only geographyByProvider from a merged effective config. + */ +export function stripGeographyByProviderFromConfig(config: Record): void { + delete config[GEOGRAPHY_BY_PROVIDER_KEY]; +} + +/** + * Normalize provider → geography map. + */ +export function normalizeGeographyByProvider(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const out: Record = {}; + + for (const [providerId, geography] of Object.entries(value as Record)) { + const provider = typeof providerId === 'string' ? providerId.trim() : ''; + const geo = typeof geography === 'string' ? geography.trim() : ''; + + if (!provider || !geo) { + continue; + } + + out[provider] = geo; + } + + return out; +} + +/** + * Default geography for a provider: geographyByProvider[provider], then region, then location. + */ +export function resolveDefaultGeographyForProvider( + providerConfigDefaults: Record | null | undefined, + providerId: string | null | undefined, +): string | null { + const defaults = providerConfigDefaults ?? {}; + const provider = typeof providerId === 'string' ? providerId.trim() : ''; + const byProvider = normalizeGeographyByProvider(defaults[GEOGRAPHY_BY_PROVIDER_KEY]); + + if (provider && byProvider[provider]) { + return byProvider[provider]; + } + + const region = defaults['region']; + const location = defaults['location']; + + if (typeof region === 'string' && region.trim()) { + return region.trim(); + } + + if (typeof location === 'string' && location.trim()) { + return location.trim(); + } + + return null; +} + +/** + * Geography from a request overlay only (ignores plan defaults merged into effective config). + */ +export function readRequestedGeography(requestedConfig: Record | undefined): string { + const region = requestedConfig?.['region']; + const location = requestedConfig?.['location']; + + if (typeof region === 'string' && region.trim()) { + return region.trim(); + } + + if (typeof location === 'string' && location.trim()) { + return location.trim(); + } + + return ''; +} + /** * Shallow copy of requestedConfig without geography keys when customer selection is disabled. */ diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.spec.ts new file mode 100644 index 000000000..e81952afe --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.spec.ts @@ -0,0 +1,192 @@ +import { + HOST_CLOUD_INIT_COMPATIBILITY_GROUP, + allowedProvidersEqual, + assertProviderAllowed, + assertProvidersCompatible, + normalizeAllowedProviders, + providersAreCompatible, + resolveCompatibilityGroup, + resolveEffectiveProvider, + resolvePlanAllowedProviders, + resolvePrimaryProvider, + resolveServiceTypeAllowedProviders, + resolveItemProvider, + stripProviderFromRequestedConfig, +} from './provider-selection.utils'; + +describe('provider-selection.utils', () => { + describe('resolveCompatibilityGroup', () => { + it('uses configured group when present', () => { + expect( + resolveCompatibilityGroup({ id: 'hetzner', compatibilityGroup: HOST_CLOUD_INIT_COMPATIBILITY_GROUP }), + ).toBe(HOST_CLOUD_INIT_COMPATIBILITY_GROUP); + }); + + it('falls back to self scope when group missing', () => { + expect(resolveCompatibilityGroup({ id: 'custom' })).toBe('self:custom'); + expect(resolveCompatibilityGroup({ id: 'custom', compatibilityGroup: ' ' })).toBe('self:custom'); + }); + }); + + describe('providersAreCompatible', () => { + it('matches same group', () => { + expect( + providersAreCompatible( + { id: 'hetzner', compatibilityGroup: HOST_CLOUD_INIT_COMPATIBILITY_GROUP }, + { id: 'digital-ocean', compatibilityGroup: HOST_CLOUD_INIT_COMPATIBILITY_GROUP }, + ), + ).toBe(true); + }); + + it('rejects different self scopes', () => { + expect(providersAreCompatible({ id: 'a' }, { id: 'b' })).toBe(false); + }); + }); + + describe('normalizeAllowedProviders / resolvePrimaryProvider', () => { + it('deduplicates and preserves order', () => { + expect(normalizeAllowedProviders([' hetzner ', 'digital-ocean', 'hetzner', '', 1])).toEqual([ + 'hetzner', + 'digital-ocean', + ]); + }); + + it('returns null primary for empty list', () => { + expect(resolvePrimaryProvider([])).toBeNull(); + expect(resolvePrimaryProvider(['hetzner', 'digital-ocean'])).toBe('hetzner'); + }); + }); + + describe('resolveServiceTypeAllowedProviders', () => { + it('prefers allowedProviders and falls back to provider', () => { + expect( + resolveServiceTypeAllowedProviders({ + provider: 'hetzner', + allowedProviders: ['digital-ocean', 'hetzner'], + }), + ).toEqual(['digital-ocean', 'hetzner']); + expect(resolveServiceTypeAllowedProviders({ provider: 'hetzner', allowedProviders: [] })).toEqual(['hetzner']); + expect(resolveServiceTypeAllowedProviders({ provider: null, allowedProviders: [] })).toEqual([]); + }); + }); + + describe('resolvePlanAllowedProviders / resolveEffectiveProvider', () => { + const serviceType = { provider: 'hetzner', allowedProviders: ['hetzner', 'digital-ocean'] }; + + it('uses pinned plan provider when customer selection is off', () => { + expect( + resolvePlanAllowedProviders( + { allowCustomerProviderSelection: false, allowedProviders: ['digital-ocean'] }, + serviceType, + ), + ).toEqual(['digital-ocean']); + expect( + resolveEffectiveProvider( + serviceType, + { allowCustomerProviderSelection: false, allowedProviders: ['digital-ocean'] }, + {}, + ), + ).toBe('digital-ocean'); + }); + + it('falls back to type primary when customer selection is off and plan has no pin', () => { + expect(resolvePlanAllowedProviders({ allowCustomerProviderSelection: false }, serviceType)).toEqual(['hetzner']); + expect(resolveEffectiveProvider(serviceType, { allowCustomerProviderSelection: false }, {})).toBe('hetzner'); + }); + + it('intersects plan allowlist when customer selection is on', () => { + expect( + resolvePlanAllowedProviders( + { allowCustomerProviderSelection: true, allowedProviders: ['digital-ocean', 'unknown'] }, + serviceType, + ), + ).toEqual(['digital-ocean']); + }); + + it('honors requested provider when allowed', () => { + expect( + resolveEffectiveProvider( + serviceType, + { allowCustomerProviderSelection: true, allowedProviders: ['hetzner', 'digital-ocean'] }, + { provider: 'digital-ocean' }, + ), + ).toBe('digital-ocean'); + }); + + it('returns null when requested provider is not allowed', () => { + expect( + resolveEffectiveProvider( + serviceType, + { allowCustomerProviderSelection: true, allowedProviders: ['hetzner'] }, + { provider: 'digital-ocean' }, + ), + ).toBeNull(); + }); + }); + + describe('resolveItemProvider', () => { + it('prefers configSnapshot.provider over service type primary', () => { + expect( + resolveItemProvider({ + configSnapshot: { provider: 'digital-ocean' }, + serviceType: { provider: 'hetzner', allowedProviders: ['hetzner', 'digital-ocean'] }, + }), + ).toBe('digital-ocean'); + }); + + it('falls back to service type allowlist primary when snapshot has no provider', () => { + expect( + resolveItemProvider({ + configSnapshot: { serverType: 'cx11' }, + serviceType: { provider: 'hetzner', allowedProviders: ['digital-ocean', 'hetzner'] }, + }), + ).toBe('digital-ocean'); + expect(resolveItemProvider({ serviceType: { provider: 'hetzner', allowedProviders: [] } })).toBe('hetzner'); + expect(resolveItemProvider({ configSnapshot: {}, serviceType: null })).toBeNull(); + }); + }); + + describe('strip / assert', () => { + it('strips provider from requested config', () => { + expect(stripProviderFromRequestedConfig({ provider: 'hetzner', serverType: 'cx11' })).toEqual({ + serverType: 'cx11', + }); + }); + + it('asserts allowed provider', () => { + expect(assertProviderAllowed('hetzner', ['hetzner'])).toBeNull(); + expect(assertProviderAllowed('digital-ocean', ['hetzner'])).toContain('not allowed'); + expect(assertProviderAllowed('', ['hetzner'])).toContain('required'); + }); + }); + + describe('assertProvidersCompatible', () => { + const lookup = (id: string) => { + if (id === 'hetzner' || id === 'digital-ocean') { + return { id, compatibilityGroup: HOST_CLOUD_INIT_COMPATIBILITY_GROUP }; + } + + if (id === 'other') { + return { id, compatibilityGroup: 'other-group' }; + } + + return undefined; + }; + + it('accepts compatible set', () => { + expect(assertProvidersCompatible(['hetzner', 'digital-ocean'], lookup)).toBeNull(); + }); + + it('rejects unknown and incompatible', () => { + expect(assertProvidersCompatible(['missing'], lookup)).toContain('Unknown'); + expect(assertProvidersCompatible(['hetzner', 'other'], lookup)).toContain('compatibility group'); + }); + }); + + describe('allowedProvidersEqual', () => { + it('compares normalized lists', () => { + expect(allowedProvidersEqual(['a', 'b'], [' a ', 'b'])).toBe(true); + expect(allowedProvidersEqual(['a', 'b'], ['b', 'a'])).toBe(false); + }); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.ts new file mode 100644 index 000000000..38e82be1d --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-selection.utils.ts @@ -0,0 +1,260 @@ +/** + * Customer / admin multi-provider selection helpers. + * Service types own the interchangeable set; plans may further restrict and opt into customer choice. + */ + +export const HOST_CLOUD_INIT_COMPATIBILITY_GROUP = 'host-cloud-init'; + +export interface ProviderCompatibilityInfo { + id: string; + compatibilityGroup?: string | null; +} + +/** + * Effective compatibility key for interchange filtering. + * Missing/empty group ⇒ provider is only compatible with itself. + */ +export function resolveCompatibilityGroup(provider: ProviderCompatibilityInfo): string { + const group = provider.compatibilityGroup?.trim(); + + if (group) { + return group; + } + + return `self:${provider.id}`; +} + +/** + * True when two providers may be selected together on one service type. + */ +export function providersAreCompatible(a: ProviderCompatibilityInfo, b: ProviderCompatibilityInfo): boolean { + return resolveCompatibilityGroup(a) === resolveCompatibilityGroup(b); +} + +/** + * Normalizes allowed provider ids: non-empty strings only, deduplicated, order preserved. + * First entry is the primary provider. + */ +export function normalizeAllowedProviders(values: unknown): string[] { + if (!Array.isArray(values)) return []; + + const seen = new Set(); + const out: string[] = []; + + for (const value of values) { + if (typeof value !== 'string') continue; + + const trimmed = value.trim(); + + if (!trimmed || seen.has(trimmed)) continue; + + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} + +/** + * Resolve primary provider from an allowlist (first entry), or null when empty (None). + */ +export function resolvePrimaryProvider(allowedProviders: string[]): string | null { + const normalized = normalizeAllowedProviders(allowedProviders); + + return normalized[0] ?? null; +} + +/** + * Service type effective allowlist: prefer allowedProviders; fall back to legacy single provider. + */ +export function resolveServiceTypeAllowedProviders(serviceType: { + provider?: string | null; + allowedProviders?: string[] | null; +}): string[] { + const fromList = normalizeAllowedProviders(serviceType.allowedProviders); + + if (fromList.length > 0) { + return fromList; + } + + const primary = serviceType.provider?.trim(); + + return primary ? [primary] : []; +} + +/** + * Plan effective allowlist: + * - Customer selection on → plan subset (or full type list if plan list empty). + * - Customer selection off → plan's pinned provider(s) if set, else the type primary only. + */ +export function resolvePlanAllowedProviders( + plan: { + allowCustomerProviderSelection?: boolean | null; + allowedProviders?: string[] | null; + }, + serviceType: { + provider?: string | null; + allowedProviders?: string[] | null; + }, +): string[] { + const typeAllowed = resolveServiceTypeAllowedProviders(serviceType); + const planAllowed = normalizeAllowedProviders(plan.allowedProviders).filter((id) => typeAllowed.includes(id)); + + if (plan.allowCustomerProviderSelection === true) { + if (planAllowed.length === 0) { + return typeAllowed; + } + + return planAllowed; + } + + if (planAllowed.length > 0) { + return planAllowed; + } + + return typeAllowed[0] ? [typeAllowed[0]] : []; +} + +/** + * Shallow copy of requestedConfig without provider when customer selection is disabled. + */ +export function stripProviderFromRequestedConfig( + requestedConfig: Record | undefined, +): Record { + const src = requestedConfig ?? {}; + const out: Record = { ...src }; + + delete out['provider']; + + return out; +} + +/** + * Returns an error message when provider is not in the allowed list, or null when valid. + */ +export function assertProviderAllowed(provider: string, allowedProviders: string[]): string | null { + const trimmed = provider?.trim(); + + if (!trimmed) { + return 'provider is required'; + } + + if (!Array.isArray(allowedProviders) || allowedProviders.length === 0) { + return 'No providers are configured for customer selection on this plan'; + } + + if (!allowedProviders.includes(trimmed)) { + return `provider "${trimmed}" is not allowed for this plan`; + } + + return null; +} + +/** + * Resolve the effective cloud provider for an order / availability check. + * Returns null when the service type has no providers (None). + */ +export function resolveEffectiveProvider( + serviceType: { + provider?: string | null; + allowedProviders?: string[] | null; + }, + plan: { + allowCustomerProviderSelection?: boolean | null; + allowedProviders?: string[] | null; + }, + requestedConfig?: Record | undefined, +): string | null { + const allowed = resolvePlanAllowedProviders(plan, serviceType); + + if (allowed.length === 0) { + return null; + } + + if (plan.allowCustomerProviderSelection === true) { + const requested = typeof requestedConfig?.['provider'] === 'string' ? requestedConfig['provider'].trim() : ''; + + if (requested) { + return allowed.includes(requested) ? requested : null; + } + } + + return allowed[0] ?? null; +} + +/** + * True when every provider id is registered and all share one compatibility group. + */ +export function assertProvidersCompatible( + providerIds: string[], + lookup: (id: string) => ProviderCompatibilityInfo | undefined, +): string | null { + const normalized = normalizeAllowedProviders(providerIds); + + if (normalized.length === 0) { + return null; + } + + const details: ProviderCompatibilityInfo[] = []; + + for (const id of normalized) { + const detail = lookup(id); + + if (!detail) { + return `Unknown provider "${id}"`; + } + + details.push({ id: detail.id, compatibilityGroup: detail.compatibilityGroup }); + } + + const firstGroup = resolveCompatibilityGroup(details[0]); + + for (let i = 1; i < details.length; i++) { + if (resolveCompatibilityGroup(details[i]) !== firstGroup) { + return `Providers must share the same compatibility group (incompatible: "${details[0].id}" and "${details[i].id}")`; + } + } + + return null; +} + +/** + * Resolve the cloud provider for an existing subscription item. + * Prefer configSnapshot.provider (customer choice at order), then service type allowlist primary. + */ +export function resolveItemProvider(item: { + configSnapshot?: Record | null; + serviceType?: { + provider?: string | null; + allowedProviders?: string[] | null; + } | null; +}): string | null { + const fromSnapshot = + typeof item.configSnapshot?.['provider'] === 'string' ? item.configSnapshot['provider'].trim() : ''; + + if (fromSnapshot) { + return fromSnapshot; + } + + if (!item.serviceType) { + return null; + } + + const allowed = resolveServiceTypeAllowedProviders(item.serviceType); + + return allowed[0] ?? null; +} + +/** + * Compare allowlists for notification change detection (order-sensitive primary). + */ +export function allowedProvidersEqual(a: unknown, b: unknown): boolean { + const left = normalizeAllowedProviders(a); + const right = normalizeAllowedProviders(b); + + if (left.length !== right.length) { + return false; + } + + return left.every((value, index) => value === right[index]); +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.spec.ts index 6802bf514..d09b93664 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.spec.ts @@ -2,7 +2,10 @@ import { assertServerTypeAllowed, effectiveSchemaSupportsServerTypeSelection, normalizeAllowedServerTypes, + normalizeServerTypeByProvider, providerConfigSchemaSupportsServerTypeSelection, + resolveDefaultServerTypeForProvider, + stripServerTypeByProviderFromConfig, stripServerTypeFromRequestedConfig, } from '../utils/provider-server-type.utils'; @@ -32,4 +35,32 @@ describe('provider-server-type.utils', () => { it('normalizeAllowedServerTypes deduplicates and trims', () => { expect(normalizeAllowedServerTypes([' cx11 ', 'cx11', '', 'cx22', 1])).toEqual(['cx11', 'cx22']); }); + + it('resolveDefaultServerTypeForProvider prefers per-provider map', () => { + expect( + resolveDefaultServerTypeForProvider( + { + serverType: 'cx11', + serverTypeByProvider: { hetzner: 'cx21', 'digital-ocean': 's-1vcpu-1gb' }, + }, + 'digital-ocean', + ), + ).toBe('s-1vcpu-1gb'); + }); + + it('resolveDefaultServerTypeForProvider falls back to top-level serverType', () => { + expect(resolveDefaultServerTypeForProvider({ serverType: 'cx11' }, 'hetzner')).toBe('cx11'); + }); + + it('normalizeServerTypeByProvider and stripServerTypeByProviderFromConfig', () => { + expect(normalizeServerTypeByProvider({ hetzner: ' cx11 ', '': 'x', do: 1 })).toEqual({ hetzner: 'cx11' }); + + const config: Record = { + serverType: 'cx11', + serverTypeByProvider: { hetzner: 'cx11' }, + }; + + stripServerTypeByProviderFromConfig(config); + expect(config).toEqual({ serverType: 'cx11' }); + }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.ts index 8d53bb806..7ef913eaf 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/provider-server-type.utils.ts @@ -7,6 +7,9 @@ export interface JsonSchemaLike { basePriceFromField?: unknown; } +/** Admin-only map on providerConfigDefaults: provider id → default server type id. */ +export const SERVER_TYPE_BY_PROVIDER_KEY = 'serverTypeByProvider'; + /** * True when the schema defines server type as the field that drives base price. */ @@ -46,6 +49,61 @@ export function stripServerTypeFromRequestedConfig( return out; } +/** + * Remove admin-only serverTypeByProvider from a merged effective config (not a provisioning field). + */ +export function stripServerTypeByProviderFromConfig(config: Record): void { + delete config[SERVER_TYPE_BY_PROVIDER_KEY]; +} + +/** + * Normalize provider → server type map: non-empty string keys/values, order not significant. + */ +export function normalizeServerTypeByProvider(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const out: Record = {}; + + for (const [providerId, serverType] of Object.entries(value as Record)) { + const provider = typeof providerId === 'string' ? providerId.trim() : ''; + const typeId = typeof serverType === 'string' ? serverType.trim() : ''; + + if (!provider || !typeId) { + continue; + } + + out[provider] = typeId; + } + + return out; +} + +/** + * Default server type for a provider: serverTypeByProvider[provider] then top-level serverType. + */ +export function resolveDefaultServerTypeForProvider( + providerConfigDefaults: Record | null | undefined, + providerId: string | null | undefined, +): string | null { + const defaults = providerConfigDefaults ?? {}; + const provider = typeof providerId === 'string' ? providerId.trim() : ''; + const byProvider = normalizeServerTypeByProvider(defaults[SERVER_TYPE_BY_PROVIDER_KEY]); + + if (provider && byProvider[provider]) { + return byProvider[provider]; + } + + const legacy = defaults['serverType']; + + if (typeof legacy === 'string' && legacy.trim()) { + return legacy.trim(); + } + + return null; +} + /** * Returns an error message when serverType is not in the allowed list, or null when valid. */ 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 96544eb1b..bf1e2e464 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 @@ -19,6 +19,7 @@ export * from './lib/utils/service-display-label.util'; export * from './lib/utils/fill-period-series.util'; export * from './lib/utils/patch-subscription-item-display-name.util'; export * from './lib/utils/merge-mandatory-order-addon-ids.util'; +export * from './lib/utils/provider-selection.utils'; // Constants export * from './lib/constants/supported-countries'; export * from './lib/constants/service-type-id.constants'; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts index 0889869f6..1d6a6f334 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.spec.ts @@ -25,6 +25,8 @@ describe('ServicePlansService', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], allowedServerTypes: [], withdrawalPolicy: { periodDays: 14, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.effects.spec.ts index d2326c63d..52327d88a 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.effects.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.effects.spec.ts @@ -51,6 +51,8 @@ describe('ServicePlansEffects', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], allowedServerTypes: [], withdrawalPolicy: { periodDays: 14, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.facade.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.facade.spec.ts index 77dc71314..b1f3887f1 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.facade.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.facade.spec.ts @@ -37,6 +37,8 @@ describe('ServicePlansFacade', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], allowedServerTypes: [], withdrawalPolicy: { periodDays: 14, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.reducer.spec.ts index 9d8bac723..20d916924 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.reducer.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.reducer.spec.ts @@ -37,6 +37,8 @@ describe('servicePlansReducer', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], allowedServerTypes: [], withdrawalPolicy: { periodDays: 14, @@ -63,6 +65,8 @@ describe('servicePlansReducer', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], allowedServerTypes: [], withdrawalPolicy: { periodDays: 14, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.selectors.spec.ts index 93a8a6ebf..ddbf0ae00 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.selectors.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-plans/service-plans.selectors.spec.ts @@ -35,6 +35,8 @@ describe('Service Plans Selectors', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], allowedServerTypes: [], withdrawalPolicy: { periodDays: 14, @@ -61,6 +63,8 @@ describe('Service Plans Selectors', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], allowedServerTypes: [], withdrawalPolicy: { periodDays: 14, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.effects.spec.ts index 8bba4fccf..2e5b58a28 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.effects.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.effects.spec.ts @@ -45,6 +45,7 @@ describe('ServiceTypesEffects', () => { key: 'cursor', name: 'Cursor', provider: 'provider-1', + allowedProviders: ['provider-1'], configSchema: {}, disallowStatutoryWithdrawal: false, isActive: true, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.facade.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.facade.spec.ts index 9d2310fcb..e0ec60d9c 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.facade.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.facade.spec.ts @@ -28,6 +28,7 @@ describe('ServiceTypesFacade', () => { key: 'cursor', name: 'Cursor', provider: 'provider-1', + allowedProviders: ['provider-1'], configSchema: {}, disallowStatutoryWithdrawal: false, isActive: true, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.reducer.spec.ts index df59dc04e..600ee2614 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.reducer.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.reducer.spec.ts @@ -30,6 +30,7 @@ describe('serviceTypesReducer', () => { key: 'cursor', name: 'Cursor', provider: 'provider-1', + allowedProviders: ['provider-1'], configSchema: {}, disallowStatutoryWithdrawal: false, isActive: true, @@ -41,6 +42,7 @@ describe('serviceTypesReducer', () => { key: 'opencode', name: 'OpenCode', provider: 'provider-1', + allowedProviders: ['provider-1'], configSchema: {}, disallowStatutoryWithdrawal: false, isActive: true, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.selectors.spec.ts index fa71c4de4..404c17e38 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.selectors.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/service-types/service-types.selectors.spec.ts @@ -25,6 +25,7 @@ describe('Service Types Selectors', () => { key: 'cursor', name: 'Cursor', provider: 'provider-1', + allowedProviders: ['provider-1'], configSchema: {}, disallowStatutoryWithdrawal: false, isActive: true, @@ -36,6 +37,7 @@ describe('Service Types Selectors', () => { key: 'opencode', name: 'OpenCode', provider: 'provider-1', + allowedProviders: ['provider-1'], configSchema: {}, disallowStatutoryWithdrawal: false, isActive: false, 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 4bca75534..1d528e128 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 @@ -34,6 +34,11 @@ export interface ProviderDetail { supportsAddons?: boolean; supportsServerTypeUpgrade?: boolean; supportsServerTypeDowngrade?: boolean; + /** + * Interchangeability key for multi-provider service types. + * Missing/empty ⇒ provider is only compatible with itself (`self:{id}`). + */ + compatibilityGroup?: string; /** Required meters declared by the provider implementation. */ meters?: DeclaredMeterDefinition[]; } @@ -104,7 +109,10 @@ export interface ServiceTypeResponse { key: string; name: string; description?: string | null; - provider: string; + /** Primary provider id; null when None (no cloud provider). */ + provider: string | null; + /** Interchangeable provider ids (first is primary). Empty means None. */ + allowedProviders: string[]; configSchema: Record; disallowStatutoryWithdrawal: boolean; isActive: boolean; @@ -117,7 +125,13 @@ export interface CreateServiceTypeDto { key: string; name: string; description?: string; - provider: string; + /** + * Primary provider id (first of allowedProviders). Null/empty with empty allowedProviders means None. + * When omitted, derived from allowedProviders[0]. + */ + provider?: string | null; + /** Interchangeable provider ids (order preserved; first is primary). Empty means None. */ + allowedProviders?: string[]; configSchema?: Record; disallowStatutoryWithdrawal?: boolean; isActive?: boolean; @@ -127,7 +141,8 @@ export interface CreateServiceTypeDto { export interface UpdateServiceTypeDto { name?: string; description?: string; - provider?: string; + provider?: string | null; + allowedProviders?: string[]; configSchema?: Record; disallowStatutoryWithdrawal?: boolean; isActive?: boolean; @@ -520,6 +535,10 @@ export interface ServicePlanResponse { allowCustomerLocationSelection: boolean; allowCustomerServerTypeSelection: boolean; allowedServerTypes: string[]; + /** When true, customers may choose provider from allowedProviders at checkout. */ + allowCustomerProviderSelection: boolean; + /** Provider ids customers may select when allowCustomerProviderSelection is true. */ + allowedProviders: string[]; taxCategory?: TaxCategory; withdrawalPolicy: WithdrawalPolicy; isActive: boolean; @@ -549,6 +568,8 @@ export interface CreateServicePlanDto { allowCustomerLocationSelection?: boolean; allowCustomerServerTypeSelection?: boolean; allowedServerTypes?: string[]; + allowCustomerProviderSelection?: boolean; + allowedProviders?: string[]; taxCategory?: TaxCategory; isActive?: boolean; } @@ -572,6 +593,8 @@ export interface UpdateServicePlanDto { allowCustomerLocationSelection?: boolean; allowCustomerServerTypeSelection?: boolean; allowedServerTypes?: string[]; + allowCustomerProviderSelection?: boolean; + allowedProviders?: string[]; taxCategory?: TaxCategory; /** Request-only: migrate eligible subscriptions when commercial pricing fields change. */ migrateExistingSubscriptions?: boolean; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/config-change.types.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/config-change.types.ts index c4e990039..d29d09164 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/config-change.types.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/config-change.types.ts @@ -37,6 +37,8 @@ export interface ConfigChangeEligibility { reasonCode?: ConfigChangeErrorCode | null; reason?: string | null; hasPendingChange: boolean; + /** Effective cloud provider for this subscription (configSnapshot, then service-type primary). */ + provider?: string | null; currentServerType?: string | null; allowedServerTypes: string[]; supportsServerTypeUpgrade: boolean; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.spec.ts new file mode 100644 index 000000000..94c0893a9 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.spec.ts @@ -0,0 +1,54 @@ +import { + normalizeAllowedProviders, + providersAreCompatible, + resolveCompatibilityGroup, +} from './provider-selection.utils'; + +describe('provider-selection.utils', () => { + describe('resolveCompatibilityGroup', () => { + it('uses configured group when present', () => { + expect(resolveCompatibilityGroup({ id: 'hetzner', compatibilityGroup: 'host-cloud-init' })).toBe( + 'host-cloud-init', + ); + }); + + it('falls back to self scope when group missing or blank', () => { + expect(resolveCompatibilityGroup({ id: 'custom' })).toBe('self:custom'); + expect(resolveCompatibilityGroup({ id: 'custom', compatibilityGroup: ' ' })).toBe('self:custom'); + }); + }); + + describe('providersAreCompatible', () => { + it('matches same group', () => { + expect( + providersAreCompatible( + { id: 'hetzner', compatibilityGroup: 'host-cloud-init' }, + { id: 'digital-ocean', compatibilityGroup: 'host-cloud-init' }, + ), + ).toBe(true); + }); + + it('rejects different self scopes', () => { + expect(providersAreCompatible({ id: 'a' }, { id: 'b' })).toBe(false); + }); + + it('treats identical ids without group as compatible', () => { + expect(providersAreCompatible({ id: 'a' }, { id: 'a' })).toBe(true); + }); + }); + + describe('normalizeAllowedProviders', () => { + it('deduplicates and preserves order', () => { + expect(normalizeAllowedProviders([' hetzner ', 'digital-ocean', 'hetzner', '', 1])).toEqual([ + 'hetzner', + 'digital-ocean', + ]); + }); + + it('returns empty array for non-arrays', () => { + expect(normalizeAllowedProviders(null)).toEqual([]); + expect(normalizeAllowedProviders(undefined)).toEqual([]); + expect(normalizeAllowedProviders('hetzner')).toEqual([]); + }); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.ts new file mode 100644 index 000000000..466cff921 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/provider-selection.utils.ts @@ -0,0 +1,60 @@ +/** + * Frontend helpers for Decabill multi-provider selection. + * Semantics mirror backend `provider-selection.utils`. + */ + +export interface ProviderCompatibilityInfo { + id: string; + compatibilityGroup?: string | null; +} + +/** + * Effective compatibility key for interchange filtering. + * Missing/empty group ⇒ provider is only compatible with itself. + */ +export function resolveCompatibilityGroup(provider: ProviderCompatibilityInfo): string { + const group = provider.compatibilityGroup?.trim(); + + if (group) { + return group; + } + + return `self:${provider.id}`; +} + +/** + * True when two providers may be selected together on one service type. + */ +export function providersAreCompatible(a: ProviderCompatibilityInfo, b: ProviderCompatibilityInfo): boolean { + return resolveCompatibilityGroup(a) === resolveCompatibilityGroup(b); +} + +/** + * Normalizes allowed provider ids: non-empty strings only, deduplicated, order preserved. + * First entry is the primary provider. + */ +export function normalizeAllowedProviders(values: unknown): string[] { + if (!Array.isArray(values)) { + return []; + } + + const seen = new Set(); + const out: string[] = []; + + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + + const trimmed = value.trim(); + + if (!trimmed || seen.has(trimmed)) { + continue; + } + + seen.add(trimmed); + out.push(trimmed); + } + + return out; +} diff --git a/libs/domains/decabill/frontend/data-access-portal/src/lib/services/public-service-plan-offerings.service.spec.ts b/libs/domains/decabill/frontend/data-access-portal/src/lib/services/public-service-plan-offerings.service.spec.ts index 93d0875dd..7081a6583 100644 --- a/libs/domains/decabill/frontend/data-access-portal/src/lib/services/public-service-plan-offerings.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-portal/src/lib/services/public-service-plan-offerings.service.spec.ts @@ -25,6 +25,8 @@ describe('PublicServicePlanOfferingsService', () => { taxRate: 19, orderingHighlights: [], allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, diff --git a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.effects.spec.ts b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.effects.spec.ts index 016797b4b..5e5fa11d8 100644 --- a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.effects.spec.ts +++ b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.effects.spec.ts @@ -36,6 +36,8 @@ describe('Portal ServicePlansEffects', () => { taxRate: 19, orderingHighlights: [], allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, diff --git a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.facade.spec.ts b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.facade.spec.ts index a2e3b4e75..89db24c0c 100644 --- a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.facade.spec.ts +++ b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.facade.spec.ts @@ -25,6 +25,8 @@ describe('ServicePlansFacade', () => { taxRate: 19, orderingHighlights: [], allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, diff --git a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.reducer.spec.ts index 63059b8ff..a4fe5f983 100644 --- a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.reducer.spec.ts +++ b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.reducer.spec.ts @@ -27,6 +27,8 @@ describe('servicePlansReducer', () => { taxRate: 19, orderingHighlights: [], allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, diff --git a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.selectors.spec.ts index 776d7060f..bb668b912 100644 --- a/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.selectors.spec.ts +++ b/libs/domains/decabill/frontend/data-access-portal/src/lib/state/service-plans/service-plans.selectors.spec.ts @@ -33,6 +33,8 @@ describe('Portal Service Plans Selectors', () => { taxRate: 19, orderingHighlights: [], allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, @@ -55,6 +57,8 @@ describe('Portal Service Plans Selectors', () => { taxRate: 19, orderingHighlights: [], allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, 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 a143cca7f..3ae6ec7a2 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 @@ -32,6 +32,10 @@ export interface PublicServicePlanOffering { orderingHighlights: ServicePlanOrderingHighlight[]; allowCustomerLocationSelection?: boolean; allowCustomerServerTypeSelection: boolean; + /** When true, checkout UIs may offer provider selection from allowedProviders. */ + allowCustomerProviderSelection: boolean; + /** Provider ids customers may select when allowCustomerProviderSelection is true. */ + allowedProviders: string[]; withdrawalPolicy: WithdrawalPolicy; } diff --git a/libs/domains/decabill/frontend/data-access-portal/src/lib/utils/public-offering-price.utils.spec.ts b/libs/domains/decabill/frontend/data-access-portal/src/lib/utils/public-offering-price.utils.spec.ts index b70df3190..efb68c09f 100644 --- a/libs/domains/decabill/frontend/data-access-portal/src/lib/utils/public-offering-price.utils.spec.ts +++ b/libs/domains/decabill/frontend/data-access-portal/src/lib/utils/public-offering-price.utils.spec.ts @@ -19,6 +19,8 @@ describe('formatPublicOfferingPrice', () => { orderingHighlights: [], allowCustomerLocationSelection: false, allowCustomerServerTypeSelection: false, + allowCustomerProviderSelection: false, + allowedProviders: [], withdrawalPolicy: { periodDays: 14, allowedAfterProvisioning: true, 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 ee54bf7a1..9353305d4 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 @@ -465,94 +465,321 @@