Additional Services (Add-ons)¶
Add-ons are optional services that attach to an existing order — databases, voice servers (TeamSpeak), etc. They are not standalone products. They appear as provisions in the order's Provisions tab, have their own billing lifecycle, and can be added at checkout or post-purchase.
Core Model¶
Catalog scope¶
pub enum CatalogItemScope {
Standalone, // browsable in the store, creates an independent order
Addon, // only attachable to an existing order, never shown standalone
}
Addon items also carry compatible_kinds: Vec<CatalogServiceKind> (empty vec = compatible with any). Store-browsing endpoints filter scope = Standalone.
OrderAddon¶
pub struct OrderAddon {
id: OrderAddonId,
pub order_id: OrderId,
pub catalog_item_id: CatalogItemId,
pub status: OrderAddonStatus,
pub monthly_cost: Decimal,
pub config: Option<AddonServiceConfig>,
pub created_at: DateTime<Utc>,
pub activated_at: Option<DateTime<Utc>>,
pub cancelled_at: Option<DateTime<Utc>>,
}
pub enum OrderAddonStatus {
Pending, // invoice unpaid
Active, // provisioned and running
Suspended, // parent order suspended or unpaid renewal
Cancelled,
}
Linking provisions to add-ons¶
Provision gains an optional FK: addon_id UUID REFERENCES order_addons(id). Provisions where addon_id IS NULL belong to the base order.
AddonServiceConfig¶
pub enum AddonServiceConfig {
MariaDb(MariaDbConfiguration),
}
pub struct MariaDbConfiguration {
pub database: String, // auto-generated, e.g. "db_a3f9"
pub username: String, // auto-generated, e.g. "u_a3f9"
pub password: String, // random 24-char alphanumeric
}
Credentials are generated at addon creation time and stored in order_addons.config.
Lifecycle¶
Adding an addon post-purchase¶
POST /orders/{id}/addons
body: { catalog_item_id, ... }
→ CreateOrderAddonService
1. Validate order is Active and not cancelled
2. Validate catalog item is scope=Addon and compatible with order.kind
3. Snapshot monthly_cost from pricing profile
4. Generate credentials (MariaDb) or prepare flow (future)
5. Create OrderAddon (status=Pending)
6. Create Invoice (kind=AddonActivation, amount=monthly_cost)
7. Return addon + invoice
When the invoice is marked paid → ActivateOrderAddonService provisions K8s resources.
Adding an addon at checkout¶
CreateOrderService accepts addons: Vec<(CatalogItemId, ...)>. Separate invoices are created but the UI groups them visually at checkout.
Cancelling an addon¶
DELETE /orders/{id}/addons/{addon_id}
→ CancelOrderAddonService
1. Verify caller has ManageProvisioning permission
2. Load provisions where addon_id = addon.id
3. Delete K8s resources via ClusterService
4. Mark provisions as deleted
5. Cancel any unpaid AddonRenewal invoice
6. Set addon.status = Cancelled
Renewal billing¶
ProcessBillingCyclesTask is extended: for each Active addon, if activated_at + billing_cycle_days has passed and no unpaid AddonRenewal invoice exists, generate one. Unpaid past the grace period → suspend addon. When paid → unsuspend. If the parent order is cancelled, all addons are cascade-cancelled.
Bundled (free) add-ons¶
ServiceBlueprint gains a bundled_addons() method (default = empty vec). FiveMBlueprint overrides it to return a free MariaDB addon.
ProvisionOrderService calls blueprint.bundled_addons() and for each result:
1. Creates an OrderAddon with monthly_cost = 0, status = Active (no invoice needed)
2. Calls the addon blueprint to provision
MariaDB Blueprint¶
pub struct MariaDbBlueprint;
impl MariaDbBlueprint {
pub fn create_specs(addon: &OrderAddon, namespace: &str) -> Vec<ProvisionSpec> {
vec![
// Data volume
ProvisionSpec {
kind: ProvisionKind::PersistentVolumeClaim,
role: "data",
is_primary: false,
config: ProvisionConfig::Pvc(PvcConfig { size_gb: 5 }),
},
// MariaDB deployment
ProvisionSpec {
kind: ProvisionKind::Deployment,
role: "database",
is_primary: true,
config: ProvisionConfig::Deployment(DeploymentConfig {
image: "mariadb:11-lts".into(),
env: { MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD, MYSQL_RANDOM_ROOT_PASSWORD },
ports: [{ container_port: 3306, protocol: Tcp }],
volume_mounts: [{ name: "data", mount_path: "/var/lib/mysql" }],
}),
},
// ClusterIP for internal access from the game server
ProvisionSpec {
kind: ProvisionKind::ClusterIpService,
role: "database-networking",
is_primary: false,
config: ProvisionConfig::Service(ServiceConfig { port: 3306, target_port: 3306 }),
},
]
}
}
The game server can reach the database at s-{svc_provision_id}.{namespace}.svc.cluster.local:3306.
HTTP Endpoints¶
GET /catalog/addons?service_kind=Minecraft
GET /orders/{id}/addons
POST /orders/{id}/addons
GET /orders/{id}/addons/{addon_id}
DELETE /orders/{id}/addons/{addon_id}
Per-Addon Member Permissions¶
New table:
CREATE TABLE order_member_addon_permissions (
id UUID PRIMARY KEY,
member_id UUID NOT NULL REFERENCES order_members(id) ON DELETE CASCADE,
addon_id UUID NOT NULL REFERENCES order_addons(id) ON DELETE CASCADE,
permissions JSONB NOT NULL,
UNIQUE (member_id, addon_id)
);
Default (no row for a given member + addon pair) = no access to that addon. The owner always has full access.
Endpoints for managing addon permissions per member:
PUT /orders/{id}/members/{member_id}/addons/{addon_id}/permissions
DELETE /orders/{id}/members/{member_id}/addons/{addon_id}/permissions
Implementation Order¶
| Phase | What |
|---|---|
| 1 | Domain & Schema (CatalogItemScope, OrderAddon, AddonServiceConfig, AddonPermission, migrations) |
| 2 | Addon Services (Create, Activate, Cancel, billing cycle extension) |
| 3 | MariaDB Blueprint + bundled_addons() |
| 4 | HTTP Endpoints + ReadAddonPolicy / ManageAddonPolicy |
| 5 | Dashboard (addon provision card, "Add service" modal, checkout step, advanced permissions modal) |