fix: correctly orphan envelopes and stripe cancel on delete (#2967)

This commit is contained in:
David Nguyen
2026-06-09 15:52:14 +10:00
committed by GitHub
parent 3c0345f755
commit 8c11266747
7 changed files with 488 additions and 91 deletions
@@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
<Callout type="error">
Account deletion is permanent and irreversible. All documents, signatures, templates, and account
data will be permanently removed. Any active subscription will be cancelled.
Account deletion is permanent and irreversible. Your account, signatures, and personal data will be
permanently removed, and any active subscription will be cancelled. How your organisations and
documents are handled is explained below.
</Callout>
## Before Deleting
- Download any documents you need to keep
- Cancel any active subscriptions
- Disable two-factor authentication (required before deletion)
## Delete Your Account
@@ -36,6 +36,31 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
If you have two-factor authentication enabled, you must disable it before deleting your account.
</Callout>
## What Happens to Your Organisations
When you delete your account, the organisations you **own** are permanently deleted along with all of
their teams. If an owned organisation has an active subscription, it is scheduled for cancellation at
the end of the current billing period.
Organisations that you are only a **member** of are not deleted. You are simply removed from them, and
the organisation continues to operate as normal.
## What Happens to Your Documents
The way your documents and templates are handled depends on whether you owned the organisation they
belong to:
- **Organisations you owned** — Completed and in-progress documents are retained in an anonymized form
(reassigned to an internal system account) so the other parties keep their records. Draft documents
and templates are permanently removed.
- **Organisations you were a member of** — Your documents and templates are transferred to the
organisation owner, so they remain accessible to the organisation after you leave.
<Callout type="warn">
Documents that are retained in anonymized form are no longer associated with your account and cannot
be recovered or accessed by you after deletion. Download anything you need to keep beforehand.
</Callout>
---
## See Also
@@ -1,23 +1,395 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
test('[USER] delete account', async ({ page }) => {
const { user } = await seedUser();
test.describe.configure({ mode: 'parallel' });
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* The deleted-account service account is where orphaned DOCUMENT envelopes land
* when the team/org they belong to is torn down. It is created by a migration so
* it always exists in the test database.
*/
const getDeletedServiceAccount = async () => {
const deletedAccount = await prisma.user.findFirstOrThrow({
where: { email: { startsWith: 'deleted-account@' } },
select: {
id: true,
ownedOrganisations: { select: { teams: { select: { id: true } } } },
},
});
return {
id: deletedAccount.id,
teamId: deletedAccount.ownedOrganisations[0].teams[0].id,
};
};
/**
* Drives the account deletion through the settings UI, exactly as a user would.
* Returns once the app has redirected to the sign-in page (deletion is performed
* synchronously by the `profile.deleteAccount` mutation before the redirect).
*/
const deleteAccountViaUi = async (page: Page, email: string) => {
await apiSignin({ page, email, redirectPath: '/settings' });
await page.getByRole('button', { name: 'Delete Account' }).click();
await page.getByLabel('Confirm Email').fill(user.email);
await page.getByLabel('Confirm Email').fill(email);
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
};
// Verify that the user no longer exists in the database
const seedDocumentWithStatus = async (sender: User, teamId: number, key: string, status: DocumentStatus) => {
const document = await seedBlankDocument(sender, teamId, { key });
if (status !== DocumentStatus.DRAFT) {
await prisma.envelope.update({
where: { id: document.id },
data: { status },
});
}
return document;
};
const waitForOrganisationToBeGone = async (organisationId: string) => {
await expect
.poll(
async () => {
const org = await prisma.organisation.findUnique({
where: { id: organisationId },
select: { id: true },
});
return org === null;
},
{
message: `Organisation ${organisationId} was not removed after account deletion`,
timeout: 15_000,
intervals: [250, 500, 1000],
},
)
.toBe(true);
};
// ─── Happy path: the basic flow still works ──────────────────────────────────
test('[USER] delete account', async ({ page }) => {
const { user } = await seedUser();
await deleteAccountViaUi(page, user.email);
// Verify that the user no longer exists in the database.
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();
});
// ─── Owned organisation: documents orphaned to the service account ───────────
test('[USER][DELETE_ACCOUNT]: owned org docs are orphaned to service account, drafts and templates removed', async ({
page,
}) => {
const { user, organisation, team } = await seedUser();
// Inflight/completed DOCUMENT envelopes that must survive as orphans.
const completed = await seedDocumentWithStatus(user, team.id, 'owned-completed', DocumentStatus.COMPLETED);
const pending = await seedDocumentWithStatus(user, team.id, 'owned-pending', DocumentStatus.PENDING);
const rejected = await seedDocumentWithStatus(user, team.id, 'owned-rejected', DocumentStatus.REJECTED);
// A draft DOCUMENT — orphan only re-parents PENDING/REJECTED/COMPLETED, so it is hard-deleted.
const draft = await seedDocumentWithStatus(user, team.id, 'owned-draft', DocumentStatus.DRAFT);
// A TEMPLATE — orphan only re-parents DOCUMENT envelopes, so it is hard-deleted.
const template = await seedBlankDocument(user, team.id, { key: 'owned-template' });
await prisma.envelope.update({
where: { id: template.id },
data: { type: EnvelopeType.TEMPLATE },
});
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(5);
await deleteAccountViaUi(page, user.email);
await waitForOrganisationToBeGone(organisation.id);
const service = await getDeletedServiceAccount();
// Completed/pending/rejected: re-parented to the service account + soft-deleted.
for (const original of [completed, pending, rejected]) {
const after = await prisma.envelope.findUnique({
where: { id: original.id },
select: { id: true, teamId: true, userId: true, deletedAt: true },
});
expect(after, `envelope ${original.id} should survive as an orphan`).not.toBeNull();
expect(after?.teamId).toBe(service.teamId);
expect(after?.userId).toBe(service.id);
expect(after?.deletedAt).not.toBeNull();
}
// Draft + template are hard-deleted.
expect(await prisma.envelope.findUnique({ where: { id: draft.id } })).toBeNull();
expect(await prisma.envelope.findUnique({ where: { id: template.id } })).toBeNull();
// The owned org, its team, and the user are gone. Nothing references the old team.
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull();
expect(await prisma.user.findUnique({ where: { id: user.id } })).toBeNull();
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(0);
});
// ─── Member of another org: documents transferred to the OWNER, not deleted ──
test('[USER][DELETE_ACCOUNT]: docs in orgs the user is a member of are transferred to the org owner', async ({
page,
}) => {
// Another org, owned by someone else, that the deleted user is merely a member of.
const { user: ownerB, organisation: orgB, team: teamB } = await seedUser();
// The account being deleted. They own their own (personal) org too.
const { user: userA, organisation: orgA, team: teamA } = await seedUser();
await seedOrganisationMembers({
organisationId: orgB.id,
members: [{ email: userA.email, name: userA.name ?? 'User A', organisationRole: 'MEMBER' }],
});
// userA authors envelopes inside orgB's team (both completed and draft).
const memberCompleted = await seedDocumentWithStatus(userA, teamB.id, 'member-completed', DocumentStatus.COMPLETED);
const memberDraft = await seedDocumentWithStatus(userA, teamB.id, 'member-draft', DocumentStatus.DRAFT);
// userA also has a completed doc in their OWN org (should orphan to service account).
const ownedCompleted = await seedDocumentWithStatus(userA, teamA.id, 'owned-completed', DocumentStatus.COMPLETED);
await deleteAccountViaUi(page, userA.email);
await waitForOrganisationToBeGone(orgA.id);
const service = await getDeletedServiceAccount();
// Member-org envelopes — regardless of status — are reassigned to orgB's owner,
// stay in orgB's team, and are NOT soft-deleted.
for (const original of [memberCompleted, memberDraft]) {
const after = await prisma.envelope.findUnique({
where: { id: original.id },
select: { id: true, teamId: true, userId: true, deletedAt: true },
});
expect(after, `member envelope ${original.id} should be transferred, not deleted`).not.toBeNull();
expect(after?.teamId).toBe(teamB.id);
expect(after?.userId).toBe(ownerB.id);
expect(after?.deletedAt).toBeNull();
}
// The other org and its owner survive — only the deleted user's own org is removed.
expect(await prisma.organisation.findUnique({ where: { id: orgB.id } })).not.toBeNull();
expect(await prisma.user.findUnique({ where: { id: ownerB.id } })).not.toBeNull();
// The deleted user's own completed doc was orphaned to the service account.
const ownedAfter = await prisma.envelope.findUnique({
where: { id: ownedCompleted.id },
select: { teamId: true, userId: true, deletedAt: true },
});
expect(ownedAfter, 'owned-org envelope should survive as an orphan').not.toBeNull();
expect(ownedAfter?.teamId).toBe(service.teamId);
expect(ownedAfter?.userId).toBe(service.id);
expect(ownedAfter?.deletedAt).not.toBeNull();
// userA is gone.
expect(await prisma.user.findUnique({ where: { id: userA.id } })).toBeNull();
});
// ─── Owned org with members: org torn down, members survive, their docs orphaned ─
test('[USER][DELETE_ACCOUNT]: deleting the owner removes the org but keeps members and orphans their docs', async ({
page,
}) => {
const { user: owner, organisation, team } = await seedUser();
const [member] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [{ organisationRole: 'MEMBER' }],
});
// A member (not the owner) authored a completed doc inside the owned org's team.
// The orphan logic filters by teamId only, so this must be orphaned too.
const memberCompleted = await seedDocumentWithStatus(member, team.id, 'member-completed', DocumentStatus.COMPLETED);
await deleteAccountViaUi(page, owner.email);
await waitForOrganisationToBeGone(organisation.id);
const service = await getDeletedServiceAccount();
const after = await prisma.envelope.findUnique({
where: { id: memberCompleted.id },
select: { teamId: true, userId: true, deletedAt: true },
});
expect(after, 'member-authored envelope should survive as an orphan').not.toBeNull();
expect(after?.teamId).toBe(service.teamId);
expect(after?.userId).toBe(service.id);
expect(after?.deletedAt).not.toBeNull();
// The member user survives — only the org and its owner are removed.
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
});
// ─── Subscription cancellation is scheduled for owned orgs ───────────────────
test('[USER][DELETE_ACCOUNT]: a cancel-subscription job is enqueued for an owned org that has a subscription', async ({
page,
}) => {
const { user, organisation } = await seedUser();
const planId = `sub_e2e_${nanoid()}`;
await prisma.subscription.create({
data: {
status: SubscriptionStatus.ACTIVE,
planId,
priceId: `price_e2e_${nanoid()}`,
customerId: `cus_e2e_${nanoid()}`,
organisationId: organisation.id,
},
});
await deleteAccountViaUi(page, user.email);
await waitForOrganisationToBeGone(organisation.id);
// The deletion must schedule the Stripe subscription cancellation job with the
// captured planId (the Subscription row itself cascades away with the org).
await expect
.poll(
async () => {
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.cancel-organisation-subscription',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
if (!job) {
return null;
}
return (job.payload as { stripeSubscriptionId?: string }).stripeSubscriptionId ?? null;
},
{
message: 'cancel-organisation-subscription job was not enqueued',
timeout: 15_000,
intervals: [250, 500, 1000],
},
)
.toBe(planId);
// The local Subscription row cascades away with the organisation — which is
// exactly why the planId has to be captured into the job payload beforehand.
expect(await prisma.subscription.findUnique({ where: { planId } })).toBeNull();
});
// ─── Owned org account (SSO) rows are cleaned up, members survive ────────────
test('[USER][DELETE_ACCOUNT]: org-linked account rows are removed when an owned org is torn down', async ({ page }) => {
const { user: owner, organisation } = await seedUser();
const [member] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [{ organisationRole: 'MEMBER' }],
});
// Simulate a member who linked their login through the organisation's SSO.
// These rows are keyed by `provider = organisation.id` and have no foreign key
// to the organisation, so they must be deleted explicitly during teardown.
const orgAccount = await prisma.account.create({
data: {
userId: member.id,
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
providerAccountId: `oidc-${nanoid()}`,
},
});
await deleteAccountViaUi(page, owner.email);
await waitForOrganisationToBeGone(organisation.id);
// The org-linked account row is gone...
expect(await prisma.account.findUnique({ where: { id: orgAccount.id } })).toBeNull();
expect(
await prisma.account.count({
where: { type: ORGANISATION_USER_ACCOUNT_TYPE, provider: organisation.id },
}),
).toBe(0);
// ...but the member user it belonged to survives (only the org + owner are removed).
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
});
// ─── Sad path: no subscription means no cancel job is enqueued ────────────────
test('[USER][DELETE_ACCOUNT]: no cancel-subscription job is enqueued when the owned org has no subscription', async ({
page,
}) => {
const { user, organisation } = await seedUser();
await deleteAccountViaUi(page, user.email);
await waitForOrganisationToBeGone(organisation.id);
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'internal.cancel-organisation-subscription',
payload: { path: ['organisationId'], equals: organisation.id },
},
});
expect(job).toBeNull();
});
// ─── Sad path: a mismatched confirmation email leaves everything intact ───────
test('[USER][DELETE_ACCOUNT]: a wrong confirmation email keeps the account, org and documents intact', async ({
page,
}) => {
const { user, organisation, team } = await seedUser();
const completed = await seedDocumentWithStatus(user, team.id, 'kept-completed', DocumentStatus.COMPLETED);
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
await page.getByRole('button', { name: 'Delete Account' }).click();
await page.getByLabel('Confirm Email').fill('not-my-email@example.com');
// The confirm button stays disabled while the email does not match.
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).toBeDisabled();
// Nothing was deleted or orphaned.
expect(await prisma.user.findUnique({ where: { id: user.id } })).not.toBeNull();
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).not.toBeNull();
const docAfter = await prisma.envelope.findUnique({
where: { id: completed.id },
select: { teamId: true, userId: true, deletedAt: true },
});
expect(docAfter?.teamId).toBe(team.id);
expect(docAfter?.userId).toBe(user.id);
expect(docAfter?.deletedAt).toBeNull();
});
@@ -1,10 +1,8 @@
import { prisma } from '@documenso/prisma';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { orphanEnvelopes } from '../../../server-only/envelope/orphan-envelopes';
import { deleteOrganisation } from '../../../server-only/organisation/delete-organisation';
import { sendOrganisationDeleteEmail } from '../../../server-only/organisation/delete-organisation-email';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TAdminDeleteOrganisationJobDefinition } from './admin-delete-organisation';
@@ -63,32 +61,13 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo
return serializableContext;
});
// 1. Orphan all envelopes for every team.
for (const team of organisation.teams) {
await io.runTask(`orphan-envelopes--team-${team.id}`, async () => {
await orphanEnvelopes({ teamId: team.id });
});
}
// 2. Delete the organisation. Matches the transaction in organisation-router/delete-organisation.ts.
// 1. Orphan envelopes, delete the organisation, and schedule the Stripe
// subscription cancellation. Shared with organisation-router/delete-organisation.ts.
await io.runTask('delete-organisation', async () => {
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
await deleteOrganisation({ organisation });
});
// 3. Send the owner notification.
// 2. Send the owner notification.
if (sendEmailToOwner) {
await io.runTask('send-organisation-deleted-email', async () => {
await sendOrganisationDeleteEmail({
@@ -99,17 +78,4 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo
});
});
}
// 4. If the organisation has a Stripe subscription, schedule it to be cancelled at the end of the current billing period.
if (organisation.subscription) {
const stripeSubscriptionId = organisation.subscription.planId;
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId,
organisationId: organisation.id,
},
});
}
};
@@ -0,0 +1,55 @@
import { prisma } from '@documenso/prisma';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../constants/organisations';
import { jobs } from '../../jobs/client';
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
export type DeleteOrganisationOptions = {
organisation: {
id: string;
teams: { id: number }[];
subscription: { planId: string } | null;
};
};
/**
* Fully tears down an organisation:
*
* 1. Orphans every team's envelopes (so foreign key constraints don't block the delete).
* 2. Removes the organisation's account rows and the organisation itself in a transaction.
* 3. Schedules the Stripe subscription to be cancelled at the end of the billing period
* (when one exists). The job runs asynchronously so a Stripe outage doesn't block the
* delete, and is retried by the job runner if Stripe is temporarily unavailable.
*
* Authorization must be handled by the caller. This is the shared implementation used by
* the organisation delete route, the admin delete-organisation job, and account deletion.
*/
export const deleteOrganisation = async ({ organisation }: DeleteOrganisationOptions) => {
// Orphan all envelopes to get rid of foreign key constraints.
await Promise.all(organisation.teams.map(async (team) => orphanEnvelopes({ teamId: team.id })));
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
if (organisation.subscription) {
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId: organisation.subscription.planId,
organisationId: organisation.id,
},
});
}
};
+13 -6
View File
@@ -1,7 +1,7 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
import { deleteOrganisation } from '../organisation/delete-organisation';
export type DeleteUserOptions = {
id: number;
@@ -20,6 +20,11 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
id: true,
},
},
subscription: {
select: {
planId: true,
},
},
},
},
organisationMember: {
@@ -44,9 +49,6 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
});
}
// Get team IDs from organisations the user owns.
const ownedTeamIds = user.ownedOrganisations.flatMap((org) => org.teams.map((team) => team.id));
// Get team IDs from organisations the user is a member of (but not owner).
const memberTeams = user.organisationMember
.filter((member) => member.organisation.ownerUserId !== user.id)
@@ -57,8 +59,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
})),
);
// For teams where user is the org owner - orphan their envelopes.
await Promise.all(ownedTeamIds.map(async (teamId) => orphanEnvelopes({ teamId })));
// For organisations the user owns - fully tear them down (orphan envelopes,
// delete the organisation, and cancel any Stripe subscription). Without this
// the organisations would only cascade away when the user row is deleted,
// leaving their subscriptions billing and account rows behind.
for (const organisation of user.ownedOrganisations) {
await deleteOrganisation({ organisation });
}
// For teams where user is a member (not owner) - transfer envelopes to team owner.
await Promise.all(
@@ -1,10 +1,6 @@
import {
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
import { deleteOrganisation } from '@documenso/lib/server-only/organisation/delete-organisation';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@@ -57,35 +53,5 @@ export const deleteOrganisationRoute = authenticatedProcedure
});
}
// Orphan all envelopes to get rid of foreign key constraints.
await Promise.all(organisation.teams.map(async (team) => orphanEnvelopes({ teamId: team.id })));
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
// If the organisation has a Stripe subscription, schedule it to be
// cancelled at the end of the current billing period. The job runs
// asynchronously so a Stripe outage doesn't block deletion, and is
// retried by the job runner if Stripe is temporarily unavailable.
if (organisation.subscription) {
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId: organisation.subscription.planId,
organisationId: organisation.id,
},
});
}
await deleteOrganisation({ organisation });
});
@@ -36,6 +36,12 @@ export const profileRouter = router({
}),
deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => {
ctx.logger.info({
input: {
userId: ctx.user.id,
},
});
await deleteUser({
id: ctx.user.id,
});