Developer Docs

The Subscription & Billing architecture controls the financial lifecycle of Malet Owners and Organizations on the Mallnline platform. It ensures that account limits (such as the number of active Malets or team members) are rigorously enforced server-side, preventing exploitation while maintaining a seamless user experience.

Single Sources of Truth

We maintain strict parity between the frontend display and the backend enforcement logic using two single sources of truth for tier limits:

  1. Frontend: ngwenya-front/src/lib/config/plans.ts (Drives the UI on pricing pages and checkout funnels)
  2. Backend: ngwenya-federation/libs/common/src/config/tier-limits.ts (Drives the enforcement guards across the supergraph)

When adjusting the metrics for the Starter, Pro, or Enterprise plans, both files must be updated simultaneously.

// Example: libs/common/src/config/tier-limits.ts
export const TIER_LIMITS: Record<TierSlug, TierLimits> = {
	starter: { maxMalets: 1, maxMembers: 3 },
	pro: { maxMalets: 5, maxMembers: 10 },
	enterprise: { maxMalets: Infinity, maxMembers: Infinity }
};

The Payments Subgraph

The payments subgraph is the canonical owner of the Subscription entity. Rather than forcing every other subgraph to hit the database to query billing status, the payments service exposes a lightweight TCP Microservice Transport on port 3018.

It runs a SubscriptionTcpController that handles the get_active_tier message pattern.

@MessagePattern('get_active_tier')
async getActiveTier(@Payload() data: { userId?: string, orgId?: string }) {
  // Finds the active subscription in the DB
  // Returns { tier: 'pro', limits: { maxMalets: 5, ... } }
}

Enforcement Guards

Other subgraphs act as TCP Clients to the payments service whenever an action occurs that is gated by a billing tier.

Malet Creation Limits

When a user calls createOneMalet in the malets subgraph, the resolver:

  1. Counts the existing Malets owned by that user/org.
  2. Queries get_active_tier from payments.
  3. Evaluates currentCount >= limits.maxMalets.
  4. Throws a ForbiddenException instructing the user to upgrade to Pro if the limit is exceeded.

Organization Member Limits

When an admin calls inviteMember in the organizations subgraph, the resolver:

  1. Counts the existing users in the Organization.
  2. Queries get_active_tier from payments.
  3. Evaluates currentCount >= limits.maxMembers.
  4. Throws a ForbiddenException instructing the admin to upgrade if the limit is exceeded.

Fail-Open / Graceful Degradation

If the payments TCP service is unreachable (e.g., during a deployment rollout or unexpected downtime), the TCP clients are configured with a strict 3-second timeout.

Instead of crashing the request, the clients employ a Fail-Open (Downgrade) strategy. They will automatically assume the user is on the Starter tier. This ensures that free-tier users can always access their basic limits, while temporarily preventing systemic outages from blocking core functionalityโ€”though it may temporarily block Pro users from exceeding Starter limits during the outage.


Enterprise Plan (Contact Sales)

The Enterprise tier has no self-serve pricing. Both the storefront (/owner/pricing) and The Deck (/orgs/upgrade) display "Custom" pricing for Enterprise and route the CTA to a contact form (/contact?subject=enterprise). The PLAN_TIERS definition in @ngwenya/queries/subscriptions and the plans.ts config in ngwenya-front both set contactSales: true with monthlyPrice: 0 / yearlyPrice: 0.

Enterprise provisioning is sales-assisted: the sales team creates the org with custom tier limits via the Tower admin dashboard. Future: Stripe seat-based subscriptions for per-member billing.

Organization Billing Security

All org-scoped billing endpoints in the payments subgraph are protected by a dual-guard chain:

  1. GqlAuthGuard โ€” verifies the request is authenticated
  2. OrgMembershipGuard โ€” verifies the user is a member of the target organization by querying the shared memberships MongoDB collection

This prevents cross-organization data leakage โ€” a user authenticated as OrgA cannot query billing data for OrgB. The guard is implemented as a reusable @UseGuards(GqlAuthGuard, OrgMembershipGuard) decorator stack applied to all 8 org-scoped endpoints.

See Payments Subgraph โ€” Organization Membership Security for full details.