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,156 @@
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey, TableIndex } from 'typeorm';

/**
* Allows service plans (and subscription items) without a service type so billing-only
* plans can deploy nothing. Adds plan.tenant_id for tenant isolation when the type FK is null.
*/
export class NullableServiceTypeOnPlansAndItems1775800000000 implements MigrationInterface {
name = 'NullableServiceTypeOnPlansAndItems1775800000000';

public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn('billing_service_plans', 'tenant_id'))) {
await queryRunner.addColumn(
'billing_service_plans',
new TableColumn({
name: 'tenant_id',
type: 'varchar',
length: '64',
isNullable: false,
default: "'default'",
}),
);
}

await queryRunner.query(`
UPDATE billing_service_plans sp
SET tenant_id = st.tenant_id
FROM billing_service_types st
WHERE st.id = sp.service_type_id
`);

await queryRunner.createIndex(
'billing_service_plans',
new TableIndex({
name: 'IDX_billing_service_plans_tenant_id',
columnNames: ['tenant_id'],
}),
);

await this.dropForeignKeysOnColumn(queryRunner, 'billing_service_plans', 'service_type_id');
await queryRunner.changeColumn(
'billing_service_plans',
'service_type_id',
new TableColumn({
name: 'service_type_id',
type: 'uuid',
isNullable: true,
}),
);
await queryRunner.createForeignKey(
'billing_service_plans',
new TableForeignKey({
name: 'FK_billing_service_plans_service_type_id',
columnNames: ['service_type_id'],
referencedTableName: 'billing_service_types',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);

await this.dropForeignKeysOnColumn(queryRunner, 'billing_subscription_items', 'service_type_id');
await queryRunner.changeColumn(
'billing_subscription_items',
'service_type_id',
new TableColumn({
name: 'service_type_id',
type: 'uuid',
isNullable: true,
}),
);
await queryRunner.createForeignKey(
'billing_subscription_items',
new TableForeignKey({
name: 'FK_billing_subscription_items_service_type_id',
columnNames: ['service_type_id'],
referencedTableName: 'billing_service_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM billing_subscription_items WHERE service_type_id IS NULL
`);
await queryRunner.query(`
DELETE FROM billing_service_plans WHERE service_type_id IS NULL
`);

await this.dropForeignKeysOnColumn(queryRunner, 'billing_subscription_items', 'service_type_id');
await queryRunner.changeColumn(
'billing_subscription_items',
'service_type_id',
new TableColumn({
name: 'service_type_id',
type: 'uuid',
isNullable: false,
}),
);
await queryRunner.createForeignKey(
'billing_subscription_items',
new TableForeignKey({
name: 'FK_billing_subscription_items_service_type_id',
columnNames: ['service_type_id'],
referencedTableName: 'billing_service_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);

await this.dropForeignKeysOnColumn(queryRunner, 'billing_service_plans', 'service_type_id');
await queryRunner.changeColumn(
'billing_service_plans',
'service_type_id',
new TableColumn({
name: 'service_type_id',
type: 'uuid',
isNullable: false,
}),
);
await queryRunner.createForeignKey(
'billing_service_plans',
new TableForeignKey({
name: 'FK_billing_service_plans_service_type_id',
columnNames: ['service_type_id'],
referencedTableName: 'billing_service_types',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);

await queryRunner.dropIndex('billing_service_plans', 'IDX_billing_service_plans_tenant_id');

if (await queryRunner.hasColumn('billing_service_plans', 'tenant_id')) {
await queryRunner.dropColumn('billing_service_plans', 'tenant_id');
}
}

private async dropForeignKeysOnColumn(
queryRunner: QueryRunner,
tableName: string,
columnName: string,
): Promise<void> {
const table = await queryRunner.getTable(tableName);

if (!table) {
return;
}

for (const fk of table.foreignKeys) {
if (fk.columnNames.includes(columnName)) {
await queryRunner.dropForeignKey(tableName, fk);
}
}
}
}
4 changes: 2 additions & 2 deletions apps/decabill/frontend-billing-console/src/i18n/messages.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -2214,8 +2214,8 @@
<trans-unit id="featureServicePlans-serviceType" datatype="html">
<source>Service type</source>
</trans-unit>
<trans-unit id="featureServicePlans-chooseType" datatype="html">
<source>Choose a service type</source>
<trans-unit id="featureServicePlans-noneType" datatype="html">
<source>None (no deployment)</source>
</trans-unit>
<trans-unit id="featureServicePlans-name" datatype="html">
<source>Name</source>
Expand Down
38 changes: 25 additions & 13 deletions docs/decabill/features/service-types-and-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,32 +77,44 @@ API behavior:

## Service Plans

Service plans belong to a service type and define customer-facing pricing and billing rules.
Service plans define customer-facing pricing and billing rules. They usually belong to a service type (provider-backed or manual). They may also use **`serviceTypeId: null`** for billing-only plans that deploy nothing.

### Plans without a service type (`null`)

- API and admin UI use **`null`** (or omit the field on create) for no deployment — not a sentinel string.
- Persistence stores `NULL` in `billing_service_plans.service_type_id` (and on subscription items when ordered); responses expose `null`.
- Admin create form defaults the service type select to **None (no deployment)**; there is no “Choose a service type” placeholder.
- Ordering creates an immediately active subscription item with no cloud provisioning, availability check, addons, or backorders.
- Location/server-type customer selection and `autoRecalculatePriceDaily` are rejected for null-`serviceTypeId` plans.
- Plans carry their own `tenant_id` so tenant isolation works when the service-type join is absent.

No dedicated webhook events are emitted for plan CRUD or for “non-provision fulfilled” items (same as other non-cloud providers). Customer orders still emit `subscription.created` and related billing events.

### Admin Endpoints

| Method | Path | Purpose |
| ------ | ------------------------------------------------ | --------------------------------------------- |
| GET | `/service-plans` | List service plans |
| POST | `/service-plans` | Create plan (admin) |
| GET | `/service-plans/{id}` | Get plan |
| GET | `/service-plans/{id}/order-provisioning-options` | List customer-selectable provisioning options |
| POST | `/service-plans/{id}` | Update plan (admin) |
| DELETE | `/service-plans/{id}` | Delete plan (admin) |
| Method | Path | Purpose |
| ------ | ------------------------------------------------ | ------------------------------------------------------------ |
| GET | `/service-plans` | List service plans |
| POST | `/service-plans` | Create plan (admin) |
| GET | `/service-plans/{id}` | Get plan |
| GET | `/service-plans/{id}/order-provisioning-options` | List customer-selectable provisioning options |
| POST | `/service-plans/{id}` | Update plan (admin) |
| DELETE | `/service-plans/{id}` | Delete plan (admin); 400 if subscriptions still reference it |

### Plan Fields (Conceptual)

- **`serviceTypeId`** UUID of a catalog service type, or **`null`** for no deployment
- Title, description, and active flag
- Billing interval (hourly, daily, monthly, **yearly**)
- **`billInAdvance`** when true, charge at period start (prepaid); default false (arrear). Incompatible with usage-based metering. See [Advance billing and yearly interval](./advance-billing-and-yearly-interval.md).
- **`autoRecalculatePriceDaily`** when true, nightly job refreshes catalog base price from the provider and migrates eligible subscriptions (default false, opt-in). See [Automatic daily price recalculation](./automatic-price-recalculation.md).
- **`autoRecalculatePriceDaily`** when true, nightly job refreshes catalog base price from the provider and migrates eligible subscriptions (default false, opt-in; not allowed for null-`serviceTypeId` plans). See [Automatic daily price recalculation](./automatic-price-recalculation.md).
- **Admin commercial migrate** on plan update, optional request field `migrateExistingSubscriptions` (not stored). When true **and** `basePrice`, `marginPercent`, `marginFixed`, or `taxCategory` actually change, a `plan-price-migrate.unit` job migrates eligible subscriptions with the same settlement, withdrawal restart, and consolidated price-change email as nightly recalc. Unchecked updates affect new orders only.
- Base price, margin, and computed customer total
- `providerConfigDefaults` merged with customer `requestedConfig` on order
- `providerConfigDefaults` merged with customer `requestedConfig` on order (empty for null-`serviceTypeId` plans)
- For provisioning plans, customers choose from `provisioningOptions` (integrated `agenstra-controller`/`agenstra-manager`/`decabill-billing` and/or custom CloudInit configs). Admins configure these exclusively via **Customer-selectable options** checkboxes in the plan editor; **Product defaults** fields are scoped to the checked options only. New plans default to every integrated stack present in the provider `service` enum (Agenstra Controller, Agenstra Manager, and Decabill Billing when enabled). Existing legacy plans are reconciled by migration `1772000000000_CloudInitAndPlanProvisioningConsolidated`. Integrated service ids were renamed from `controller`/`manager` to `agenstra-controller`/`agenstra-manager` by migration `1775500000000_RenameIntegratedProvisioningServiceIds` (runtime parsers still accept the legacy aliases).
- `billing_day_of_month` for subscription period alignment
- `allowCustomerLocationSelection` when geography override is supported
- `allowCustomerServerTypeSelection` and `allowedServerTypes` when server-type override is supported (provider schema `basePriceFromField: 'serverType'`)
- `allowCustomerLocationSelection` when geography override is supported (not for null `serviceTypeId`)
- `allowCustomerServerTypeSelection` and `allowedServerTypes` when server-type override is supported (provider schema `basePriceFromField: 'serverType'`; not for null `serviceTypeId`)
- Provider `configSchema.properties` may set `scope: "server"` or `scope: "product"` with optional `productServices` (`agenstra-controller`, `agenstra-manager`, `decabill-billing`) to control the plan editor. Server fields stay under **Provider default config**; product fields appear under **Product defaults** when required by selected customer options.

### Customer Geography Selection
Expand Down
2 changes: 1 addition & 1 deletion docs/decabill/features/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Order service plans, manage subscription lifecycle, and provision cloud infrastr

## Overview

Subscriptions link a user to a service plan. Plans reference a service type that may include Hetzner or DigitalOcean provisioning. Each subscription can have one or more subscription items representing provisioned or pending instances.
Subscriptions link a user to a service plan. Plans usually reference a service type that may include Hetzner or DigitalOcean provisioning. Plans may also use `serviceTypeId: null` (billing-only, no deployment). Each subscription can have one or more subscription items representing provisioned, pending, or immediately active (non-provisioned) instances.

The order flow requires a complete [Customer Profile](./customer-profiles.md) before `POST /subscriptions` is accepted.

Expand Down
2 changes: 2 additions & 0 deletions docs/decabill/features/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Events are published from the **billing** service after successful mutations.
- `subscription.ssh_access_granted` (metadata only: subscription/item ids, hostname, grantedAt — never the private key)
- `addon.activated`, `addon.deactivated`, `addon.provision_failed`, `addon.teardown_failed`

Plans with `serviceTypeId: null` (billing-only, no deployment) do not emit `subscription.provisioned` / `provision_failed`; fulfillment is immediate and silent, same as other non-cloud providers. Plan catalog CRUD does not emit webhooks. Orders still emit `subscription.created`. See [Service Types and Plans](./service-types-and-plans.md#plans-without-a-service-type-null).

Addon payloads include subscription id/number, plan id/name, addon id/key/name, and status timestamps. Config snapshots and secrets are never included. See [Addons](./addons.md).

Config-change payloads carry the subscription payload plus `configChangeId`, `appliedSteps` (step keys such as `serverType` or `addonAdd:<addonId>`), `billingOutcome` (`none` | `charged` | `credited` | `deferred`), and `errorCode`. The requested payload is never included because addon configuration can hold credentials.
Expand Down
17 changes: 16 additions & 1 deletion graph/graph.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"generatedAt": "2026-08-03T18:42:25.796Z",
"generatedAt": "2026-08-04T14:49:14.978Z",
"nodes": [
{
"id": "project:@forepath/test/mounted-plugin-fixture",
Expand Down Expand Up @@ -50443,6 +50443,11 @@
"to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/addons.repository.ts",
"type": "injects"
},
{
"from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts",
"to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts",
"type": "injects"
},
{
"from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/service-plans.controller.ts",
"to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/withdrawal-policy.service.ts",
Expand Down Expand Up @@ -61603,6 +61608,16 @@
"to": "api:HTTP:POST:/admin/billing/webhooks",
"type": "documents"
},
{
"from": "concept:decabill-event-catalog",
"to": "api:HTTP:GET:/service-types",
"type": "documents"
},
{
"from": "concept:decabill-event-catalog",
"to": "api:HTTP:POST:/service-types",
"type": "documents"
},
{
"from": "concept:decabill-event-catalog",
"to": "api:HTTP:GET:/addons",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Backend billing module providing subscription management, backorders, availabili
- WebSocket dashboard status stream: see [`spec/asyncapi.yaml`](spec/asyncapi.yaml) (namespace `billing`, permission-locked; mirrors REST subscription ownership).
- WebSocket project board stream: see [`spec/asyncapi.yaml`](spec/asyncapi.yaml) (namespace `projects`, room `project:{projectId}`; see [`docs/project-board-realtime.mmd`](docs/project-board-realtime.mmd)).
- **Projects:** Customer-assigned work tracking with admin CRUD, milestones, tickets, time entries, KPI summaries, and `POST /admin/billing/projects/{projectId}/bill-time` (see [`docs/project-bill-time.mmd`](docs/project-bill-time.mmd)).
- Service types and plans (admin endpoints), including optional per-plan customer geography selection when the provider schema supports it.
- Service types and plans (admin endpoints), including optional per-plan customer geography selection when the provider schema supports it, and billing-only plans with `serviceTypeId: null` (no deployment).
- CloudInit config templates (admin CRUD) and order-fields for custom service plans.
- Subscription ordering, cancel, resume, and statutory withdrawal for authenticated users.
- Backorder management for provider capacity failures.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,12 @@ paths:
responses:
'204':
description: Service plan deleted
'400':
description: Plan cannot be deleted (e.g. still referenced by subscriptions)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
content:
Expand Down Expand Up @@ -5308,9 +5314,13 @@ components:
serviceTypeId:
type: string
format: uuid
nullable: true
description: >
Service type UUID, or null when the plan has no service type
and deploys nothing.
serviceTypeName:
type: string
description: Display name of the service type (no config schema)
description: Display name of the service type (no config schema); empty when serviceTypeId is null
billingIntervalType:
type: string
enum: [hour, day, month, year]
Expand Down Expand Up @@ -5352,12 +5362,15 @@ components:
$ref: '#/components/schemas/WithdrawalPolicy'
CreateServicePlanDto:
type: object
required: [serviceTypeId, name, billingIntervalType, billingIntervalValue]
required: [name, billingIntervalType, billingIntervalValue]
properties:
serviceTypeId:
type: string
format: uuid
description: Service type ID
nullable: true
description: >
UUID of an existing service type, or null/omitted for a billing-only plan that deploys
nothing (default in the admin UI).
name:
type: string
description:
Expand Down Expand Up @@ -5467,7 +5480,11 @@ components:
type: object
properties:
id: { type: string, format: uuid }
serviceTypeId: { type: string, format: uuid }
serviceTypeId:
type: string
format: uuid
nullable: true
description: Service type UUID, or null when the plan has no service type / no deployment
name: { type: string }
description: { type: string, nullable: true }
billingIntervalType: { type: string, enum: [hour, day, month, year] }
Expand Down Expand Up @@ -5868,11 +5885,15 @@ components:
properties:
id: { type: string, format: uuid }
subscriptionId: { type: string, format: uuid }
serviceTypeId: { type: string, format: uuid }
serviceTypeId:
type: string
format: uuid
nullable: true
description: Service type UUID, or null when the parent plan has no service type
serviceTypeName:
{
type: string,
description: User-facing service type name from the catalog (billing_service_types.name),
description: User-facing service type name from the catalog (billing_service_types.name); empty when serviceTypeId is null,
}
provisioningStatus: { type: string, enum: [pending, active, failed] }
hostname:
Expand Down
Loading
Loading