Skip to content

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

pub enum AddonPermission {
    View,
    ViewCredentials,
    ManageExternalAccess,
}

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)