mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
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:
@@ -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> (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
|
||||
>;
|
||||
Reference in New Issue
Block a user