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 |
Related
- Subscription & Billing Architecture โ Server-side tier enforcement via TCP microservice
- Org Subscription Bootstrap & Ownership Picker โ Auto-Starter provisioning on org creation
- Organization & Malet Management โ Billing dashboard and org settings
- Org Context & Tier Access โ Frontend org context switching and tier gates