diff --git a/apps/docs/content/docs/users/settings/delete-account.mdx b/apps/docs/content/docs/users/settings/delete-account.mdx
index 569dc1bc2..77640db70 100644
--- a/apps/docs/content/docs/users/settings/delete-account.mdx
+++ b/apps/docs/content/docs/users/settings/delete-account.mdx
@@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
- 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.
## 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.
+## 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.
+
+
+ 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.
+
+
---
## See Also
diff --git a/packages/app-tests/e2e/user/delete-account.spec.ts b/packages/app-tests/e2e/user/delete-account.spec.ts
index 821cdc0ac..25888cfdc 100644
--- a/packages/app-tests/e2e/user/delete-account.spec.ts
+++ b/packages/app-tests/e2e/user/delete-account.spec.ts
@@ -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();
+});
diff --git a/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts
index 3de61331c..433eda723 100644
--- a/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts
+++ b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts
@@ -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,
- },
- });
- }
};
diff --git a/packages/lib/server-only/organisation/delete-organisation.ts b/packages/lib/server-only/organisation/delete-organisation.ts
new file mode 100644
index 000000000..e0c8bded6
--- /dev/null
+++ b/packages/lib/server-only/organisation/delete-organisation.ts
@@ -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,
+ },
+ });
+ }
+};
diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts
index 6a4da06a2..2ad28d42c 100644
--- a/packages/lib/server-only/user/delete-user.ts
+++ b/packages/lib/server-only/user/delete-user.ts
@@ -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(
diff --git a/packages/trpc/server/organisation-router/delete-organisation.ts b/packages/trpc/server/organisation-router/delete-organisation.ts
index 11b051ba3..f622fe643 100644
--- a/packages/trpc/server/organisation-router/delete-organisation.ts
+++ b/packages/trpc/server/organisation-router/delete-organisation.ts
@@ -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 });
});
diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts
index 750f0c60e..f75610609 100644
--- a/packages/trpc/server/profile-router/router.ts
+++ b/packages/trpc/server/profile-router/router.ts
@@ -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,
});