From 7d3a56a0060cc205b760151298b6616f8c56622e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 4 Mar 2026 22:34:53 +1100 Subject: [PATCH] feat: add admin ability to move subscription between orgs (#2558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ...zel-beam-swap-subscription-between-orgs.md | 151 ++++++++++++++ .../admin-swap-subscription-dialog.tsx | 197 ++++++++++++++++++ .../tables/admin-organisations-table.tsx | 44 +++- packages/trpc/server/admin-router/router.ts | 2 + .../swap-organisation-subscription.ts | 140 +++++++++++++ .../swap-organisation-subscription.types.ts | 15 ++ 6 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 .agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md create mode 100644 apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx create mode 100644 packages/trpc/server/admin-router/swap-organisation-subscription.ts create mode 100644 packages/trpc/server/admin-router/swap-organisation-subscription.types.ts diff --git a/.agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md b/.agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md new file mode 100644 index 000000000..676c16808 --- /dev/null +++ b/.agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md @@ -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; +``` + +**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 `` 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. diff --git a/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx new file mode 100644 index 000000000..a912fd28d --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx @@ -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(''); + 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 ( + !isSubmitting && onOpenChange(value)}> + + + + Move Subscription + + + + + Move the subscription from "{sourceOrganisationName}" to another organisation owned by + this user. + + + + +
+
+ + + + + {eligibleOrgs.length === 0 && orgsData && ( +

+ No eligible organisations found. The target must be on the free plan. +

+ )} +
+ + {selectedOrg && ( + + + + This will move the subscription from "{sourceOrganisationName}" to " + {selectedOrg.name}". The source organisation will be reset to the free plan. + + + + )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/tables/admin-organisations-table.tsx b/apps/remix/app/components/tables/admin-organisations-table.tsx index eab94c199..1da49067d 100644 --- a/apps/remix/app/components/tables/admin-organisations-table.tsx +++ b/apps/remix/app/components/tables/admin-organisations-table.tsx @@ -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 }) => ( - + @@ -172,6 +181,23 @@ export const AdminOrganisationsTable = ({ {!row.original.customerId &&  (N/A)} + + {row.original.subscription && + (row.original.subscription.status === 'ACTIVE' || + row.original.subscription.status === 'PAST_DUE') && ( + + setSwapSource({ + id: row.original.id, + name: row.original.name, + ownerId: row.original.owner.id, + }) + } + > + + Move Subscription + + )} ), @@ -227,6 +253,20 @@ export const AdminOrganisationsTable = ({ ) : null } + + {swapSource && ( + { + if (!open) { + setSwapSource(null); + } + }} + /> + )} ); }; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index dd472705f..47fa286e0 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -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, diff --git a/packages/trpc/server/admin-router/swap-organisation-subscription.ts b/packages/trpc/server/admin-router/swap-organisation-subscription.ts new file mode 100644 index 000000000..d7b100047 --- /dev/null +++ b/packages/trpc/server/admin-router/swap-organisation-subscription.ts @@ -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]), + }, + }); + } + }); + }); diff --git a/packages/trpc/server/admin-router/swap-organisation-subscription.types.ts b/packages/trpc/server/admin-router/swap-organisation-subscription.types.ts new file mode 100644 index 000000000..5a82f9aa0 --- /dev/null +++ b/packages/trpc/server/admin-router/swap-organisation-subscription.types.ts @@ -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 +>;