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
orgsStorewith 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 |
Related
- Subscription Checkout Identity & Idempotency โ How orgId flows through checkout, idempotency guards, and debug tracing
- Organization Context & Tier Access โ How the Org Context Switcher works,
canCreateOrganization()gate, andx-org-idheader injection - Subscription & Billing Architecture โ Backend tier enforcement via TCP microservice and
TIER_LIMITSconfig - Organization & Malet Management โ Frontend management UI: org dashboard, activity feed, billing
- Organizations Subgraph โ Internals of the organizations subgraph including membership, invitations, and audit
- Malet-First Ownership Architecture โ Payment-first provisioning ensuring Organizations and Malets are created atomically after checkout (replaces bootstrap flow)