Developer Docs

Subscription Checkout Identity & Idempotency

The subscription checkout flow connects three frontend routes and two backend services through an identity chain that must remain consistent. This document explains how the orgId identity is preserved from Organization creation through Stripe checkout to the billing dashboard, and how idempotency guards prevent duplicate charges.


The Identity Chain

When a Malet Owner creates an Organization with a Pro plan, the identity flows through this pipeline:

sequenceDiagram
    participant Create as Org Create Page
    participant Checkout as Checkout Page
    participant Pay as Payments Subgraph
    participant Billing as Billing Page

    Create->>Create: createOrganization
    Create->>Checkout: redirect orgId=org.id
    Note right of Create: Use org.id NOT org.slug
    Checkout->>Pay: createSubscriptionSetupIntent
    Checkout->>Pay: confirmSubscription
    Pay->>Pay: Store BillingSubscription
    Billing->>Pay: orgSubscription query
    Pay->>Billing: planName Pro, status ACTIVE

The `orgId` vs `slug` Distinction

Organizations have two identifiers:

Field Example Used For
org.id 17figinb7y5icnrrwuyl2 Database key, subscription binding, GraphQL queries
org.slug mallnline URL paths (/orgs/mallnline/billing), human display

The checkout URL must use org.id for the orgId parameter and org.slug for the return URL:

// โœ… Correct โ€” org.id for data binding, org.slug for URL path
const checkoutUrl = `/checkout/subscription?plan=${planId}&orgId=${org.id}&return=/orgs/${org.slug}/billing`;

// โŒ Wrong โ€” slug as orgId causes subscription lookup failure
const checkoutUrl = `/checkout/subscription?plan=${planId}&orgId=${org.slug}&return=/orgs/${org.slug}/billing`;

CAUTION

If orgId is set to the slug, confirmSubscription persists the subscription under the slug string. But the billing page resolves the slug to the ObjectID before querying โ€” so the subscription is stored but invisible. The billing page falls back to the bootstrapped free Starter record.


Idempotency Guards

The platform implements two layers of idempotency to prevent duplicate paid subscriptions:

Frontend Guard (Checkout Mount)

When the checkout page loads, it queries orgSubscription before initializing Stripe:

// src/routes/checkout/subscription/+page.svelte
const existingSub = existingSubRes?.orgSubscription;
if (existingSub && existingSub.priceAmount > 0 
    && ['ACTIVE', 'TRIALING'].includes(existingSub.status)) {
    successMessage = `Already subscribed to ${existingSub.planName}!`;
    isInitializing = false;
    return; // Skip Stripe initialization entirely
}

This prevents the Stripe payment form from rendering if the Organization already has an active paid subscription.

Backend Guard (confirmSubscription)

Even if the frontend guard is bypassed (e.g., direct API call), the confirmSubscription method checks for existing active paid subscriptions before creating a Stripe subscription:

// subscription.service.ts
if (input.orgId) {
  const existing = await this.subscriptionModel.findOne({
    orgId: input.orgId,
    status: { $in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING] },
    priceAmount: { $gt: 0 },
  }).exec();

  if (existing) {
    this.logger.warn(`Org already has active paid sub. Returning existing.`);
    return existing;  // No duplicate Stripe subscription created
  }
}

IMPORTANT

The backend guard returns the existing subscription record rather than throwing an error. This makes the mutation safely idempotent โ€” calling it twice with the same orgId produces the same result.


Subscription Sort Order

When querying an Organization's active subscription, getOrgSubscription sorts by priceAmount descending:

.sort({ priceAmount: -1 })

This ensures the most valuable plan (Pro at $2900 > Starter at $0) is always returned, even if multiple subscription records exist due to the bootstrapped Starter.


Post-Checkout Navigation

After successful subscription confirmation, the "Go to Dashboard" button uses the returnUrl parameter (which contains the slug-based path) instead of constructing a URL from the orgId:

<!-- โœ… Correct โ€” returnUrl contains /orgs/mallnline/billing -->
<a href={returnUrl}>Go to Dashboard</a>

<!-- โŒ Wrong โ€” orgId is an ObjectID, not a valid URL segment -->
<a href={`/orgs/${orgId}/manage`}>Go to Dashboard</a>

Debug Audit Trail

The checkout flow includes [DEBUG] console logs at each stage for development traceability:

Stage Log Key Data
Org Create [DEBUG] Org created orgId, orgSlug, checkoutUrl
Checkout Mount [DEBUG] Subscription checkout mounted planId, orgId, returnUrl
User Resolve [DEBUG] checkout user resolved userId, userEmail, orgId
Confirm [DEBUG] Confirming subscription customerId, orgId, planId
Confirmed [DEBUG] Subscription confirmed id, status, planName
Billing Load [DEBUG] Billing data fetched orgId, subscriptionPlan, status

NOTE

These logs are intended for the F&F beta launch period. They should be removed or downgraded to console.debug before production release.


File Reference

Feature File Purpose
Org Create Redirect src/routes/orgs/create/+page.svelte Constructs checkout URL with org.id
Checkout Page src/routes/checkout/subscription/+page.svelte Frontend idempotency guard + Stripe flow
Confirm Service apps/payments/src/subscription/subscription.service.ts Backend idempotency + sort order
Billing Page src/routes/orgs/[slug]/billing/+page.svelte Slugโ†’ObjectID resolution + subscription display
Register Page src/routes/owner/register/+page.svelte Org-aware pricing cards