## Summary - Adds a new admin action to move a subscription (and Stripe customerId) from one organisation to another owned by the same user - The target organisation must be on the free plan (no active subscription) — enforces paid → free only - The source organisation's claim is reset to the free plan after the move ## How it works A "Move Subscription" option appears in the actions dropdown of the organisations table (on the admin user detail page) for any org with an active or past-due subscription. Clicking it opens a dialog where the admin selects a target org from a filtered list of eligible (free-plan) orgs owned by the same user. The backend performs the swap atomically in a single Prisma transaction: 1. Deletes any stale inactive subscription on the target org 2. Moves the `customerId` from source to target org 3. Reassigns the `Subscription` record to the target org 4. Copies claim entitlements to the target org 5. Resets the source org's claim to FREE No Stripe API calls are made — the Stripe subscription and customer remain unchanged; only the DB-level org association is updated. ## Files changed - **New:** `packages/trpc/server/admin-router/swap-organisation-subscription.types.ts` — Zod schemas - **New:** `packages/trpc/server/admin-router/swap-organisation-subscription.ts` — Admin mutation - **New:** `apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx` — Dialog component - **Modified:** `packages/trpc/server/admin-router/router.ts` — Register route - **Modified:** `apps/remix/app/components/tables/admin-organisations-table.tsx` — Add action menu item
8.2 KiB
date, title
| date | title |
|---|---|
| 2026-03-04 | Swap Subscription Between Orgs |
Overview
Add the ability for admins to move a subscription (and its associated Stripe customerId) from one organisation to another, when viewing a user in the admin panel. The target org must be owned by the same user and must be on the free plan (no existing active subscription).
Context & Data Model
Organisationhas a 1:1 optionalSubscriptionand acustomerId(Stripe customer ID,@unique)Organisationhas a 1:1OrganisationClaimthat tracks entitlements (team count, member count, feature flags)Subscriptionalso stores a redundantcustomerIdand hasorganisationId(@unique)- When a subscription is removed from an org, its
OrganisationClaimshould be reset to the FREE claim - Relationship chain:
User --owns--> Organisation --has--> Subscription + OrganisationClaim
Constraints
- paid → free only: The target org must NOT have an active subscription (status ACTIVE or PAST_DUE). It must be on the free plan.
- same owner: Both source and target orgs must be owned by the same user (the user being viewed).
- The
customerIdmust move with the subscription to the target org (cleared from source, set on target). - The Stripe subscription object itself is NOT modified — only the DB-level mapping changes. The Stripe customer stays the same; we just reassociate it to a different org.
Implementation Plan
1. Backend: TRPC Admin Route
Files to create:
packages/trpc/server/admin-router/swap-organisation-subscription.types.tspackages/trpc/server/admin-router/swap-organisation-subscription.ts
Request schema (ZSwapOrganisationSubscriptionRequestSchema):
z.object({
sourceOrganisationId: z.string(),
targetOrganisationId: z.string(),
});
Response schema: z.void()
Route logic (in a single prisma.$transaction):
- Fetch source org with
subscription+organisationClaim - Fetch target org with
subscription+organisationClaim - Validate:
- Source org has an active subscription (status
ACTIVEorPAST_DUE) - Target org does NOT have an active subscription (no subscription record, or status
INACTIVE) - Both orgs have the same
ownerUserId
- Source org has an active subscription (status
- In a transaction:
a. Clear
customerIdon source org (set tonull) b. SetcustomerIdon target org to the source'scustomerIdc. Move theSubscriptionrecord: updateorganisationIdto target org ID d. Copy the source org'sOrganisationClaimentitlements to the target org'sOrganisationClaim(originalSubscriptionClaimId,teamCount,memberCount,envelopeItemCount,flags) e. Reset the source org'sOrganisationClaimto the FREE claim (usingcreateOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE])pattern fromon-subscription-deleted.ts)
Note on ordering: Because Organisation.customerId is @unique, we must clear the source first, then set the target — or do both in a transaction that handles the constraint. Prisma transactions handle this correctly as they apply all writes atomically.
Register the route:
- Import in
packages/trpc/server/admin-router/router.ts - Add under
organisationasswapSubscription - Call path:
trpc.admin.organisation.swapSubscription
2. Frontend: Dialog Component
File to create:
apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
Props:
type AdminSwapSubscriptionDialogProps = {
trigger?: React.ReactNode;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
Dialog behavior:
- Opens when the trigger is clicked (from the organisations table actions dropdown)
- Fetches the user's owned orgs via
trpc.admin.organisation.find.useQuery({ ownerUserId: userId }) - Filters to only show orgs that are on the free plan (no active subscription) and excludes the source org
- Displays a select dropdown to pick the target org
- Shows a warning alert: "This will move the subscription from {source} to {target}. The source organisation will be reset to the free plan."
- On submit, calls
trpc.admin.organisation.swapSubscription.useMutation() - On success, shows a toast, invalidates relevant queries, and closes the dialog
UI layout (following existing dialog patterns like admin-organisation-create-dialog.tsx):
DialogHeaderwith title "Move Subscription" and description- A select dropdown listing eligible target orgs (name + url)
- An
Alertexplaining what will happen DialogFooterwith Cancel + "Move Subscription" buttons (submit button usesloadingprop)
3. Frontend: Wire into the Organisations Table
File to modify:
apps/remix/app/components/tables/admin-organisations-table.tsx
Changes:
- Import the
AdminSwapSubscriptionDialog - Add a new prop
ownerUserId?: numbertoAdminOrganisationsTableOptions(needed so the dialog can query other owned orgs) - Add a new dropdown menu item in the actions column: "Move Subscription" with
ArrowRightLeftIconfrom lucide - Only render this item when the org row has an active subscription (
subscription?.status === 'ACTIVE' || subscription?.status === 'PAST_DUE') - The menu item renders inside
AdminSwapSubscriptionDialogwithtriggerprop as the menu item
4. Frontend: Pass userId from User Detail Page
File to modify:
apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
Changes:
- Pass
ownerUserId={user.id}to<AdminOrganisationsTable>so it can forward this to the swap dialog
File Change Summary
| File | Action | Description |
|---|---|---|
packages/trpc/server/admin-router/swap-organisation-subscription.types.ts |
Create | Request/response Zod schemas + TS types |
packages/trpc/server/admin-router/swap-organisation-subscription.ts |
Create | Admin mutation with prisma transaction |
packages/trpc/server/admin-router/router.ts |
Modify | Register route at organisation.swapSubscription |
apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx |
Create | Dialog for selecting target org |
apps/remix/app/components/tables/admin-organisations-table.tsx |
Modify | Add "Move Subscription" action + accept ownerUserId prop |
apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx |
Modify | Pass ownerUserId={user.id} to table |
Edge Cases & Considerations
-
Stripe customer stays the same: The Stripe subscription is tied to a Stripe customer. We move the
customerIdto the target org, so webhook lookups (findFirst where customerId) will correctly resolve to the target org going forward. -
@uniqueconstraint onOrganisation.customerId: Must clear source before setting target within the transaction. Prisma interactive transactions handle this correctly. -
@uniqueconstraint onSubscription.organisationId: Since the target org should not have a subscription record, updating the existing subscription'sorganisationIdto the target should work. If the target has an INACTIVE subscription record, we need to delete it first. -
Target org has INACTIVE subscription: The target org might have a stale INACTIVE subscription from a previous cancellation. In this case, delete the target's old subscription record before moving the source's subscription over.
-
Seat-based plans: If the subscription is seat-based, the Stripe quantity may not match the target org's member count. Consider calling
syncMemberCountWithStripeSeatPlanafter the swap as a post-transaction step. -
OrganisationClaim transfer: Copy
originalSubscriptionClaimId,teamCount,memberCount,envelopeItemCount, andflagsfrom source claim to target claim. Reset source claim to FREE. -
No Stripe API calls needed: This is purely a DB-level reassociation. The Stripe subscription, customer, and payment method all remain unchanged.