From 21871d9034f7a9000d7c7f4c72310f1fb7f82a88 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Thu, 27 Aug 2026 17:19:20 +0200 Subject: [PATCH] fix(decabill): addon compatibility marker --- .../src/i18n/messages.de.xlf | 2 + .../src/i18n/messages.xlf | 6 ++ docs/decabill/features/addons.md | 12 ++- .../features/customer-provider-selection.md | 1 + .../feature-billing-manager/spec/openapi.yaml | 15 +++ .../controllers/pricing.controller.spec.ts | 49 +++++++++- .../src/lib/controllers/pricing.controller.ts | 27 ++++-- .../service-plans.controller.spec.ts | 46 +++++++++ .../controllers/service-plans.controller.ts | 16 +++- .../src/lib/dto/addon-response.dto.ts | 2 + .../src/lib/services/addon.service.spec.ts | 28 ++++++ .../src/lib/services/addon.service.ts | 87 ++++++++++++++--- ...subscription-config-change.service.spec.ts | 93 ++++++++++++++----- .../subscription-config-change.service.ts | 20 +++- .../lib/services/subscription.service.spec.ts | 61 ++++++++++++ .../src/lib/services/subscription.service.ts | 14 ++- .../utils/addon-compatibility.utils.spec.ts | 38 ++++++++ .../lib/utils/addon-compatibility.utils.ts | 37 ++++++++ .../data-access-billing-console/src/index.ts | 1 + .../services/service-plans.service.spec.ts | 8 ++ .../src/lib/services/service-plans.service.ts | 6 +- .../src/lib/types/billing.types.ts | 4 + .../utils/addon-compatibility.utils.spec.ts | 15 +++ .../lib/utils/addon-compatibility.utils.ts | 19 ++++ .../subscriptions.component.html | 60 +++++++++++- .../subscriptions/subscriptions.component.ts | 31 ++++++- 26 files changed, 622 insertions(+), 76 deletions(-) create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.spec.ts create mode 100644 libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.ts create mode 100644 libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.spec.ts create mode 100644 libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.ts diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf index ce5d5f4e8..e31c01ea3 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf @@ -932,6 +932,8 @@ Addons totalAdd-ons gesamt RequiredErforderlich Included with this plan and cannot be removed.In diesem Plan enthalten und kann nicht entfernt werden. + Not available for the selected providerFür den gewählten Anbieter nicht verfügbar + Some selected addons are not available for the chosen provider and will be omitted from the order.Einige ausgewählte Add-ons sind für den gewählten Anbieter nicht verfügbar und werden aus der Bestellung entfernt. Plan and addonsPlan und Add-ons Plan & addonsPlan & Add-ons diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index 8d3d72bbb..3222d88cc 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -3321,6 +3321,12 @@ Included with this plan and cannot be removed. + + Not available for the selected provider + + + Some selected addons are not available for the chosen provider and will be omitted from the order. + Plan usage meters diff --git a/docs/decabill/features/addons.md b/docs/decabill/features/addons.md index b0d5fcf72..dc41406ce 100644 --- a/docs/decabill/features/addons.md +++ b/docs/decabill/features/addons.md @@ -84,6 +84,8 @@ Delete/deactivate of a catalog addon is blocked while active plans reference it, On order create and mid-life addon add, the server merges client `addonIds` with plan `mandatoryAddonIds` (`mergeOrderAddonIds`): mandatory IDs are always included first regardless of the client payload. Clients cannot omit a mandatory addon. +When the effective provider (from customer selection or plan pin) is incompatible with a selected addon, pricing preview marks the line with `invalid: true`, excludes it from totals, and order create silently omits that addon. Mandatory addons incompatible with the chosen provider are omitted as well — operators should avoid configuring plans where a mandatory addon only supports a subset of allowed providers. + ### Integrated stack auto-mandatory Plans that offer at least one **integrated** (Docker-host) provisioning option automatically ensure the first-party **Container Manager** catalog addon (`key` / `moduleKey`: `container-manager`) is present in both `allowedAddonIds` and `mandatoryAddonIds`. Custom-only plans are left unchanged. See [Container Manager](./container-manager.md). @@ -130,11 +132,11 @@ Addon responses embed attached meters (optional unit-price override). Module add ## Customer API -| Method | Path | Purpose | -| ------ | ---------------------------- | ----------------------------------------------------------- | -| GET | `/service-plans/{id}/addons` | Orderable addons (`orderFields` included; no secret values) | -| POST | `/pricing/preview` | Include `addonIds` for live totals | -| POST | `/subscriptions` | Include `addonIds` + optional `addonConfigs` | +| Method | Path | Purpose | +| ------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| GET | `/service-plans/{id}/addons` | Orderable addons (`orderFields` included; no secret values). Optional `?provider=` filters by compatibility. | +| POST | `/pricing/preview` | Include `addonIds` for live totals; incompatible addons return `addonLines[].invalid` and are excluded from totals | +| POST | `/subscriptions` | Include `addonIds` + optional `addonConfigs`; incompatible addons are omitted server-side | ## Notifications diff --git a/docs/decabill/features/customer-provider-selection.md b/docs/decabill/features/customer-provider-selection.md index da74b1008..c93f06c49 100644 --- a/docs/decabill/features/customer-provider-selection.md +++ b/docs/decabill/features/customer-provider-selection.md @@ -55,6 +55,7 @@ Webhook: `service_plan.allowed_providers_changed`. 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`). +5. Selected addons incompatible with the resolved provider are marked invalid in pricing preview and omitted from the created subscription. ## Availability (`POST /availability/check`) 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 4754cbc96..b19252c86 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -1330,6 +1330,11 @@ paths: in: path required: true schema: { type: string, format: uuid } + - name: provider + in: query + required: false + schema: { type: string } + description: Filter addons by provider compatibility; defaults to the service type primary provider. responses: '200': description: Plan addon options (empty when provider does not support addons) @@ -7758,6 +7763,12 @@ components: addonId: { type: string, format: uuid } name: { type: string } periodPrice: { type: number } + invalid: + { + type: boolean, + description: When true, + the addon is incompatible with the selected provider and excluded from totals, + } addonsTotal: { type: number, description: Sum of addon period prices } grandTotal: { @@ -7855,6 +7866,10 @@ components: description: When true, the addon is required for this plan and cannot be deselected, } + compatibleProviders: + type: array + items: { type: string } + description: Provider ids this addon supports; empty means all addon-capable providers. CreateAddonDto: type: object required: [key, name, implementationType] 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 63ca7046b..bfd111319 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 @@ -37,7 +37,7 @@ describe('PricingController', () => { let findServiceTypeById: jest.Mock; let calculate: jest.Mock; let getServerTypes: jest.Mock; - let assertAddonIdsForOrder: jest.Mock; + let resolveOrderAddonSelection: jest.Mock; beforeEach(async () => { findPlanById = jest.fn().mockResolvedValue(planRow); @@ -56,7 +56,7 @@ describe('PricingController', () => { { id: 'cx11', priceMonthly: 4.15 }, { id: 'cpx11', priceMonthly: 6.49 }, ]); - assertAddonIdsForOrder = jest.fn().mockResolvedValue([]); + resolveOrderAddonSelection = jest.fn().mockResolvedValue({ compatible: [], incompatible: [] }); const moduleRef = await Test.createTestingModule({ controllers: [PricingController], @@ -87,7 +87,7 @@ describe('PricingController', () => { provide: TaxPreviewService, useValue: { preview: jest.fn() }, }, - { provide: AddonService, useValue: { assertAddonIdsForOrder } }, + { provide: AddonService, useValue: { resolveOrderAddonSelection } }, { provide: AddonsRepository, useValue: { findByIds: jest.fn().mockResolvedValue([]) } }, ], }).compile(); @@ -237,4 +237,47 @@ describe('PricingController', () => { expect(result.taxRate).toBe(7); expect(result.totalGross).toBeCloseTo(10.7, 2); }); + + it('marks incompatible addons invalid and excludes them from totals', async () => { + findPlanById.mockResolvedValue({ + ...planRow, + providerConfigDefaults: { allowedAddonIds: ['addon-1', 'addon-2'] }, + }); + resolveOrderAddonSelection.mockResolvedValue({ + compatible: [ + { + id: 'addon-1', + name: 'Compatible', + basePrice: '5', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + ], + incompatible: [ + { + id: 'addon-2', + name: 'Incompatible', + basePrice: '3', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + ], + }); + + const result = await controller.preview( + { + planId: planRow.id, + addonIds: ['addon-1', 'addon-2'], + requestedConfig: { provider: 'digital-ocean' }, + }, + authReq as never, + ); + + expect(result.addonLines).toEqual([ + { addonId: 'addon-1', name: 'Compatible', periodPrice: 5 }, + { addonId: 'addon-2', name: 'Incompatible', periodPrice: 3, invalid: true }, + ]); + expect(result.addonsTotal).toBe(5); + expect(result.grandTotal).toBe(15); + }); }); 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 c084c8628..9596aef40 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 @@ -110,22 +110,31 @@ export class PricingController { throw new BadRequestException('Addons are not supported for plans without a service type'); } - const addons = + const { compatible, incompatible } = plan.serviceTypeId == null - ? [] - : await this.addonService.assertAddonIdsForOrder( + ? { compatible: [], incompatible: [] } + : await this.addonService.resolveOrderAddonSelection( plan.serviceTypeId, parsePlanAllowedAddonIds(plan.providerConfigDefaults), selectedAddonIds, dto.requestedConfig, plan, ); - const addonLines = addons.map((addon) => ({ - addonId: addon.id, - name: addon.name, - periodPrice: convertAddonPriceToPlanPeriod(addon, plan), - })); - const addonsTotal = Math.round(addonLines.reduce((sum, line) => sum + line.periodPrice, 0) * 100) / 100; + const addonLines = [ + ...compatible.map((addon) => ({ + addonId: addon.id, + name: addon.name, + periodPrice: convertAddonPriceToPlanPeriod(addon, plan), + })), + ...incompatible.map((addon) => ({ + addonId: addon.id, + name: addon.name, + periodPrice: convertAddonPriceToPlanPeriod(addon, plan), + invalid: true, + })), + ]; + const addonsTotal = + Math.round(compatible.reduce((sum, addon) => sum + convertAddonPriceToPlanPeriod(addon, plan), 0) * 100) / 100; const grandTotal = Math.round((planPricing.totalPrice + addonsTotal) * 100) / 100; const taxed = enrichPricingWithTax( { ...planPricing, totalPrice: grandTotal }, 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 2cf3d2919..a31242fe8 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 @@ -78,6 +78,7 @@ describe('ServicePlansController', () => { const addonServiceStub = { assertAllowedAddonIdsForPlan: jest.fn().mockResolvedValue(undefined), providerSupportsAddons: jest.fn().mockReturnValue(true), + getOrderFieldsForAddon: jest.fn().mockReturnValue([]), }; const addonsRepositoryStub = { findByIds: jest.fn().mockResolvedValue([]), @@ -755,4 +756,49 @@ describe('ServicePlansController', () => { await expect(controller.remove(basePlanRow.id)).resolves.toBeUndefined(); expect(deleteFn).toHaveBeenCalledWith(basePlanRow.id); }); + + it('listOrderAddons filters by provider query param', async () => { + const planWithAddons = { + ...basePlanRow, + providerConfigDefaults: { allowedAddonIds: ['addon-1', 'addon-2'] }, + }; + addonsRepositoryStub.findByIds.mockResolvedValue([ + { + id: 'addon-1', + key: 'hetzner-only', + name: 'Hetzner only', + isActive: true, + compatibleProviders: ['hetzner'], + implementationType: 'module', + basePrice: '5', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + { + id: 'addon-2', + key: 'do-only', + name: 'DO only', + isActive: true, + compatibleProviders: ['digital-ocean'], + implementationType: 'module', + basePrice: '3', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + ]); + const moduleRef = await setupRepositoryMock({ + findAll: jest.fn(), + findByIdOrThrow: jest.fn().mockResolvedValue(planWithAddons), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }); + const controller = moduleRef.get(ServicePlansController); + + const result = await controller.listOrderAddons(planWithAddons.id, 'digital-ocean'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('addon-2'); + expect(result[0].compatibleProviders).toEqual(['digital-ocean']); + }); }); 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 5fb6ba1c8..f93652d51 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 @@ -45,6 +45,7 @@ import { type PlanPriceMigrateEnqueuePort, } from '../queue/plan-price-migrate-enqueue.token'; import { convertAddonPriceToPlanPeriod } from '../utils/addon-pricing.util'; +import { isAddonCompatibleWithProvider } from '../utils/addon-compatibility.utils'; import { normalizePlanProviderConfigDefaults } from '../utils/cloud-init/plan-provisioning-options.utils'; import { parsePlanAllowedAddonIds, parsePlanMandatoryAddonIds } from '../utils/plan-addons.utils'; import { commercialPricingFieldsChanged, snapshotCommercialPricing } from '../utils/plan-commercial-pricing.utils'; @@ -165,7 +166,10 @@ export class ServicePlansController { @RequireScopes('subscriptions:read') @Get(':id/addons') - async listOrderAddons(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise { + async listOrderAddons( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Query('provider') provider?: string, + ): Promise { const plan = await this.servicePlansRepository.findByIdOrThrow(id); if (!plan.serviceTypeId) { @@ -173,9 +177,12 @@ export class ServicePlansController { } const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); - const primaryProvider = resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? null; + const filterProvider = + typeof provider === 'string' && provider.trim() + ? provider.trim() + : (resolveServiceTypeAllowedProviders(serviceType)[0] ?? serviceType.provider ?? null); - if (!primaryProvider || !this.addonService.providerSupportsAddons(primaryProvider)) { + if (!filterProvider || !this.addonService.providerSupportsAddons(filterProvider)) { return []; } @@ -190,7 +197,7 @@ export class ServicePlansController { const compatible = addons .filter((addon) => addon.isActive) - .filter((addon) => addon.compatibleProviders.length === 0 || addon.compatibleProviders.includes(primaryProvider)); + .filter((addon) => isAddonCompatibleWithProvider(addon, filterProvider)); return await Promise.all( compatible.map(async (addon) => ({ @@ -206,6 +213,7 @@ export class ServicePlansController { orderFields: this.addonService.getOrderFieldsForAddon(addon), meters: await this.meterService.listAddonMeters(addon.id), mandatory: mandatoryIds.has(addon.id), + compatibleProviders: addon.compatibleProviders ?? [], })), ); } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts index 259a6fdd3..818972d56 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/addon-response.dto.ts @@ -43,4 +43,6 @@ export class PlanAddonOptionDto { meters!: AttachedMeterResponseDto[]; /** When true, the addon is required for this plan and cannot be deselected. */ mandatory!: boolean; + /** Provider ids this addon supports; empty means all addon-capable providers. */ + compatibleProviders!: string[]; } 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 1dfba42c5..1c10eb999 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 @@ -226,6 +226,34 @@ describe('AddonService', () => { ).rejects.toThrow(BadRequestException); }); + it('partitions incompatible addons in resolveOrderAddonSelection without throwing', async () => { + const compatibleAddon = { id: 'addon-1', key: 'av', isActive: true, compatibleProviders: ['hetzner'] }; + const incompatibleAddon = { id: 'addon-2', key: 'do-only', 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([compatibleAddon, incompatibleAddon]); + + await expect( + service.resolveOrderAddonSelection( + 'st-1', + ['addon-1', 'addon-2'], + ['addon-1', 'addon-2'], + { provider: 'hetzner' }, + { allowCustomerProviderSelection: true, allowedProviders: ['hetzner', 'digital-ocean'] }, + ), + ).resolves.toEqual({ + compatible: [compatibleAddon], + incompatible: [incompatibleAddon], + }); + }); + 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({ 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 a6149ab1f..131999539 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 @@ -16,6 +16,7 @@ import { sanitizeAddonConfigFields, type SanitizedAddonConfigResult, } from '../utils/addon-config.utils'; +import { partitionAddonsByProviderCompatibility } from '../utils/addon-compatibility.utils'; import { assertNonNegativeAddonPrice } from '../utils/addon-pricing.util'; import { parsePlanMandatoryAddonIds, planReferencesAddonId } from '../utils/plan-addons.utils'; import { resolveEffectiveProvider, resolveServiceTypeAllowedProviders } from '../utils/provider-selection.utils'; @@ -24,6 +25,11 @@ import { ProviderRegistryService } from './provider-registry.service'; const SERVICE_PLAN_REFERENCE_BATCH_SIZE = 100; +export interface OrderAddonSelectionResolution { + compatible: AddonEntity[]; + incompatible: AddonEntity[]; +} + @Injectable() export class AddonService { constructor( @@ -215,6 +221,33 @@ export class AddonService { } } + async resolveOrderAddonSelection( + serviceTypeId: string, + planAllowedAddonIds: string[], + requestedAddonIds: string[], + requestedConfig?: Record, + plan?: { + allowCustomerProviderSelection?: boolean | null; + allowedProviders?: string[] | null; + }, + ): Promise { + if (requestedAddonIds.length === 0) { + return { compatible: [], incompatible: [] }; + } + + const addons = await this.loadValidatedOrderAddons( + serviceTypeId, + planAllowedAddonIds, + requestedAddonIds, + requestedConfig, + plan, + ); + const providerToCheck = await this.resolveOrderAddonProvider(serviceTypeId, requestedConfig, plan); + const { compatible, incompatible } = partitionAddonsByProviderCompatibility(addons, providerToCheck); + + return { compatible, incompatible }; + } + async assertAddonIdsForOrder( serviceTypeId: string, planAllowedAddonIds: string[], @@ -225,15 +258,53 @@ export class AddonService { allowedProviders?: string[] | null; }, ): Promise { - if (requestedAddonIds.length === 0) { - return []; + const { compatible, incompatible } = await this.resolveOrderAddonSelection( + serviceTypeId, + planAllowedAddonIds, + requestedAddonIds, + requestedConfig, + plan, + ); + + if (incompatible.length > 0) { + const providerToCheck = await this.resolveOrderAddonProvider(serviceTypeId, requestedConfig, plan); + + throw new BadRequestException( + `Addon "${incompatible[0].key}" is not compatible with provider "${providerToCheck ?? 'none'}"`, + ); } + return compatible; + } + + private async resolveOrderAddonProvider( + serviceTypeId: string, + requestedConfig?: Record, + plan?: { + allowCustomerProviderSelection?: boolean | null; + allowedProviders?: string[] | null; + }, + ): Promise { const serviceType = await this.serviceTypesRepository.findByIdOrThrow(serviceTypeId); - const providerToCheck = + + return ( resolveEffectiveProvider(serviceType, plan ?? {}, requestedConfig) ?? resolveServiceTypeAllowedProviders(serviceType)[0] ?? - null; + null + ); + } + + private async loadValidatedOrderAddons( + serviceTypeId: string, + planAllowedAddonIds: string[], + requestedAddonIds: string[], + requestedConfig?: Record, + plan?: { + allowCustomerProviderSelection?: boolean | null; + allowedProviders?: string[] | null; + }, + ): Promise { + const providerToCheck = await this.resolveOrderAddonProvider(serviceTypeId, requestedConfig, plan); if (!this.providerSupportsAddons(providerToCheck)) { throw new BadRequestException(`Provider "${providerToCheck ?? 'none'}" does not support addons`); @@ -257,14 +328,6 @@ 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/subscription-config-change.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change.service.spec.ts index 561f5fbe8..ea990884e 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 @@ -108,6 +108,9 @@ describe('SubscriptionConfigChangeService', () => { }); providerServerTypesService.getServerTypes.mockResolvedValue(SERVER_TYPES); addonService.providerSupportsAddons.mockReturnValue(true); + addonsRepository.findByIds.mockResolvedValue([ + { id: 'addon-1', key: 'backup', isActive: true, compatibleProviders: ['hetzner'] }, + ]); }); it('getEligibility reports the current server type and plan addon offering', async () => { @@ -123,6 +126,28 @@ describe('SubscriptionConfigChangeService', () => { expect(eligibility.activeAddonIds).toEqual(['addon-2']); }); + it('getEligibility excludes addons incompatible with the subscription provider', async () => { + servicePlansRepository.findByIdOrThrow.mockResolvedValue({ + id: 'plan-1', + basePrice: '0', + marginPercent: '0', + marginFixed: '0', + billingIntervalType: BillingIntervalType.MONTH, + billingIntervalValue: 1, + allowCustomerServerTypeSelection: true, + allowedServerTypes: ['cx11', 'cx21', 'cpx11'], + providerConfigDefaults: { allowedAddonIds: ['addon-1', 'addon-do'] }, + }); + addonsRepository.findByIds.mockResolvedValue([ + { id: 'addon-1', key: 'backup', isActive: true, compatibleProviders: ['hetzner'] }, + { id: 'addon-do', key: 'do-only', isActive: true, compatibleProviders: ['digital-ocean'] }, + ]); + + const eligibility = await service.getEligibility('sub-1', 'user-1'); + + expect(eligibility.availableAddonIds).toEqual(['addon-1']); + }); + it('getEligibility prefers configSnapshot.provider over service type primary', async () => { subscriptionItemsRepository.findBySubscription.mockResolvedValue([ { @@ -347,33 +372,57 @@ describe('SubscriptionConfigChangeService', () => { }); it('rejects inactive or incompatible addons', async () => { - addonsRepository.findByIds.mockResolvedValueOnce([ - { - id: 'addon-1', - key: 'backup', - isActive: false, - compatibleProviders: ['hetzner'], - basePrice: '2', - priceIntervalType: BillingIntervalType.MONTH, - priceIntervalValue: 1, - }, - ]); + addonsRepository.findByIds + .mockResolvedValueOnce([ + { + id: 'addon-1', + key: 'backup', + isActive: false, + compatibleProviders: ['hetzner'], + basePrice: '2', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + ]) + .mockResolvedValueOnce([ + { + id: 'addon-1', + key: 'backup', + isActive: false, + compatibleProviders: ['hetzner'], + basePrice: '2', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + ]); const inactive = await service.preview('sub-1', 'user-1', { addAddonIds: ['addon-1'] }).catch((caught) => caught); expect(extractErrorCode(inactive)).toBe('CONFIG_CHANGE_ADDON_INVALID'); - addonsRepository.findByIds.mockResolvedValueOnce([ - { - id: 'addon-1', - key: 'backup', - isActive: true, - compatibleProviders: ['digital-ocean'], - basePrice: '2', - priceIntervalType: BillingIntervalType.MONTH, - priceIntervalValue: 1, - }, - ]); + addonsRepository.findByIds + .mockResolvedValueOnce([ + { + id: 'addon-1', + key: 'backup', + isActive: true, + compatibleProviders: ['digital-ocean'], + basePrice: '2', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + ]) + .mockResolvedValueOnce([ + { + id: 'addon-1', + key: 'backup', + isActive: true, + compatibleProviders: ['digital-ocean'], + basePrice: '2', + priceIntervalType: BillingIntervalType.MONTH, + priceIntervalValue: 1, + }, + ]); const incompatible = await service .preview('sub-1', 'user-1', { addAddonIds: ['addon-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 81e83d383..b118876d7 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 @@ -33,6 +33,7 @@ import { SubscriptionConfigChangesRepository } from '../repositories/subscriptio import { SubscriptionItemsRepository } from '../repositories/subscription-items.repository'; import { SubscriptionsRepository } from '../repositories/subscriptions.repository'; import { convertAddonPriceToPlanPeriod } from '../utils/addon-pricing.util'; +import { isAddonCompatibleWithProvider } from '../utils/addon-compatibility.utils'; import { parsePlanAllowedAddonIds, parsePlanMandatoryAddonIds } from '../utils/plan-addons.utils'; import { roundMoney } from '../utils/promotion-advantage.util'; import { normalizeStoredProviderDefaults } from '../utils/provider-env-defaults.utils'; @@ -103,7 +104,7 @@ export class SubscriptionConfigChangeService { async getEligibility(subscriptionId: string, userId: string): Promise { const context = await this.loadContext(subscriptionId, userId); - return this.buildEligibility(context); + return await this.buildEligibility(context); } async preview( @@ -116,7 +117,7 @@ export class SubscriptionConfigChangeService { const amounts = await this.computeAmounts(context, resolved); return { - eligibility: this.buildEligibility(context), + eligibility: await this.buildEligibility(context), amounts, disclaimer: this.buildDisclaimer(amounts, resolved, context.plan), discounts: await this.loadDiscounts(subscriptionId), @@ -221,12 +222,19 @@ export class SubscriptionConfigChangeService { return undefined; } - private buildEligibility(context: ConfigChangeContext): ConfigChangeEligibilityDto { + private async buildEligibility(context: ConfigChangeContext): Promise { const detail = context.provider ? this.providerRegistry.getProvider(context.provider) : undefined; const hasPendingChange = context.latestChange?.status === 'pending' || context.latestChange?.status === 'processing'; const activeAddonIds = context.activeAddons.map((row) => row.addonId); const planAddonIds = parsePlanAllowedAddonIds(context.plan.providerConfigDefaults); + const planAddons = planAddonIds.length > 0 ? await this.addonsRepository.findByIds(planAddonIds) : []; + const providerCompatibleAddonIds = new Set( + planAddons + .filter((addon) => addon.isActive) + .filter((addon) => isAddonCompatibleWithProvider(addon, context.provider)) + .map((addon) => addon.id), + ); let reasonCode: ConfigChangeErrorCode | undefined; let reason: string | undefined; @@ -254,7 +262,9 @@ export class SubscriptionConfigChangeService { : [], supportsServerTypeUpgrade: detail?.supportsServerTypeUpgrade === true, supportsServerTypeDowngrade: detail?.supportsServerTypeDowngrade === true, - availableAddonIds: planAddonIds.filter((id) => !activeAddonIds.includes(id)), + availableAddonIds: planAddonIds.filter( + (id) => !activeAddonIds.includes(id) && providerCompatibleAddonIds.has(id), + ), activeAddonIds, }; } @@ -276,7 +286,7 @@ export class SubscriptionConfigChangeService { context: ConfigChangeContext, dto: ConfigChangeRequestDto, ): Promise { - const eligibility = this.buildEligibility(context); + const eligibility = await this.buildEligibility(context); if (!eligibility.canRequestChange) { throwConfigChangeBadRequest( 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 087177fec..fa8e37987 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 @@ -159,6 +159,7 @@ describe('SubscriptionService', () => { processDueBilling: jest.fn(), }; const addonService = { + resolveOrderAddonSelection: jest.fn().mockResolvedValue({ compatible: [], incompatible: [] }), assertAddonIdsForOrder: jest.fn().mockResolvedValue([]), providerSupportsAddons: jest.fn().mockReturnValue(true), }; @@ -931,6 +932,66 @@ describe('SubscriptionService', () => { expect(availabilityService.checkAvailability).toHaveBeenCalledWith('hetzner', 'nbg1', 'cx23', {}); }); + it('creates subscription with only provider-compatible addons', async () => { + const compatibleAddon = { + id: 'addon-1', + key: 'backup', + name: 'Backup', + isActive: true, + compatibleProviders: ['hetzner'], + }; + plansRepository.findByIdOrThrow = jest.fn().mockResolvedValue({ + id: 'plan-1', + serviceTypeId: 'stype-1', + billingIntervalType: BillingIntervalType.DAY, + billingIntervalValue: 1, + billingDayOfMonth: undefined, + providerConfigDefaults: { + ...controllerProvisioningDefaults, + allowedAddonIds: ['addon-1', 'addon-2'], + }, + }); + typesRepository.findByIdOrThrow = jest.fn().mockResolvedValue({ + id: 'stype-1', + provider: 'hetzner', + configSchema: { required: ['location'] }, + }); + (addonService.resolveOrderAddonSelection as jest.Mock).mockResolvedValue({ + compatible: [compatibleAddon], + incompatible: [ + { id: 'addon-2', key: 'do-only', name: 'DO only', isActive: true, compatibleProviders: ['digital-ocean'] }, + ], + }); + subscriptionsRepository.create = jest.fn().mockResolvedValue({ + id: 'sub-1', + userId: 'user-1', + planId: 'plan-1', + status: SubscriptionStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + }); + itemsRepository.create = jest.fn().mockResolvedValue({ id: 'item-1' }); + (availabilityService.checkAvailability as jest.Mock).mockResolvedValue({ isAvailable: true }); + + await service.createSubscription( + 'user-1', + 'plan-1', + { location: 'fsn1', serverType: 'cx23' }, + true, + undefined, + undefined, + ['addon-1', 'addon-2'], + { 'addon-1': { KEY: 'a' }, 'addon-2': { KEY: 'b' } }, + ); + + expect(addonLifecycleService.createPendingSubscriptionAddons).toHaveBeenCalledWith( + expect.objectContaining({ + addons: [compatibleAddon], + addonConfigs: { 'addon-1': { KEY: 'a' } }, + }), + ); + }); + it('throws BadRequestException when customer profile is null', async () => { (customerProfilesService.getByUserId as jest.Mock).mockResolvedValue(null); (customerProfilesService.isProfileComplete as jest.Mock).mockReturnValue(false); 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 8b5811c43..052ea2a27 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 @@ -168,16 +168,20 @@ export class SubscriptionService { const serviceType = await this.serviceTypesRepository.findByIdOrThrow(plan.serviceTypeId); const selectedAddonIds = mergeOrderAddonIds(addonIds, plan.providerConfigDefaults); - - assertAddonConfigsMatchSelection(selectedAddonIds, addonConfigs); - - const selectedAddons = await this.addonService.assertAddonIdsForOrder( + const { compatible: selectedAddons } = await this.addonService.resolveOrderAddonSelection( plan.serviceTypeId, parsePlanAllowedAddonIds(plan.providerConfigDefaults), selectedAddonIds, requestedConfig, plan, ); + const compatibleAddonIds = new Set(selectedAddons.map((addon) => addon.id)); + const filteredAddonConfigs = + addonConfigs == null + ? undefined + : Object.fromEntries(Object.entries(addonConfigs).filter(([addonId]) => compatibleAddonIds.has(addonId))); + + assertAddonConfigsMatchSelection([...compatibleAddonIds], filteredAddonConfigs); const allowCustomerLocationSelection = plan.allowCustomerLocationSelection === true; const allowCustomerServerTypeSelection = plan.allowCustomerServerTypeSelection === true; const allowCustomerProviderSelection = plan.allowCustomerProviderSelection === true; @@ -373,7 +377,7 @@ export class SubscriptionService { subscriptionId: subscription.id, addons: selectedAddons, plan, - addonConfigs, + addonConfigs: filteredAddonConfigs, }); if (promotionCode?.trim()) { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.spec.ts new file mode 100644 index 000000000..6c270b8c3 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.spec.ts @@ -0,0 +1,38 @@ +import { isAddonCompatibleWithProvider, partitionAddonsByProviderCompatibility } from './addon-compatibility.utils'; + +describe('addon-compatibility.utils', () => { + describe('isAddonCompatibleWithProvider', () => { + it('treats empty compatibleProviders as all providers', () => { + expect(isAddonCompatibleWithProvider({ compatibleProviders: [] }, 'hetzner')).toBe(true); + expect(isAddonCompatibleWithProvider({ compatibleProviders: [] }, 'digital-ocean')).toBe(true); + }); + + it('matches when provider is in the allowlist', () => { + expect(isAddonCompatibleWithProvider({ compatibleProviders: ['hetzner'] }, 'hetzner')).toBe(true); + }); + + it('rejects when provider is not in the allowlist', () => { + expect(isAddonCompatibleWithProvider({ compatibleProviders: ['hetzner'] }, 'digital-ocean')).toBe(false); + }); + + it('rejects non-empty allowlist when provider is missing', () => { + expect(isAddonCompatibleWithProvider({ compatibleProviders: ['hetzner'] }, null)).toBe(false); + expect(isAddonCompatibleWithProvider({ compatibleProviders: ['hetzner'] }, '')).toBe(false); + }); + }); + + describe('partitionAddonsByProviderCompatibility', () => { + it('splits addons by provider compatibility', () => { + const addons = [ + { id: 'a1', compatibleProviders: ['hetzner'] }, + { id: 'a2', compatibleProviders: [] }, + { id: 'a3', compatibleProviders: ['digital-ocean'] }, + ]; + + const result = partitionAddonsByProviderCompatibility(addons, 'hetzner'); + + expect(result.compatible.map((addon) => addon.id)).toEqual(['a1', 'a2']); + expect(result.incompatible.map((addon) => addon.id)).toEqual(['a3']); + }); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.ts new file mode 100644 index 000000000..ab36dfebb --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/addon-compatibility.utils.ts @@ -0,0 +1,37 @@ +export interface AddonProviderCompatibility { + compatibleProviders: string[]; +} + +/** + * Empty compatibleProviders means the addon works with all addon-capable providers. + */ +export function isAddonCompatibleWithProvider( + addon: AddonProviderCompatibility, + providerId: string | null | undefined, +): boolean { + const trimmed = providerId?.trim(); + + if (!trimmed) { + return addon.compatibleProviders.length === 0; + } + + return addon.compatibleProviders.length === 0 || addon.compatibleProviders.includes(trimmed); +} + +export function partitionAddonsByProviderCompatibility( + addons: T[], + providerId: string | null | undefined, +): { compatible: T[]; incompatible: T[] } { + const compatible: T[] = []; + const incompatible: T[] = []; + + for (const addon of addons) { + if (isAddonCompatibleWithProvider(addon, providerId)) { + compatible.push(addon); + } else { + incompatible.push(addon); + } + } + + return { compatible, incompatible }; +} 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 bf1e2e464..7426c47a9 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/addon-compatibility.utils'; export * from './lib/utils/provider-selection.utils'; // Constants export * from './lib/constants/supported-countries'; 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 1d6a6f334..668d7709a 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 @@ -222,6 +222,14 @@ describe('ServicePlansService', () => { expect(req.request.method).toBe('GET'); req.flush(options); }); + + it('should pass provider query param when provided', (done) => { + service.getOrderAddons('sp-1', 'digital-ocean').subscribe(() => done()); + + const req = httpMock.expectOne(`${apiUrl}/service-plans/sp-1/addons?provider=digital-ocean`); + expect(req.request.method).toBe('GET'); + req.flush([]); + }); }); describe('plan meters', () => { diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts index 258d5e43b..a73fa4cc7 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/service-plans.service.ts @@ -67,8 +67,10 @@ export class ServicePlansService { ); } - getOrderAddons(planId: string): Observable { - return this.http.get(`${this.apiUrl}/service-plans/${planId}/addons`); + getOrderAddons(planId: string, provider?: string): Observable { + const params = provider?.trim() ? { provider: provider.trim() } : undefined; + + return this.http.get(`${this.apiUrl}/service-plans/${planId}/addons`, { params }); } getCloudInitOrderFields(planId: string, configId: string): Observable { 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 1d528e128..81276dda5 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 @@ -471,6 +471,8 @@ export interface PlanAddonOptionDto { meters?: AttachedMeterResponse[]; /** When true, the addon is required for this plan and cannot be deselected. */ mandatory: boolean; + /** Provider ids this addon supports; empty means all addon-capable providers. */ + compatibleProviders: string[]; } export interface CreateAddonDto { @@ -960,6 +962,8 @@ export interface PricingPreviewAddonLine { addonId: string; name: string; periodPrice: number; + /** When true, the addon is incompatible with the selected provider and excluded from totals. */ + invalid?: boolean; } export interface PricingPreviewResponse { diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.spec.ts new file mode 100644 index 000000000..61a7cf784 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.spec.ts @@ -0,0 +1,15 @@ +import { isAddonCompatibleWithProvider } from './addon-compatibility.utils'; + +describe('addon-compatibility.utils', () => { + it('treats empty compatibleProviders as all providers', () => { + expect(isAddonCompatibleWithProvider({ compatibleProviders: [] }, 'hetzner')).toBe(true); + }); + + it('matches when provider is in the allowlist', () => { + expect(isAddonCompatibleWithProvider({ compatibleProviders: ['hetzner'] }, 'hetzner')).toBe(true); + }); + + it('rejects when provider is not in the allowlist', () => { + expect(isAddonCompatibleWithProvider({ compatibleProviders: ['hetzner'] }, 'digital-ocean')).toBe(false); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.ts new file mode 100644 index 000000000..3efa8405e --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/utils/addon-compatibility.utils.ts @@ -0,0 +1,19 @@ +export interface AddonProviderCompatibility { + compatibleProviders: string[]; +} + +/** + * Empty compatibleProviders means the addon works with all addon-capable providers. + */ +export function isAddonCompatibleWithProvider( + addon: AddonProviderCompatibility, + providerId: string | null | undefined, +): boolean { + const trimmed = providerId?.trim(); + + if (!trimmed) { + return addon.compatibleProviders.length === 0; + } + + return addon.compatibleProviders.length === 0 || addon.compatibleProviders.includes(trimmed); +} diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.html index a093c9021..4ac460e3f 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/subscriptions/subscriptions.component.html @@ -579,6 +579,14 @@
Order