Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddMultiProviderSelection1776900000000 implements MigrationInterface {
name = 'AddMultiProviderSelection1776900000000';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"
`);
}
}
47 changes: 46 additions & 1 deletion apps/decabill/frontend-billing-console/src/i18n/messages.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -2952,6 +2952,30 @@
<trans-unit id="featureServicePlans-autoRecalculatePriceDailyHelp" datatype="html">
<source> 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. </source>
</trans-unit>
<trans-unit id="featureServicePlans-allowCustomerProviderSelection" datatype="html">
<source>Allow provider selection</source>
</trans-unit>
<trans-unit id="featureServicePlans-allowCustomerProviderSelectionHelp" datatype="html">
<source>Customers may choose a cloud provider from the selected options when ordering.</source>
</trans-unit>
<trans-unit id="featureServicePlans-allowedProviders" datatype="html">
<source>Allowed providers</source>
</trans-unit>
<trans-unit id="featureServicePlans-allowedProvidersHelp" datatype="html">
<source>Hold Ctrl/Cmd to select. First selected is the default provider at checkout.</source>
</trans-unit>
<trans-unit id="featureServicePlans-pinnedProvider" datatype="html">
<source>Plan provider</source>
</trans-unit>
<trans-unit id="featureServicePlans-pinnedProviderHelp" datatype="html">
<source>Customers cannot choose a provider. This provider is used for all orders on this plan.</source>
</trans-unit>
<trans-unit id="featureServicePlans-serverTypeByProviderHelp" datatype="html">
<source>Choose a default server type for each provider. Used when the customer picks that provider (or omits server type at checkout).</source>
</trans-unit>
<trans-unit id="featureServicePlans-geographyByProviderHelp" datatype="html">
<source>Choose a default location or region for each provider.</source>
</trans-unit>
<trans-unit id="featureServicePlans-active" datatype="html">
<source>Active</source>
</trans-unit>
Expand Down Expand Up @@ -3082,7 +3106,16 @@
<source>Description</source>
</trans-unit>
<trans-unit id="featureServiceTypes-provider" datatype="html">
<source>Provider</source>
<source>Providers</source>
</trans-unit>
<trans-unit id="featureServiceTypes-noneProvider" datatype="html">
<source>None</source>
</trans-unit>
<trans-unit id="featureServiceTypes-providersMultiHelp" datatype="html">
<source>Hold Ctrl/Cmd to select multiple compatible providers. First selected is primary. Choose None for no cloud provider.</source>
</trans-unit>
<trans-unit id="featureServiceTypes-providersPrimaryPlusCount" datatype="html">
<source><x id="PH" equiv-text="primary"/> (+<x id="PH_1" equiv-text="extraCount"/>)</source>
</trans-unit>
<trans-unit id="featureServiceTypes-loadingProviders" datatype="html">
<source> Loading providers... </source>
Expand All @@ -3102,6 +3135,9 @@
<trans-unit id="featureServiceTypes-providerDefaultsHelp" datatype="html">
<source> Overrides platform environment variables for this service type. Leave blank to use the environment default. </source>
</trans-unit>
<trans-unit id="featureServiceTypes-providerDefaultsGroupIntro" datatype="html">
<source>Defaults for <x id="INTERPOLATION" equiv-text="{{ group.label }}"/></source>
</trans-unit>
<trans-unit id="featureServiceTypes-providerDefaultEnvPlaceholder" datatype="html">
<source>Using environment default</source>
</trans-unit>
Expand Down Expand Up @@ -3672,6 +3708,15 @@
<trans-unit id="featureSubscriptions-orderStepPlanAddons" datatype="html">
<source>Plan &amp; addons</source>
</trans-unit>
<trans-unit id="featureSubscriptions-orderStepInfrastructure" datatype="html">
<source>Server &amp; region</source>
</trans-unit>
<trans-unit id="featureSubscriptions-orderProvider" datatype="html">
<source>Provider</source>
</trans-unit>
<trans-unit id="featureSubscriptions-orderProviderHelp" datatype="html">
<source>Choose which cloud provider provisions this subscription.</source>
</trans-unit>
<trans-unit id="featureSubscriptions-configChangeStepServerType" datatype="html">
<source>Server type</source>
</trans-unit>
Expand Down
71 changes: 71 additions & 0 deletions docs/decabill/features/customer-provider-selection.md
Original file line number Diff line number Diff line change
@@ -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`
8 changes: 7 additions & 1 deletion docs/decabill/features/dynamic-provider-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/decabill/features/email-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
2 changes: 1 addition & 1 deletion docs/decabill/features/server-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions docs/decabill/features/service-types-and-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading