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
Expand Up @@ -932,6 +932,8 @@
<trans-unit id="featureSubscriptions-addonsTotal" datatype="html"><source>Addons total</source><target>Add-ons gesamt</target></trans-unit>
<trans-unit id="featureSubscriptions-mandatoryAddonBadge" datatype="html"><source>Required</source><target>Erforderlich</target></trans-unit>
<trans-unit id="featureSubscriptions-mandatoryAddonHint" datatype="html"><source>Included with this plan and cannot be removed.</source><target>In diesem Plan enthalten und kann nicht entfernt werden.</target></trans-unit>
<trans-unit id="featureSubscriptions-orderInvalidAddonLabel" datatype="html"><source>Not available for the selected provider</source><target>Für den gewählten Anbieter nicht verfügbar</target></trans-unit>
<trans-unit id="featureSubscriptions-orderInvalidAddonsHint" datatype="html"><source>Some selected addons are not available for the chosen provider and will be omitted from the order.</source><target>Einige ausgewählte Add-ons sind für den gewählten Anbieter nicht verfügbar und werden aus der Bestellung entfernt.</target></trans-unit>
<trans-unit id="featureSubscriptions-grandTotal" datatype="html"><source>Plan and addons</source><target>Plan und Add-ons</target></trans-unit>
<trans-unit id="featureSubscriptions-orderStepPlanAddons" datatype="html"><source>Plan &amp; addons</source><target>Plan &amp; Add-ons</target></trans-unit>
<trans-unit id="featureCloudInitConfigs-title" datatype="html">
Expand Down
6 changes: 6 additions & 0 deletions apps/decabill/frontend-billing-console/src/i18n/messages.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -3321,6 +3321,12 @@
<trans-unit id="featureSubscriptions-mandatoryAddonHint" datatype="html">
<source>Included with this plan and cannot be removed.</source>
</trans-unit>
<trans-unit id="featureSubscriptions-orderInvalidAddonLabel" datatype="html">
<source>Not available for the selected provider</source>
</trans-unit>
<trans-unit id="featureSubscriptions-orderInvalidAddonsHint" datatype="html">
<source>Some selected addons are not available for the chosen provider and will be omitted from the order.</source>
</trans-unit>
<trans-unit id="featureSubscriptions-planMeters" datatype="html">
<source> Plan usage meters </source>
</trans-unit>
Expand Down
12 changes: 7 additions & 5 deletions docs/decabill/features/addons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/decabill/features/customer-provider-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
{
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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],
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down Expand Up @@ -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']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -165,17 +166,23 @@ export class ServicePlansController {

@RequireScopes('subscriptions:read')
@Get(':id/addons')
async listOrderAddons(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise<PlanAddonOptionDto[]> {
async listOrderAddons(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query('provider') provider?: string,
): Promise<PlanAddonOptionDto[]> {
const plan = await this.servicePlansRepository.findByIdOrThrow(id);

if (!plan.serviceTypeId) {
return [];
}

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 [];
}

Expand All @@ -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) => ({
Expand All @@ -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 ?? [],
})),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading