Developer Docs

Org Subscription Bootstrap & Ownership Picker

WARNING

Deprecated (Phase 2): The bootstrap_org_subscription TCP event described in this document has been removed. Organizations no longer auto-provision Starter subscriptions on creation. Subscriptions now come exclusively from verified Stripe payments via the Malet-First Ownership Architecture. The get_active_tier TCP call defaults to starter when no subscription record exists, so tier enforcement continues to work without a bootstrap.

When a Malet Owner creates a new Organization, the platform automatically provisions previously provisioned a free Starter tier subscription for that Organization. This ensured tier enforcement always resolved to a valid record.

The OwnershipPicker component gives Malet Owners explicit control over which context (Personal or Organization) a new Malet is created under. This replaces the previous implicit behavior where the wizard silently inherited the global org context.


Architecture: Bootstrap Flow

sequenceDiagram
    participant UI as Frontend
    participant Orgs as organizations subgraph
    participant Pay as payments subgraph
    participant DB as MongoDB

    UI->>Orgs: createOrganization(input)
    Orgs->>DB: Save Organization document
    Orgs->>Orgs: Create Owner Membership
    Orgs->>Pay: emit('bootstrap_org_subscription', {orgId, userId})
    Pay->>DB: Upsert BillingSubscription(orgId, planId='starter', status=ACTIVE)
    Note over Pay: Idempotent โ€” skips if active subscription exists

Event Payload

// Emitted by organizations subgraph
this.paymentsClient.emit('bootstrap_org_subscription', {
  orgId: org.id,      // Organization document ID
  userId: actor.id,   // Creator's user ID (for audit)
});

Handler: `subscription-tcp.controller.ts`

The payments subgraph listens for the bootstrap_org_subscription event and creates a free Starter subscription:

@EventPattern('bootstrap_org_subscription')
async handleBootstrapOrgSubscription(
  @Payload() data: { orgId: string; userId: string },
) {
  // Check for existing active subscription (idempotent)
  const existing = await this.subscriptionModel.findOne({
    orgId: data.orgId,
    status: 'ACTIVE',
  });
  if (existing) return; // Already bootstrapped

  // Create synthetic free subscription
  await this.subscriptionModel.create({
    orgId: data.orgId,
    userId: data.userId,
    planId: 'starter',
    status: 'ACTIVE',
    stripeSubscriptionId: `free_org_${data.orgId}`,
  });
}

Key design decisions:

  • Fire-and-forget: The emit() pattern means org creation succeeds even if the payments service is temporarily unavailable
  • Idempotent: Duplicate events are safely ignored
  • Synthetic Stripe ID: Free plans use free_org_{orgId} โ€” no Stripe object is created

Tier Enforcement Pipeline

When a Malet is created under an org, the resolver calls get_active_tier via TCP:

malets subgraph โ†’ TCP โ†’ payments subgraph
                         โ†’ findOne({ orgId, status: ACTIVE })
                         โ†’ returns { tier, limits }

The checkTierLimit() function in @app/common now accepts optional org context:

export function checkTierLimit(
  tier: string,
  resource: string,
  currentCount: number,
  context?: { orgName?: string },
) {
  const limits = getTierLimits(tier);
  const limit = limits[resource];
  if (currentCount >= limit) {
    const owner = context?.orgName
      ? `Your organization "${context.orgName}"`
      : "You've";
    throw new ForbiddenException(
      `${owner} reached the ${tier} plan limit of ${limit} ${resource}.`
    );
  }
}
Context Error Message
Personal "You've reached the Starter plan limit of 1 Malet."
Organization "Your organization "Acme Corp" has reached the Starter plan limit of 1 Malet."

OwnershipPicker Component

File: src/lib/components/create-malet/OwnershipPicker.svelte

A GitHub-inspired dropdown that sits above the wizard progress bar, allowing the Malet Owner to explicitly choose ownership context before creating a Malet.

Features

  • Shows Personal Account with the user's display name
  • Lists all Organization memberships from orgsStore with role and category metadata
  • "Create new organization" link โ†’ /orgs/create?then=/create-malet
  • Selected option is indicated with a check badge
  • Selecting an org sets both wizard-scoped ownership AND global org context (so the GraphQL client sends x-org-id)

Wizard State: `createMaletStore.svelte.ts`

The store was updated with wizard-scoped ownership fields:

// Wizard-scoped override (set by OwnershipPicker)
wizardOrgId = $state<string | null>(null);
wizardOrgName = $state<string | null>(null);
ownershipChosen = $state(false);

// Priority chain: wizard-scoped โ†’ localStorage global context
get orgId(): string | null {
  if (this.wizardOrgId !== null) return this.wizardOrgId;
  return localStorage.getItem('currentOrgId');
}

get orgName(): string | null {
  if (this.wizardOrgName) return this.wizardOrgName;
  return localStorage.getItem('currentOrgName') || 'Your Organization';
}

setWizardOwnership(orgId: string | null, orgName: string | null) {
  this.wizardOrgId = orgId;
  this.wizardOrgName = orgName;
  this.ownershipChosen = true;
  // Persist to localStorage for cross-tab consistency
  if (orgName) localStorage.setItem('currentOrgName', orgName);
}

IMPORTANT

The wizard-scoped values take priority over the global localStorage context. This means selecting "Personal" in the OwnershipPicker overrides the global org context for the duration of the wizard session.

URL Seeding

The wizard supports ?orgId=xxx URL parameters for post-org-creation redirects:

/create-malet?orgId=abc123&tier=pro

This automatically seeds wizardOrgId from the URL and resolves the org name from localStorage cache.


OrgName Persistence

Organization names are cached in localStorage (not sessionStorage) for cross-tab persistence:

Component Action Storage
OrgSwitcher.svelte On successful switch localStorage.setItem('currentOrgName', name)
UserMenu.svelte On successful switch localStorage.setItem('currentOrgName', name)
createMaletStore On ownership selection localStorage.setItem('currentOrgName', name)
createMaletStore orgName getter localStorage.getItem('currentOrgName')

NOTE

Previous versions used sessionStorage which caused cache misses when opening new tabs. The migration to localStorage ensures consistent org name display across the platform.


Deck Dashboard: Ownership Badges

File: ngwenya-deck/src/routes/dashboard/+page.svelte

The Deck now renders ownership badges on each Malet card:

ownerType Badge Color
ORGANIZATION ๐Ÿข Org Pink (#d946a8)
USER (default) ๐Ÿ‘ค Personal Blue (accent)

The GET_MY_OWNED_MALETS query now includes ownerType and ownerId fields. The "Create New Malet" add-card dynamically shows "Create Malet for {orgName}" when in org context.


File Reference

Feature File Purpose
Bootstrap Handler apps/payments/src/subscription/subscription-tcp.controller.ts Creates free Starter subscription
Event Emission apps/organizations/src/organization/organization.resolver.ts Emits bootstrap_org_subscription
Tier Enforcement libs/common/src/config/tier-limits.ts Context-aware checkTierLimit()
OwnershipPicker src/lib/components/create-malet/OwnershipPicker.svelte Wizard ownership dropdown
Wizard Store src/stores/createMaletStore.svelte.ts Wizard-scoped ownership state
Wizard Page src/routes/create-malet/+page.svelte Picker integration + context badge
Review Step src/lib/components/create-malet/ReviewLaunch.svelte Org name in plan badge
Deck Dashboard ngwenya-deck/src/routes/dashboard/+page.svelte Ownership badges on cards
Malet Query src/lib/queries/malet.ts ownerType/ownerId fields