[data-reveal]{opacity:1!important;transform:none!important}
Extending
SaaS

Extending

Plug your modules into dms-saas through its interface, replace the public screens, capture sign-up data, bill usage, preview invoices and add workspace pages.

dms-saas exposes its extension points through the separately published @antelopejs/interface-dms-saas package. Pages you add stay backend-declared trees of DMS blocks: make only the block the DMS lacks a custom component, and give it .meta({ name, description, icon }) so the role editor can show it.

Choose an integration

You want to…Start hereExpected result
Save a referral code at signupRegistration extrasYour module stores the answers before registration completes
Bill compute or storage usageInvoice line itemsUsage appears beside the subscription on renewal invoices
Show an estimated billUpcoming invoice previewA priced estimate, or an explicit absent/unavailable state
Add a billing blockWorkspace settings pagesYour block appears beside the existing billing content
Provision a resource after paymentWorkspace lifecycleA durable notification your module deduplicates before creating resources

Start with the example for your task. The interface reference contains the complete types and failure contracts; the remaining sections explain how to use them in a product.

Consuming this module

A module that extends dms-saas depends on the separately published interface package ("@antelopejs/interface-dms-saas" in its dependencies) and imports the extension points from it. Load the dms-saas runtime separately in the project; importing its package root boots the registration graph. The consumer reference maps supported subpaths and defines signatures, results, failures and lifecycle contracts.

invoiceLineItems and tenantLifecycle come off the root entry as namespaces because each carries an internal proxy namespace of its own; db is reachable only through its subpath, since importing it registers the SaaS tables.

After a seed or import writes subscription records directly, await RecomputeTenantBillingState for each affected tenant. This refreshes the derived billing and workspace directory state; it does not replace the runtime's Stripe or lifecycle work.

The interface package must be released before dms-saas, which depends on it through >=<interface version> <0.<minor + 1>.0: dms-saas implements the interface, so it caps the range below the next minor and a breaking interface minor never reaches a dms-saas that does not implement it. During workspace development pnpm resolves that range to the sibling package. Release the interface package first, then release dms-saas, and update consumers to the published interface version.

Tenant owner permissions

The dms-saas.plan-intersection permissions resolver runs at order 100. For a member whose isTenantOwner is true in the requested tenant, it grants all explicit permissions from that tenant's resolved plan, including inherited permissions, regardless of assigned roles. Permissions outside the plan are removed even when a role grants them. Other members retain only the intersection of their incoming permissions and the resolved plan.

A plan grants exactly the ids it lists, nothing nested under them: the plan editor's permission tree adds a node's descendants and ancestors when it is ticked, so plans built from the UI list every component id, and plans seeded by code must list them too.

Tenant ownership never grants *, even if a plan contains it. An incoming * (including the DMS platform owner's wildcard) retains the existing behavior: the wildcard survives, and other incoming permissions are intersected with the plan. Without a subscription, a planId, or an existing plan, the resolver preserves incoming permissions and grants nothing new. This compatibility fallback is not a denial of access for tenants with missing billing data.

Applications define their business permissions and can project the capabilities that survive the plan into resource-specific actions in later resolvers. They do not need a separate tenant-owner grant list. Subscription access gates remain independent and unchanged. This resolver alone does not constrain defaultGranted permissions or guards that do not consult effective permissions.

Public screens

dms-saas ships a working registration screen so a fresh project can sell from day one. It is a reference implementation, not a design: the module owns the money and the compliance of the flow, the consuming SaaS owns the conversion.

Replacing it takes two steps. Turn the bundled page off in the module config — page registration is keyed by slug, so declaring a second page on register races the bundled one instead of overriding it:

{
  "modules": {
    "dms-saas": {
      "config": {
        "publicScreens": { "register": false }
      }
    }
  }
}

Then register your own page on the register slug and build it on the useSaasRegistration() composable the Vue adapter auto-imports. It holds the whole flow — the card policy, the Stripe setup intent and card confirmation, the order the requirements are validated in, the provisioning call and the landing route — and renders nothing, so a re-themed screen keeps the same steps:

<script setup lang="ts">
const {
  form, // name, email, password, hasAcceptedLegal, skipsPaymentMethod
  paymentElementId,
  canSkipPaymentMethod, // true under `optional`
  isPaymentStepVisible, // bind with v-show so the Stripe element stays mounted
  passwordStrength, // { strength, score, color } for <DmsPasswordStrength>
  isRegistrationClosed, // render the invitation-only state instead of the form
  errorMessage,
  isSubmitting,
  submit,
} = useSaasRegistration({ redirectTo: "/welcome" });
</script>

Nothing it returns throws: a failure surfaces as a translated errorMessage, and submit() resolves to the new tenant id or null. A password outside the DMS policy stops submit() before the card is confirmed. The workspace name is the localised saas.register.default_workspace_name. Every screen stays enabled by default, so an existing project needs no configuration change.

Registration extras and the provisioning hook

A SaaS that captures its own fields at signup — referral source, team size, anything — sends them as extras on the registration payload and writes them from a TENANT_BEING_PROVISIONED listener. dms-saas forwards the object verbatim: it never reads it, never stores it, never returns it. The data stays the consumer's, and so does the schema.

import type { TenantBeingProvisionedPayload } from "@antelopejs/interface-dms-saas/provisioning";
import {
  Hook,
  RegisterHook,
} from "@antelopejs/interface-dms/hooks";

RegisterHook(
  Hook.TENANT_BEING_PROVISIONED,
  async ({ tenantId, userId, extras }: TenantBeingProvisionedPayload) => {
    await storeOnboardingAnswers(tenantId, userId, extras);
    return undefined;
  },
);

storeOnboardingAnswers is your module's persistence function. Keep the explicit undefined return: this hook's handler contract is Promise<undefined>, not Promise<void>.

The provisioning contract defines capture limits, rollback and the per-card lock. Keep the hook to bounded persistence work: a failure cancels registration. For external resource creation after payment, use the durable tenant lifecycle instead.

On the browser side, useSaasRegistration() takes the capture as an option and sends it with the rest of the form:

<script setup lang="ts">
const answers = reactive({ referral: "", teamSize: "" });
const { form, submit } = useSaasRegistration({ extras: () => answers });
</script>

Invoice line items

RegisterInvoiceLineItemsProvider lets a module bill its own metered usage on the workspace's subscription invoice. When Stripe opens the renewal invoice of a billing cycle, dms-saas asks every registered provider for the lines it wants and pushes them as Stripe invoice items while the invoice is still a draft, so they are charged on finalization.

dms-saas stays agnostic: it never interprets a line. What a metric is, how it is measured and how it is priced belong to the provider.

import {
  RegisterInvoiceLineItemsProvider,
  UnregisterInvoiceLineItemsProvider,
} from "@antelopejs/interface-dms-saas/invoice-line-items";

const PROVIDER_ID = "cloud";

export async function construct(): Promise<void> {
  RegisterInvoiceLineItemsProvider({
    id: PROVIDER_ID,
    resolve: async ({ tenantId, periodStart, periodEnd, currency }) => {
      const usage = await aggregateUsage(tenantId, periodStart, periodEnd);
      return [
        {
          key: "vcpu-minutes",
          description: "vCPU minutes",
          amountCents: usage.vcpuCents,
          quantity: usage.vcpuMinutes,
          unit: "minute",
        },
        {
          key: "included-credit",
          description: "Included usage credit",
          amountCents: -usage.includedCreditCents,
        },
      ];
    },
  });
}

export async function stop(): Promise<void> {
  UnregisterInvoiceLineItemsProvider(PROVIDER_ID);
}

Use a unique provider ID, stable line keys and pre-aggregated usage. Follow the invoice line-item contract for paid-period boundaries, amounts, idempotency, error handling and preview calls. A resolver also prices previews, so reading usage must not consume it.

Upcoming invoice preview

GetUpcomingInvoicePreview returns what Stripe will bill the current workspace next, tax included, so a page can show the exact VAT and total before the invoice exists. Stripe computes every figure (Stripe Tax: country, VAT number, reverse charge); dms-saas copies them without recomputing any.

import { GetUpcomingInvoicePreview } from "@antelopejs/interface-dms-saas/billing";

const preview = await GetUpcomingInvoicePreview({ tenantId, userId });
if (preview.status === "available") {
  const { taxMinorUnits, totalMinorUnits, currency } = preview;
  render({ tax: taxMinorUnits, total: totalMinorUnits, currency });
}

Take the scope from trusted authentication and handle all three states: available, absent and unavailable. The billing reference defines their fields, reason codes, authorization failures and monetary units.

Usage is quoted up to the time of the request with the same providers, paid coverage window and line validation as the renewal invoice, so the preview's tax covers plan and usage together. It is an estimate of a cycle still running: the renewal invoice bills the whole cycle.

Previews are cached per workspace for an hour, shared by every instance through the database. Set upcomingInvoicePreviewCacheTtlSeconds to change that lifetime, or to 0 to price every request. A cached preview is dropped as soon as the workspace's subscription (plan change, status) or billing identity (address, VAT number) changes locally, and when Stripe reports an invoice created, finalized, paid or voided, a subscription updated or deleted, or a customer updated. unavailable results are never cached. Display computedAt to tell the reader how current the figures are.

The Stripe reads a page renders (this preview, the billing status, the VAT check) give up after stripe.readTimeoutMs (4000 ms by default) and are not retried unless stripe.readMaxNetworkRetries says so: a slow Stripe makes the preview unavailable rather than holding the page. Writes keep the Stripe SDK's own timeout and retries.

Workspace settings pages

A module adding a page to workspace settings takes workspaceSettingsCategory from @antelopejs/interface-dms/page, rather than rebuilding its hierarchy. The SaaS interface does not export this category or a category getter.

import { PageController, RegisterPage, workspaceSettingsCategory } from "@antelopejs/interface-dms/page";

@RegisterPage()
export class WorkspaceRegistriesController extends PageController("registries", {
  displayName: "$cloud.workspace.registries.title",
  category: workspaceSettingsCategory,
  icon: "i-ph-package",
  order: 10,
}) {}

Extend the existing tenant billing page through its stable identifiers instead of importing its controller. Use an anchor such as tenantBillingPage.components.planCard; the page extension reference defines placement and registration lifetime.

import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
import {
  RegisterTenantBillingPageExtension,
  tenantBillingPage,
  type TenantBillingPageExtensionRegistration,
} from "@antelopejs/interface-dms-saas/pages";

let billingBlocks: TenantBillingPageExtensionRegistration | undefined;

export function construct(): void {
  billingBlocks = RegisterTenantBillingPageExtension({
    name: "CloudBillingBlocks",
    components: [
      {
        key: "inProgressInvoice",
        component: CustomComponent("CloudInProgressInvoice").meta({
          name: "$cloud.billing.in_progress_invoice",
          description: "$cloud.billing.in_progress_invoice_description",
          icon: "i-ph-receipt",
        }),
        side: "after",
        anchorKey: tenantBillingPage.components.planCard,
      },
    ],
  });
}

export function stop(): void {
  billingBlocks?.unregister();
  billingBlocks = undefined;
}

Platform modules can extend the SaaS platform-owner workspace detail without importing its controller or the private saasModule instance. Use platformSaasModule, platformWorkspaceDetailPage, and RegisterPlatformWorkspaceDetailPageExtension from the same public pages entry point. platformSaasModule is also a synchronous descriptor; runtime code that requires the registered module root can await GetPlatformSaasModule(). Platform data controllers that must hide a tenant routing field can import HiddenStringFilter from @antelopejs/interface-dms-saas/data-api; the underlying SaaS data controllers remain private. Canonical database models are available from /db, with the import and direct-write warnings.

Plan feature labels and values

A feature's displayName and tooltip are localized fields (@Localized, falling back to en): the row stores one value per locale and every reader gets the text in the viewer's language, taken from the x-content-language header the DMS frontend sends. The tenant plan pages and the plan admin pages show it as written; the features table in the platform admin edits it one language at a time.

A module declaring features writes every locale at once through localize("*"), never i18n keys:

const row = FeatureModel.fromPlainData({
  _id: "cloud.plan.egress",
  displayName: { en: "Included egress", fr: "Trafic sortant inclus" },
  tooltip: { en: "Outbound traffic", fr: "Trafic sortant" },
  // ...
}).localize("*");
await GetModel(FeatureModel).insert(row);

Read a row with localizeFeature(feature, locale) from @antelopejs/interface-dms-saas/plans. A row without a value in the viewer's locale or in en has no tooltip.

Values are formatted from the feature's valueType and unit: -1 reads as unlimited, booleans as ✓/—, numbers are grouped in the viewer's locale. A per <unit> unit makes the value a price in the plan's currency, and currency units an amount of it. Known units are scaled to something a person reads at a glance; any other unit is shown verbatim after the grouped number. A text value follows the same convention as labels: a $<key> value is translated in the declaring module's locales, anything else is shown as stored.

Stored unitQuantity reads asper <unit> price reads as
byte(s)bytes, KB, MB, GB or TB (powers of 1000)per GB
minute(s)minutesper minute

A module declaring features in its own units teaches the plan pages how they read with registerPlanFeatureUnit, auto-imported like the other dms-saas composables. Call it from a frontend plugin that runs on the server and in the browser, so server-rendered and hydrated values agree. Each scale says how many stored units it holds; a quantity takes the largest scale it fills, and a per <unit> price reads per priceScale. Labels are full i18n keys the module ships in its own locales, each a node holding a quantity message ({value}, pluralised on the scaled value) and a price message ({price}):

// frontend-vue/app/plugins/plan-feature-units.ts
const MINUTES_PER_HOUR = 60;
const vcpuHour = { label: "cloud.plan.units.vcpu_hour", size: MINUTES_PER_HOUR };

export default defineDmsPlugin(() => {
  registerPlanFeatureUnit(["vCPU-minute", "vCPU-minutes"], {
    scales: [vcpuHour],
    priceScale: vcpuHour,
  });
});
{
  "cloud": {
    "plan": {
      "units": {
        "vcpu_hour": {
          "quantity": "{value} vCPU-hour | {value} vCPU-hours",
          "price": "{price} / vCPU-hour"
        }
      }
    }
  }
}

Units match case-insensitively, and a registered unit takes precedence over a built-in one of the same name.