feat: add admin ability to move subscription between orgs (#2558)

## 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
This commit is contained in:
Lucas Smith
2026-03-04 22:34:53 +11:00
committed by GitHub
parent f1323679aa
commit 7d3a56a006
6 changed files with 547 additions and 2 deletions
@@ -0,0 +1,151 @@
---
date: 2026-03-04
title: 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
- `Organisation` has a 1:1 optional `Subscription` and a `customerId` (Stripe customer ID, `@unique`)
- `Organisation` has a 1:1 `OrganisationClaim` that tracks entitlements (team count, member count, feature flags)
- `Subscription` also stores a redundant `customerId` and has `organisationId` (`@unique`)
- When a subscription is removed from an org, its `OrganisationClaim` should 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 `customerId` must 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.ts`
- `packages/trpc/server/admin-router/swap-organisation-subscription.ts`
**Request schema (`ZSwapOrganisationSubscriptionRequestSchema`):**
```ts
z.object({
sourceOrganisationId: z.string(),
targetOrganisationId: z.string(),
});
```
**Response schema:** `z.void()`
**Route logic (in a single `prisma.$transaction`):**
1. Fetch source org with `subscription` + `organisationClaim`
2. Fetch target org with `subscription` + `organisationClaim`
3. Validate:
- Source org has an active subscription (status `ACTIVE` or `PAST_DUE`)
- Target org does NOT have an active subscription (no subscription record, or status `INACTIVE`)
- Both orgs have the same `ownerUserId`
4. In a transaction:
a. Clear `customerId` on source org (set to `null`)
b. Set `customerId` on target org to the source's `customerId`
c. Move the `Subscription` record: update `organisationId` to target org ID
d. Copy the source org's `OrganisationClaim` entitlements to the target org's `OrganisationClaim` (`originalSubscriptionClaimId`, `teamCount`, `memberCount`, `envelopeItemCount`, `flags`)
e. Reset the source org's `OrganisationClaim` to the FREE claim (using `createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE])` pattern from `on-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 `organisation` as `swapSubscription`
- Call path: `trpc.admin.organisation.swapSubscription`
### 2. Frontend: Dialog Component
**File to create:**
- `apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx`
**Props:**
```ts
type AdminSwapSubscriptionDialogProps = {
trigger?: React.ReactNode;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
```
**Dialog behavior:**
1. Opens when the trigger is clicked (from the organisations table actions dropdown)
2. Fetches the user's owned orgs via `trpc.admin.organisation.find.useQuery({ ownerUserId: userId })`
3. Filters to only show orgs that are on the free plan (no active subscription) and excludes the source org
4. Displays a select dropdown to pick the target org
5. Shows a warning alert: "This will move the subscription from {source} to {target}. The source organisation will be reset to the free plan."
6. On submit, calls `trpc.admin.organisation.swapSubscription.useMutation()`
7. On success, shows a toast, invalidates relevant queries, and closes the dialog
**UI layout (following existing dialog patterns like `admin-organisation-create-dialog.tsx`):**
- `DialogHeader` with title "Move Subscription" and description
- A select dropdown listing eligible target orgs (name + url)
- An `Alert` explaining what will happen
- `DialogFooter` with Cancel + "Move Subscription" buttons (submit button uses `loading` prop)
### 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?: number` to `AdminOrganisationsTableOptions` (needed so the dialog can query other owned orgs)
- Add a new dropdown menu item in the actions column: "Move Subscription" with `ArrowRightLeftIcon` from 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 `AdminSwapSubscriptionDialog` with `trigger` prop 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
1. **Stripe customer stays the same**: The Stripe subscription is tied to a Stripe customer. We move the `customerId` to the target org, so webhook lookups (`findFirst where customerId`) will correctly resolve to the target org going forward.
2. **`@unique` constraint on `Organisation.customerId`**: Must clear source before setting target within the transaction. Prisma interactive transactions handle this correctly.
3. **`@unique` constraint on `Subscription.organisationId`**: Since the target org should not have a subscription record, updating the existing subscription's `organisationId` to the target should work. If the target has an INACTIVE subscription record, we need to delete it first.
4. **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.
5. **Seat-based plans**: If the subscription is seat-based, the Stripe quantity may not match the target org's member count. Consider calling `syncMemberCountWithStripeSeatPlan` after the swap as a post-transaction step.
6. **OrganisationClaim transfer**: Copy `originalSubscriptionClaimId`, `teamCount`, `memberCount`, `envelopeItemCount`, and `flags` from source claim to target claim. Reset source claim to FREE.
7. **No Stripe API calls needed**: This is purely a DB-level reassociation. The Stripe subscription, customer, and payment method all remain unchanged.
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminSwapSubscriptionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
};
export const AdminSwapSubscriptionDialog = ({
open,
onOpenChange,
sourceOrganisationId,
sourceOrganisationName,
userId,
}: AdminSwapSubscriptionDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [selectedOrgId, setSelectedOrgId] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: orgsData } = trpc.admin.organisation.find.useQuery(
{
ownerUserId: userId,
perPage: 100,
},
{
enabled: open,
},
);
const trpcUtils = trpc.useUtils();
const eligibleOrgs = useMemo(() => {
if (!orgsData?.data) {
return [];
}
return orgsData.data.filter((org) => {
if (org.id === sourceOrganisationId) {
return false;
}
const hasActiveSubscription =
org.subscription &&
(org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
return !hasActiveSubscription;
});
}, [orgsData, sourceOrganisationId]);
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
return;
}
setIsSubmitting(true);
try {
await swapSubscription({
sourceOrganisationId,
targetOrganisationId: selectedOrgId,
});
await trpcUtils.admin.organisation.find.invalidate();
await trpcUtils.admin.organisation.get.invalidate();
onOpenChange(false);
toast({
title: t`Success`,
description: t`Subscription moved successfully`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Error`,
description: t`Failed to move subscription. Please try again.`,
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (!open) {
setSelectedOrgId('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && onOpenChange(value)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Move Subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Move the subscription from "{sourceOrganisationName}" to another organisation owned by
this user.
</Trans>
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">
<Trans>Target Organisation</Trans>
</label>
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
<SelectTrigger>
<SelectValue placeholder={t`Select an organisation`} />
</SelectTrigger>
<SelectContent>
{eligibleOrgs.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name} ({org.url})
</SelectItem>
))}
</SelectContent>
</Select>
{eligibleOrgs.length === 0 && orgsData && (
<p className="text-sm text-muted-foreground">
<Trans>No eligible organisations found. The target must be on the free plan.</Trans>
</p>
)}
</div>
{selectedOrg && (
<Alert variant="warning">
<AlertDescription className="mt-0">
<Trans>
This will move the subscription from "{sourceOrganisationName}" to "
{selectedOrg.name}". The source organisation will be reset to the free plan.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={!selectedOrgId}
loading={isSubmitting}
>
<Trans>Move Subscription</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
ArrowRightLeftIcon,
CreditCardIcon,
ExternalLinkIcon,
MoreHorizontalIcon,
@@ -29,6 +30,8 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { AdminSwapSubscriptionDialog } from '~/components/dialogs/admin-swap-subscription-dialog';
type AdminOrganisationsTableOptions = {
ownerUserId?: number;
memberUserId?: number;
@@ -44,6 +47,12 @@ export const AdminOrganisationsTable = ({
}: AdminOrganisationsTableOptions) => {
const { t, i18n } = useLingui();
const [swapSource, setSwapSource] = useState<{
id: string;
name: string;
ownerId: number;
} | null>(null);
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
@@ -143,7 +152,7 @@ export const AdminOrganisationsTable = ({
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
<MoreHorizontalIcon className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@@ -172,6 +181,23 @@ export const AdminOrganisationsTable = ({
{!row.original.customerId && <span>&nbsp;(N/A)</span>}
</Link>
</DropdownMenuItem>
{row.original.subscription &&
(row.original.subscription.status === 'ACTIVE' ||
row.original.subscription.status === 'PAST_DUE') && (
<DropdownMenuItem
onClick={() =>
setSwapSource({
id: row.original.id,
name: row.original.name,
ownerId: row.original.owner.id,
})
}
>
<ArrowRightLeftIcon className="mr-2 h-4 w-4" />
<Trans>Move Subscription</Trans>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
),
@@ -227,6 +253,20 @@ export const AdminOrganisationsTable = ({
) : null
}
</DataTable>
{swapSource && (
<AdminSwapSubscriptionDialog
sourceOrganisationId={swapSource.id}
sourceOrganisationName={swapSource.name}
userId={swapSource.ownerId}
open={true}
onOpenChange={(open) => {
if (!open) {
setSwapSource(null);
}
}}
/>
)}
</div>
);
};
@@ -22,6 +22,7 @@ import { reregisterEmailDomainRoute } from './reregister-email-domain';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { resyncLicenseRoute } from './resync-license';
import { swapOrganisationSubscriptionRoute } from './swap-organisation-subscription';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
@@ -35,6 +36,7 @@ export const adminRouter = router({
get: getAdminOrganisationRoute,
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
swapSubscription: swapOrganisationSubscriptionRoute,
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
@@ -0,0 +1,140 @@
import { SubscriptionStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZSwapOrganisationSubscriptionRequestSchema,
ZSwapOrganisationSubscriptionResponseSchema,
} from './swap-organisation-subscription.types';
export const swapOrganisationSubscriptionRoute = adminProcedure
.input(ZSwapOrganisationSubscriptionRequestSchema)
.output(ZSwapOrganisationSubscriptionResponseSchema)
.mutation(async ({ input, ctx }) => {
const { sourceOrganisationId, targetOrganisationId } = input;
ctx.logger.info({
input: {
sourceOrganisationId,
targetOrganisationId,
},
});
if (sourceOrganisationId === targetOrganisationId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Source and target organisations must be different',
});
}
const sourceOrg = await prisma.organisation.findUnique({
where: { id: sourceOrganisationId },
include: {
subscription: true,
organisationClaim: true,
},
});
if (!sourceOrg) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Source organisation not found',
});
}
if (
!sourceOrg.subscription ||
(sourceOrg.subscription.status !== SubscriptionStatus.ACTIVE &&
sourceOrg.subscription.status !== SubscriptionStatus.PAST_DUE)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Source organisation does not have an active subscription',
});
}
const targetOrg = await prisma.organisation.findUnique({
where: { id: targetOrganisationId },
include: {
subscription: true,
organisationClaim: true,
},
});
if (!targetOrg) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Target organisation not found',
});
}
if (sourceOrg.ownerUserId !== targetOrg.ownerUserId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Both organisations must be owned by the same user',
});
}
if (
targetOrg.subscription &&
(targetOrg.subscription.status === SubscriptionStatus.ACTIVE ||
targetOrg.subscription.status === SubscriptionStatus.PAST_DUE)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Target organisation already has an active subscription',
});
}
const customerId = sourceOrg.customerId ?? sourceOrg.subscription.customerId;
await prisma.$transaction(async (tx) => {
// Delete stale INACTIVE subscription on target if present.
if (targetOrg.subscription) {
await tx.subscription.delete({
where: { id: targetOrg.subscription.id },
});
}
// Clear customerId on source org to avoid unique constraint violation.
await tx.organisation.update({
where: { id: sourceOrganisationId },
data: { customerId: null },
});
// Set customerId on target org.
await tx.organisation.update({
where: { id: targetOrganisationId },
data: { customerId },
});
// Move the subscription record to the target org.
await tx.subscription.update({
where: { id: sourceOrg.subscription!.id },
data: { organisationId: targetOrganisationId },
});
// Copy source org's claim entitlements to target org's claim.
if (sourceOrg.organisationClaim && targetOrg.organisationClaim) {
await tx.organisationClaim.update({
where: { id: targetOrg.organisationClaim.id },
data: {
originalSubscriptionClaimId: sourceOrg.organisationClaim.originalSubscriptionClaimId,
teamCount: sourceOrg.organisationClaim.teamCount,
memberCount: sourceOrg.organisationClaim.memberCount,
envelopeItemCount: sourceOrg.organisationClaim.envelopeItemCount,
flags: sourceOrg.organisationClaim.flags,
},
});
}
// Reset source org's claim to FREE.
if (sourceOrg.organisationClaim) {
await tx.organisationClaim.update({
where: { id: sourceOrg.organisationClaim.id },
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
},
});
}
});
});
@@ -0,0 +1,15 @@
import { z } from 'zod';
export const ZSwapOrganisationSubscriptionRequestSchema = z.object({
sourceOrganisationId: z.string(),
targetOrganisationId: z.string(),
});
export const ZSwapOrganisationSubscriptionResponseSchema = z.void();
export type TSwapOrganisationSubscriptionRequest = z.infer<
typeof ZSwapOrganisationSubscriptionRequestSchema
>;
export type TSwapOrganisationSubscriptionResponse = z.infer<
typeof ZSwapOrganisationSubscriptionResponseSchema
>;