Create your own
Lesson illustration

Defining Host-Integration Boundaries for Billable Owners and Eligible Assignees

Welcome. This course builds a reusable, provider-neutral subscription module around NestJS, TypeORM, and PostgreSQL. We will begin by making one architectural decision that affects nearly every later feature: what entity is actually subscribed, and how does the billing module safely ask the host application who may act for that entity?

Your module must support a person buying a subscription for themselves, while also supporting a company, organization, or team buying seats for many people. This lesson defines that boundary without coupling the billing domain to your existing user, organization, or membership tables.

By the end, you should have a concrete contract for:

  • identifying a billable owner with stable ownerKind and ownerId;
  • distinguishing the authenticated actor from the subscription owner;
  • asking the host application whether an actor may perform billing actions;
  • determining who is eligible to occupy a paid seat.

Plan versions, prices, subscriptions, and database migrations come next. First, establish the identity boundary they will rely on.


1. Three identities that must not be conflated

A subscription module commonly needs to reason about three different identities:

IdentityMeaningExample
Billable ownerThe entity that owns the subscription and receives its entitlementsorganization:acme-uuid
Authorized actorThe authenticated principal requesting an actionuser:priya-uuid
Eligible seat assigneeA person who may consume one purchased seatuser:devon-uuid

They can sometimes be the same person, but they are not the same concept.

Consider these cases:

  1. Individual subscription: Priya buys a Pro plan for herself. Priya is the billable owner, authorized actor, and sole eligible assignee.
  2. Organization subscription: Acme buys 25 seats. Priya is an Acme billing administrator and starts checkout. Acme is the billable owner; Priya is only the actor.
  3. Seat management: Priya assigns a seat to Devon. Acme remains the owner, Priya is the actor, and Devon is the assignee.
  4. Multiple memberships: Priya belongs to Acme and Globex. She may administer billing at Acme but only be a viewer at Globex. Her user ID alone cannot determine whether a billing command is allowed.

The key principle is:

A subscription belongs to the commercial customer, while a user performs actions on that customer’s behalf.

For B2B products, the commercial customer is usually the organization. For B2C, it is usually an individual account. A reusable module must support both without having separate “personal subscription” and “organization subscription” subsystems.

How Senior Engineers Build B2B Multi-Tenant SaaS (RBAC, Billing & Entitlements)

Watch “How Senior Engineers Build B2B Multi-Tenant SaaS (RBAC, Billing & Entitlements)” by Jan Marshal for a concise mental model of organizations, members, and organization-scoped permissions.

Watch tenant identity to distinguish the customer or tenant from the people who use the system. Then watch roles and permissions. Focus on the fact that permissions are evaluated for a user within an organization, and that role labels alone should not be your authorization API.

A user may have a different role in every organization. Therefore, do not make a subscription record look like this:

// Avoid: assumes every subscription belongs directly to one user.
type Subscription = {
  userId: string;
  planId: string;
};

That model breaks as soon as an organization owns the subscription, or when the same user belongs to several organizations with different plans.


2. Representing the billable owner with a stable reference

The billing module should not import your UsersRepository, OrganizationsRepository, or MembershipsRepository. Those are host-application concerns. Instead, the billing module receives a small, durable reference.

export const BILLABLE_OWNER_KINDS = [
  'individual',
  'organization',
  'team',
] as const;

export type BillableOwnerKind =
  (typeof BILLABLE_OWNER_KINDS)[number];

export interface BillableOwnerRef {
  kind: BillableOwnerKind;
  id: string;
}

For your PostgreSQL application, id will normally contain a UUID. The interface still uses string because the billing module should treat it as an opaque stable identifier. It should not assume that IDs encode tenant names, emails, or database table details.

Examples:

const priyaPersonalAccount: BillableOwnerRef = {
  kind: 'individual',
  id: 'e99dd607-efce-4b45-9f53-7a2a762d97a3',
};

const acmeOrganization: BillableOwnerRef = {
  kind: 'organization',
  id: '6e1ebc45-5e7f-40c1-a9d7-fefa2a79004d',
};

What “stable” means

A valid owner reference must survive changes that should not alter commercial history:

Use as ownerIdDo not use as ownerId
Immutable UUIDEmail address
Internal immutable account IDOrganization slug
Stable external identity ID, if guaranteed immutableDisplay name
Stable tenant/workspace IDCurrent domain name

An organization might rename itself from “Acme Labs” to “Acme Systems,” change its logo, or change its slug. Its subscription, invoices, audit trail, provider customer reference, and entitlements must remain associated with the same commercial entity.

The host application, not the billing module, owns the mapping between human-friendly labels and stable IDs.

Architectural Considerations for Identity in a Multitenant Solution

Read Microsoft’s guidance to reinforce two important boundaries: tenant membership uses immutable identifiers, and commercial licensing belongs in application logic rather than in the identity provider.

In “Grant users access to tenant data,” read the identifier rule. Notice that a user-to-tenant relationship is not safely represented by mutable profile information. Then, in “Entitlements and licensing,” read the licensing separation. Focus on why authentication and commercial licensing have different responsibilities.

The identity provider proves who signed in. The host application determines the active organization and its permissions. The subscription module records commercial state and evaluates entitlements later in the course. Keep those responsibilities separate.

A tenant-context design: identity supplies the user, tenant, and role context; the application service uses that scoped context and access policies before accessing tenant-owned data. The subscription module should receive this validated owner context rather than infer it from a user ID.

3. The host-integration boundary

The billing domain needs answers to a few questions, but it does not need to know how your host application stores members or roles.

A clean boundary is an interface implemented by the host application:

export type BillingPermission =
  | 'billing:read'
  | 'billing:manage'
  | 'seats:manage';

export interface ActorRef {
  kind: 'user';
  id: string;
}

export type AuthorizationDecision =
  | { allowed: true }
  | {
      allowed: false;
      reason:
        | 'OWNER_NOT_FOUND'
        | 'ACTOR_NOT_A_MEMBER'
        | 'MISSING_PERMISSION'
        | 'OWNER_INACTIVE';
    };

export type EligibleSeatAssignee =
  | {
      eligible: true;
      user: ActorRef;
      displayName: string;
    }
  | {
      eligible: false;
      reason:
        | 'USER_NOT_FOUND'
        | 'NOT_AN_ACTIVE_MEMBER'
        | 'ASSIGNEE_NOT_ALLOWED_FOR_OWNER';
    };

export interface BillingHostPort {
  ownerExists(owner: BillableOwnerRef): Promise<boolean>;

  authorize(
    actor: ActorRef,
    owner: BillableOwnerRef,
    permission: BillingPermission,
  ): Promise<AuthorizationDecision>;

  resolveEligibleSeatAssignee(
    owner: BillableOwnerRef,
    assignee: ActorRef,
  ): Promise<EligibleSeatAssignee>;
}

This is a port in the hexagonal-architecture sense: the subscription module defines what it needs, while the host application supplies the implementation.

The following division of responsibility is useful:

Billing module ownsHost application owns
Plans, prices, subscriptions, periods, invoices, provider referencesUsers, organizations, teams, memberships, authentication
Whether a subscription is active, in trial, past due, canceled, or expiredWhich actor is currently authenticated
Seat quantity purchased and seat assignmentsWhether a user is a current eligible member
Entitlement rules derived from subscription stateRoles, permissions, organization context
Billing audit historyOrganization lifecycle and user lifecycle

Authorization should be permission-based

Avoid this inside the billing module:

if (membership.role === 'admin') {
  // start checkout
}

The word admin is a host-specific role label. Its meaning can vary by organization or change over time.

Instead, ask for a capability:

const decision = await billingHost.authorize(
  actor,
  owner,
  'billing:manage',
);

if (!decision.allowed) {
  throw new ForbiddenException({
    code: 'BILLING_ACTION_NOT_ALLOWED',
    reason: decision.reason,
  });
}

The host can map billing:manage to whatever its authorization model requires:

  • organization owner;
  • finance administrator;
  • a custom “billing manager” role;
  • a fine-grained relationship-based permission;
  • a direct grant for a specific actor.

The billing module remains reusable.

Seat eligibility is not billing authorization

The ability to purchase or manage seats is intentionally separate from the ability to receive one.

For example:

  • A finance administrator may manage billing but not be eligible for a product seat.
  • An active engineering member may be eligible for a seat but unable to view invoices.
  • An external guest might belong to the organization but be excluded from seat assignment.
  • A deactivated member must no longer be eligible, even if they were previously assigned.

The host adapter determines eligibility based on your membership rules. The subscription module should not reproduce those rules with queries such as SELECT * FROM memberships.

For an individual owner, the implementation can be simple:

async resolveEligibleSeatAssignee(
  owner: BillableOwnerRef,
  assignee: ActorRef,
): Promise<EligibleSeatAssignee> {
  if (owner.kind !== 'individual' || owner.id !== assignee.id) {
    return {
      eligible: false,
      reason: 'ASSIGNEE_NOT_ALLOWED_FOR_OWNER',
    };
  }

  return {
    eligible: true,
    user: assignee,
    displayName: 'Resolved by host profile service',
  };
}

For an organization, it would query the host membership model and apply rules such as “active internal members, excluding guests.”


4. Make organization context explicit

A request from Priya needs both an identity and an owner context. Her identity answers, “who is making this request?” The owner reference answers, “for which commercial customer is this request being made?”

export interface SubscriptionCommandContext {
  actor: ActorRef;
  owner: BillableOwnerRef;
  requestId: string;
}

For example, a checkout request might target:

POST /api/billing/owners/organization/6e1ebc45-5e7f-40c1-a9d7-fefa2a79004d/checkout

The route parameter is only an input, not proof of authorization. Your NestJS application should:

  1. authenticate the user and create ActorRef;
  2. parse and validate the owner kind and UUID;
  3. call BillingHostPort.authorize for that actor and owner;
  4. pass the resulting SubscriptionCommandContext into the billing use case.

A client must never gain access simply by changing an organization ID in a URL, body field, Angular store value, or X-Organization-Id header.

This matters especially when one user belongs to multiple organizations. The selected organization must be part of the authorization decision for each command.

Authorization Through Organization Context

Read OpenFGA’s discussion of organization context to see why a user’s current organization must be treated as request-specific context, not as a permanent global attribute on the user.

In “Use Contextual Tuples For Context Related Checks,” read the concurrent-context example. The important point is that the same person can operate in two organization contexts at the same time, such as in separate browser tabs. Your API command context must therefore carry the selected owner explicitly.

A practical NestJS adapter could look like this:

@Injectable()
export class TypeOrmBillingHostAdapter implements BillingHostPort {
  constructor(
    private readonly memberships: MembershipService,
    private readonly organizations: OrganizationService,
    private readonly users: UserService,
    private readonly permissions: PermissionService,
  ) {}

  async ownerExists(owner: BillableOwnerRef): Promise<boolean> {
    switch (owner.kind) {
      case 'individual':
        return this.users.existsActive(owner.id);

      case 'organization':
      case 'team':
        return this.organizations.existsActive(owner.id);
    }
  }

  async authorize(
    actor: ActorRef,
    owner: BillableOwnerRef,
    permission: BillingPermission,
  ): Promise<AuthorizationDecision> {
    if (!(await this.ownerExists(owner))) {
      return { allowed: false, reason: 'OWNER_NOT_FOUND' };
    }

    const isMember = await this.memberships.isActiveMember(
      actor.id,
      owner,
    );

    if (!isMember && owner.kind !== 'individual') {
      return { allowed: false, reason: 'ACTOR_NOT_A_MEMBER' };
    }

    const allowed = await this.permissions.hasPermission(
      actor.id,
      owner,
      permission,
    );

    return allowed
      ? { allowed: true }
      : { allowed: false, reason: 'MISSING_PERMISSION' };
  }

  async resolveEligibleSeatAssignee(
    owner: BillableOwnerRef,
    assignee: ActorRef,
  ): Promise<EligibleSeatAssignee> {
    const eligible = await this.memberships.isEligibleForSeat(
      assignee.id,
      owner,
    );

    return eligible
      ? {
          eligible: true,
          user: assignee,
          displayName: await this.users.displayName(assignee.id),
        }
      : {
          eligible: false,
          reason: 'NOT_AN_ACTIVE_MEMBER',
        };
  }
}

The method names will differ in your application. What matters is the direction of dependency: the adapter knows the host’s tables and services; the billing domain does not.


5. Polymorphic owner references and relational integrity

A subscription owner reference is a polymorphic association: its ID may refer to one of several entity categories.

{
  kind: 'individual',
  id: 'e99dd607-efce-4b45-9f53-7a2a762d97a3'
}

{
  kind: 'organization',
  id: '6e1ebc45-5e7f-40c1-a9d7-fefa2a79004d'
}

In a conventional relational schema, one foreign key references one specific table. A pair such as owner_kind and owner_id cannot be a normal foreign key to both users(id) and organizations(id).

How to Solve This Database Design Problem

Watch Database Star’s short introduction to polymorphic associations before deciding where referential integrity can and cannot be enforced by PostgreSQL.

Watch the core problem. Translate its comment-to-content example into this lesson: a subscription can have one owner, but that owner may be an individual, organization, or team.

A polymorphic access-control schema: the central `accesses` table stores both an owner kind and owner ID, plus a resource kind and resource ID, allowing users, teams, and organizations to relate to several resource types. A subscription module uses the same owner-reference idea, while keeping authorization and membership checks behind the host boundary.

For this module, a direct pair of owner-reference fields is appropriate because the subscription system must remain independent of the host’s ownership tables. The trade-off is deliberate:

  • PostgreSQL can validate that owner_kind has an allowed value.
  • PostgreSQL can validate that owner_id has the expected UUID shape if all host IDs are UUIDs.
  • PostgreSQL cannot automatically guarantee that every pair points to a currently existing host record.
  • The host adapter validates existence, membership, and eligibility at command time.

Later, when you create the subscription migration, the conceptual fields will be:

owner_kind text not null,
owner_id uuid not null

You will also add appropriate check constraints and indexes. Do not put a foreign key on owner_id to users(id), because that would make organization subscriptions impossible.

An optional local registry

If later reporting and internal database foreign keys become more important, introduce a local billing_owners registry:

billing_owners
- id
- owner_kind
- owner_id
- created_at
- archived_at

Then subscriptions can reference billing_owners.id with a real foreign key, while the registry maintains a unique constraint on (owner_kind, owner_id).

That is an optional internal normalization step, not a replacement for host authorization. It still must not become a duplicate source of truth for membership, roles, or organization lifecycle.


6. Boundary rules to implement now

Before creating plan entities, write down these integration rules in your module’s README or architecture decision record.

Invariants

  1. Every subscription command includes exactly one immutable BillableOwnerRef.
  2. A billable owner is not inferred from the actor’s user ID.
  3. An actor may act only after a host permission check scoped to that owner.
  4. Seat assignees are independently validated by the host against that owner.
  5. Role names never cross the billing boundary; permissions do.
  6. The billing module stores references, not copies of users, organizations, roles, or memberships.
  7. Mutable labels such as emails, names, and slugs are never commercial identifiers.
  8. Owner deletion or deactivation must not silently reassign a subscription to a different owner.

Minimal permission vocabulary

Start small and evolve it only when your product needs more granularity:

PermissionUsed for
billing:readView subscription summary, invoices, renewal status
billing:manageStart checkout, change plan, change paid quantity, cancel
seats:manageAssign and release seats

A small vocabulary keeps the integration straightforward. The host can grant multiple permissions through its own roles, policies, or relationship rules.

Implementation checkpoint

Add the following to your NestJS codebase now:

  • BillableOwnerRef, ActorRef, and BillingPermission types in a billing-domain package.
  • BillingHostPort as an interface owned by the billing module.
  • A host-side TypeORM adapter that implements the interface using existing user, organization, membership, and permission services.
  • Tests proving that:
    • a billing admin at Acme can manage Acme’s subscription;
    • the same user cannot manage Globex without the relevant permission;
    • an inactive or guest member cannot be assigned an Acme seat;
    • an individual owner can be handled without requiring an organization membership;
    • changing an organization display name does not affect the owner reference.

Keep these tests at the adapter boundary. They protect the most important future assumption: every subscription command will operate in the correct owner context.


Key takeaways

A reusable subscription system should model the billable owner, authorized actor, and seat assignee separately. Store subscriptions against a stable, polymorphic owner reference such as (organization, UUID) or (individual, UUID), rather than directly against user_id.

The billing module should ask the host application three things: whether the owner exists, whether the current actor has a specific owner-scoped permission, and whether a proposed assignee is eligible for a seat. This lets the subscription domain remain provider-neutral and independent of your current organization and authorization schema.

Next, you will implement versioned plan and price entities: fixed recurring charges, per-seat charges, billing intervals, currencies, feature entitlements, and retired offerings.

Can't find a good explanation? Sign up and we'll make it for you

Sign up