From c9e0164721ad60848af5c4c7edc7c6d332587807 Mon Sep 17 00:00:00 2001 From: "Martin P. Tielemans" Date: Sat, 24 Oct 2026 15:13:24 +0200 Subject: [PATCH 1/2] feat(retention): delete an organization from Settings Admins can delete the organization from the Company page of Settings after typing its name. The server checks the role and the name again. A new runtime extension point, requestOrganizationDeletion, decides what the action does; without one the organization is purged at once. The capability is on for self-host and off in the hosted distribution until it opts in. Better Auth's own delete stays closed, so every deletion goes through the purge. Also proves PDFs, public links and exports keep working while the organization is read-only. Refs QUI-252 --- .../settings/delete-organization-card.tsx | 96 +++++++++++++++++++ apps/oss/src/domain/organization-deletion.ts | 14 +++ .../__tests__/pdf-access.integration.test.ts | 21 ++++ apps/oss/src/lib/i18n/catalog/da/settings.ts | 11 +++ apps/oss/src/lib/i18n/catalog/en/settings.ts | 11 +++ apps/oss/src/lib/runtime/auth-config.ts | 3 +- apps/oss/src/lib/runtime/extensions.ts | 18 ++++ .../oss/src/routes/_app/settings.$section.tsx | 9 ++ ...gs-delete-organization.integration.test.ts | 62 ++++++++++++ apps/oss/src/trpc/routers/settings.ts | 21 ++++ packages/contracts/src/runtime.ts | 5 + 11 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 apps/oss/src/components/settings/delete-organization-card.tsx create mode 100644 apps/oss/src/domain/organization-deletion.ts create mode 100644 apps/oss/src/trpc/routers/__tests__/settings-delete-organization.integration.test.ts diff --git a/apps/oss/src/components/settings/delete-organization-card.tsx b/apps/oss/src/components/settings/delete-organization-card.tsx new file mode 100644 index 000000000..36b708417 --- /dev/null +++ b/apps/oss/src/components/settings/delete-organization-card.tsx @@ -0,0 +1,96 @@ +import { Link } from "@tanstack/react-router" +import { useState } from "react" +import { switchActiveOrganization } from "../../lib/active-organization" +import { authClient } from "../../lib/auth-client" +import { invalidateAppLayoutSession } from "../../lib/app-layout-session" +import { formatDate } from "../../lib/i18n/format" +import { useI18n } from "../../lib/i18n/react" +import { loadPage } from "../../lib/page-navigation" +import { trpc } from "../../trpc/client" +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog" +import { Button } from "../ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" +import { Input } from "../ui/input" +import { Label } from "../ui/label" + +/** Permanently deletes the organization after the admin types its name. The server checks both again. */ +export function DeleteOrganizationCard({ organizationName }: { organizationName: string }) { + const { t, locale } = useI18n() + const [confirmName, setConfirmName] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [scheduledFor, setScheduledFor] = useState(null) + + async function deleteOrganization() { + setBusy(true) + setError(null) + try { + const outcome = await trpc.settings.deleteOrganization.mutate({ confirmName }) + if (outcome.status === "scheduled") { + setScheduledFor(outcome.deleteAfter) + setBusy(false) + return + } + if (outcome.nextOrganizationId) { + await switchActiveOrganization(outcome.nextOrganizationId) + return + } + // The user may have been deleted with the organization, which already ended the session. + await authClient.signOut().catch(() => undefined) + invalidateAppLayoutSession() + loadPage("/login") + } catch { + setError(t("settings.deleteOrganization.failed")) + setBusy(false) + } + } + + return ( + + + {t("settings.deleteOrganization.title")} + {t("settings.deleteOrganization.description", { name: organizationName })} + + + + {t("settings.deleteOrganization.export")} + + {scheduledFor ? ( +

{t("settings.deleteOrganization.scheduled", { date: formatDate(scheduledFor, locale) })}

+ ) : ( + { if (!open) { setConfirmName(""); setError(null) } }}> + + + + + + {t("settings.deleteOrganization.confirm.title", { name: organizationName })} + {t("settings.deleteOrganization.confirm.description")} + +
+ + setConfirmName(event.target.value)} /> + {error &&

{error}

} +
+ + {t("settings.deleteOrganization.confirm.cancel")} + + +
+
+ )} +
+
+ ) +} diff --git a/apps/oss/src/domain/organization-deletion.ts b/apps/oss/src/domain/organization-deletion.ts new file mode 100644 index 000000000..f5db88b5c --- /dev/null +++ b/apps/oss/src/domain/organization-deletion.ts @@ -0,0 +1,14 @@ +import { getRuntimeExtensions, type OrganizationDeletionOutcome, type OrganizationDeletionRequest } from "../lib/runtime/extensions" +import { purgeOrganization } from "./organization-purge" + +/** + * Carries out "Delete organisation" once the caller has checked the admin and the typed name. A + * distribution's `requestOrganizationDeletion` decides what happens; without one the organization is + * purged now. + */ +export async function requestOrganizationDeletion(request: OrganizationDeletionRequest): Promise { + const extension = getRuntimeExtensions().find(candidate => candidate.requestOrganizationDeletion) + if (extension?.requestOrganizationDeletion) return extension.requestOrganizationDeletion(request) + await purgeOrganization(request.organizationId) + return { status: "deleted" } +} diff --git a/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts b/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts index 33232eb8f..a756012e7 100644 --- a/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts +++ b/apps/oss/src/lib/documents/__tests__/pdf-access.integration.test.ts @@ -7,6 +7,9 @@ import { setRuntimeServices, resetRuntimeServices, type RenderInput } from "../. import { privateDocumentPdf, publicInvoicePdf } from "../pdf-access" import { issueDocument } from "../../../application/issuance" import { signInvoicePaymentToken } from "../../payments/public" +import { loadPublicInvoiceByToken } from "../../payments/public-access" +import { billingProvider } from "../../billing" +import { exportAccounting } from "../../exports/accounting" const cleanups: Array<() => Promise> = [] const bytes = new Map() @@ -86,4 +89,22 @@ const request = new Request("http://quits.test/download") await prisma.invoice.update({ where: { id: invoice.id }, data: { publicPaymentKeyVersion: { increment: 1 } } }) expect((await publicInvoicePdf(token)).status).toBe(404) }) + it("keeps PDFs, public links and exports available while the organization is read-only", async () => { + const { org, invoice } = await setup() + const sent = await issueDocument({ kind: "invoice", actor: org.actors.admin, commandInput: { id: invoice.id, allowSendWithoutEmail: true }, clientRequestId: "read-only" }) + expect(sent, JSON.stringify(sent)).toMatchObject({ status: "completed" }) + const issued = await prisma.invoice.update({ where: { id: invoice.id }, data: { publicPaymentIssuedAt: new Date() } }) + setRuntimeServices({ billingProvider: { + getSubscription: async () => ({ status: "canceled", priceId: null, organizationAccess: { state: "read_only", reason: "canceled" }, + access: { paidOnly: false, hasSubscription: false, checkoutAvailable: false, portalAvailable: false } }), + assertInvoiceCreationAllowed: async () => {}, + } }) + await expect(billingProvider.assertInvoiceCreationAllowed(org.organizationId)).rejects.toMatchObject({ code: "FORBIDDEN" }) + expect((await privateDocumentPdf(request, "invoice", invoice.id)).headers.get("X-Quits-Artifact")).toBe("stored") + const token = signInvoicePaymentToken({ invoiceId: invoice.id, keyVersion: issued.publicPaymentKeyVersion, scope: "invoice_payment" }, "synthetic-download-link-secret") + expect((await publicInvoicePdf(token)).headers.get("X-Quits-Artifact")).toBe("stored") + expect(await loadPublicInvoiceByToken(token, "synthetic-download-link-secret")).toMatchObject({ invoice: { id: invoice.id } }) + const today = new Date().toISOString().slice(0, 10) + expect((await exportAccounting(org.organizationId, { dataset: "invoices", from: today, to: today })).csv).toContain(issued.number!) + }) }) diff --git a/apps/oss/src/lib/i18n/catalog/da/settings.ts b/apps/oss/src/lib/i18n/catalog/da/settings.ts index f4c3eae6d..456e7b082 100644 --- a/apps/oss/src/lib/i18n/catalog/da/settings.ts +++ b/apps/oss/src/lib/i18n/catalog/da/settings.ts @@ -284,4 +284,15 @@ export const daSettingsMessages = { "settings.row.done": "Færdig", "settings.row.empty": "Ikke angivet", "settings.row.vat": "Momsregistrering", + "settings.deleteOrganization.title": "Slet organisation", + "settings.deleteOrganization.description": "Sletter {name} permanent med dokumenter, kunder og betalinger og de brugere, der ikke er med i en anden organisation. Det kan ikke fortrydes. Du skal selv opbevare dine fakturaer og kreditnotaer i 5 år, så hent dine data først.", + "settings.deleteOrganization.export": "Hent dine data", + "settings.deleteOrganization.action": "Slet organisation", + "settings.deleteOrganization.confirm.title": "Slet {name} permanent?", + "settings.deleteOrganization.confirm.description": "Alt i organisationen bliver slettet og kan ikke gendannes.", + "settings.deleteOrganization.confirm.label": "Skriv {name} for at bekræfte", + "settings.deleteOrganization.confirm.cancel": "Annuller", + "settings.deleteOrganization.confirm.submit": "Slet permanent", + "settings.deleteOrganization.failed": "Kunne ikke slette organisationen. Tjek navnet, og prøv igen.", + "settings.deleteOrganization.scheduled": "Organisationen bliver slettet den {date}.", } as const diff --git a/apps/oss/src/lib/i18n/catalog/en/settings.ts b/apps/oss/src/lib/i18n/catalog/en/settings.ts index 8298617e9..d9ac91beb 100644 --- a/apps/oss/src/lib/i18n/catalog/en/settings.ts +++ b/apps/oss/src/lib/i18n/catalog/en/settings.ts @@ -282,4 +282,15 @@ export const enSettingsMessages = { "settings.row.done": "Done", "settings.row.empty": "Not set", "settings.row.vat": "VAT registration", + "settings.deleteOrganization.title": "Delete organization", + "settings.deleteOrganization.description": "Permanently deletes {name} with its documents, customers and payments, and the users who belong to no other organization. This cannot be undone. You must keep your invoices and credit notes for 5 years, so export your data first.", + "settings.deleteOrganization.export": "Export your data", + "settings.deleteOrganization.action": "Delete organization", + "settings.deleteOrganization.confirm.title": "Delete {name} permanently?", + "settings.deleteOrganization.confirm.description": "Everything in the organization is deleted and cannot be restored.", + "settings.deleteOrganization.confirm.label": "Type {name} to confirm", + "settings.deleteOrganization.confirm.cancel": "Cancel", + "settings.deleteOrganization.confirm.submit": "Delete permanently", + "settings.deleteOrganization.failed": "Could not delete the organization. Check the name and try again.", + "settings.deleteOrganization.scheduled": "The organization will be deleted on {date}.", } as const diff --git a/apps/oss/src/lib/runtime/auth-config.ts b/apps/oss/src/lib/runtime/auth-config.ts index 3a9a48843..49839845e 100644 --- a/apps/oss/src/lib/runtime/auth-config.ts +++ b/apps/oss/src/lib/runtime/auth-config.ts @@ -102,7 +102,8 @@ export function buildQuitsAuthOptions(input: { const organizationPlugin = organization({ ac, roles: { admin, member, accountant }, - // Organization deletion must not remove financial records or their evidence. + // Better Auth would delete only some rows. Settings deletes through `requestOrganizationDeletion`, + // which ends in `purgeOrganization`, so this endpoint stays closed. disableOrganizationDeletion: true, allowUserToCreateOrganization: true, organizationHooks: { diff --git a/apps/oss/src/lib/runtime/extensions.ts b/apps/oss/src/lib/runtime/extensions.ts index 324c645a9..e8d2b4ca1 100644 --- a/apps/oss/src/lib/runtime/extensions.ts +++ b/apps/oss/src/lib/runtime/extensions.ts @@ -36,6 +36,14 @@ export type OrganizationPurgeContext = { userIds: readonly string[] } +export type OrganizationDeletionRequest = { + organizationId: string + /** The admin who confirmed the deletion by typing the organization's name. */ + userId: string +} +/** `deleted`: the organization is gone now. `scheduled`: it will be deleted at `deleteAfter`. */ +export type OrganizationDeletionOutcome = { status: "deleted" } | { status: "scheduled"; deleteAfter: Date } + export type RuntimeExtension = { id: string /** Hosted runtimes opt in after resolving existing duplicate CVRs. Ignored by self-host. */ @@ -76,6 +84,13 @@ export type RuntimeExtension = { * Self-hosted runtimes leave this unset. */ onOrganizationPurge?: (tx: OrganizationPurgeTransaction, context: OrganizationPurgeContext) => Promise + /** + * Decides what "Delete organisation" in Settings does, after the app has checked that the user is + * an admin and typed the organization's name. The first extension that sets this replaces the + * self-hosted default, which purges at once. A scheduled deletion must still end in + * `purgeOrganization`. Self-hosted runtimes leave this unset. + */ + requestOrganizationDeletion?: (request: OrganizationDeletionRequest) => Promise } const DEFAULT_MAX_PROMPT_CHARS = 4000 @@ -91,6 +106,7 @@ function mergeCapabilities( return { sellerVatConfirmation: { enabled: base.sellerVatConfirmation?.enabled ?? false, ...patch.sellerVatConfirmation }, + organizationDeletion: { enabled: base.organizationDeletion?.enabled ?? false, ...patch.organizationDeletion }, documents: { ...base.documents, ...patch.documents }, agreements: { ...base.agreements, ...patch.agreements }, aiInvoiceDraft: { @@ -142,6 +158,8 @@ function readDefaultCapabilities( return { onboardingV2: { enabled: false, nonVatEnabled: false }, sellerVatConfirmation: { enabled: false }, + // Hosted turns it on once its extension decides what deletion does there. + organizationDeletion: { enabled: !isCloud }, documents: { artifactsRequired: true }, agreements: { depositsEnabled: readBooleanEnv(readProductEnv(env, "DEPOSITS_ENABLED"), true) }, aiInvoiceDraft: { diff --git a/apps/oss/src/routes/_app/settings.$section.tsx b/apps/oss/src/routes/_app/settings.$section.tsx index e42ba1482..d3ef0919c 100644 --- a/apps/oss/src/routes/_app/settings.$section.tsx +++ b/apps/oss/src/routes/_app/settings.$section.tsx @@ -71,6 +71,8 @@ import type { VatPeriod } from "../../lib/vat-deadline" import { AccountingIntegrationsCard } from "../../components/settings/accounting-integrations-card" import { AgentKeysCard } from "../../components/settings/agent-keys-card" import { AuditLogCard } from "../../components/settings/audit-log-card" +import { DeleteOrganizationCard } from "../../components/settings/delete-organization-card" +import { parseOrganizationRoles } from "../../domain/permissions" import { PaymentDetailsCard } from "../../components/settings/payment-details-card" import { ReminderPolicyCard } from "../../components/settings/reminder-policy-card" import { useRuntimeUi } from "../../lib/runtime/ui" @@ -282,6 +284,7 @@ function SettingsPage() { const [sellerVatChoiceReviewed, setSellerVatChoiceReviewed] = useState(false) const [withdrawSellerVatEvidence, setWithdrawSellerVatEvidence] = useState(false) const [nonVatEnabled, setNonVatEnabled] = useState(false) + const [organizationDeletionEnabled, setOrganizationDeletionEnabled] = useState(false) const [sellerVatConfirmationEnabled, setSellerVatConfirmationEnabled] = useState(null) const [capabilitiesFailed, setCapabilitiesFailed] = useState(false) const [capabilitiesAttempt, setCapabilitiesAttempt] = useState(0) @@ -331,6 +334,7 @@ function SettingsPage() { // Team members state const [organizationSlug, setOrganizationSlug] = useState("") const [savedOrganizationSlug, setSavedOrganizationSlug] = useState(null) + const [organizationName, setOrganizationName] = useState("") const [slugSaving, setSlugSaving] = useState(false) const [slugSaved, setSlugSaved] = useState(false) const [slugError, setSlugError] = useState(null) @@ -454,6 +458,7 @@ function SettingsPage() { setAiCapabilities((data as Pick).aiInvoiceDraft) setSellerVatConfirmationEnabled(data.sellerVatConfirmation?.enabled === true) setNonVatEnabled(data.onboardingV2?.nonVatEnabled === true) + setOrganizationDeletionEnabled(data.organizationDeletion?.enabled === true) }) .catch(() => setCapabilitiesFailed(true)) .finally(() => setLoadingAiCapabilities(false)) @@ -514,6 +519,7 @@ function SettingsPage() { const slug = typeof org.data.slug === "string" ? org.data.slug : "" setOrganizationSlug(slug) setSavedOrganizationSlug(slug) + setOrganizationName(org.data.name) setMembers((org.data.members ?? []) as OrgMember[]) setInvitations( ((org.data.invitations ?? []) as OrgInvitation[]).filter( @@ -1175,6 +1181,9 @@ function SettingsPage() { locale={displayLocale} /> )} + {organizationDeletionEnabled && organizationName && parseOrganizationRoles(members.find(m => m.user.id === session?.user.id)?.role).includes("admin") && ( + + )}
diff --git a/apps/oss/src/trpc/routers/__tests__/settings-delete-organization.integration.test.ts b/apps/oss/src/trpc/routers/__tests__/settings-delete-organization.integration.test.ts new file mode 100644 index 000000000..668a3baf9 --- /dev/null +++ b/apps/oss/src/trpc/routers/__tests__/settings-delete-organization.integration.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { prisma } from "../../../lib/db" +import { setRuntimeExtensions } from "../../../lib/runtime/extensions" +import { ensureTestMembership } from "../../../test-utils/membership" +import { createTestOrganization, hasTestDatabase } from "../../../test-utils/organization" +import { appRouter } from "../../router" + +;(hasTestDatabase ? describe : describe.skip)("settings.deleteOrganization", () => { + const cleanups: Array<() => Promise> = [] + afterEach(async () => { setRuntimeExtensions([]); while (cleanups.length) await cleanups.pop()?.() }) + + async function setup() { + const org = await createTestOrganization({ roles: ["admin", "member", "accountant"] }) + cleanups.push(org.cleanup) + const { name } = await prisma.organization.findUniqueOrThrow({ where: { id: org.organizationId } }) + const caller = (role: "admin" | "member" | "accountant") => appRouter.createCaller({ session: { + user: { id: org.actors[role].userId, email: `${role}@example.test`, name: role }, + session: { activeOrganizationId: org.organizationId }, + } } as never) + const exists = async () => Boolean(await prisma.organization.findUnique({ where: { id: org.organizationId } })) + return { org, name, caller, exists } + } + + it("refuses members, accountants and a mistyped name", async () => { + const { name, caller, exists } = await setup() + for (const role of ["member", "accountant"] as const) { + await expect(caller(role).settings.deleteOrganization({ confirmName: name })).rejects.toMatchObject({ code: "FORBIDDEN" }) + } + await expect(caller("admin").settings.deleteOrganization({ confirmName: `${name} typo` })).rejects.toMatchObject({ code: "BAD_REQUEST" }) + expect(await exists()).toBe(true) + }) + + it("refuses when a distribution switches the capability off", async () => { + const { name, caller, exists } = await setup() + setRuntimeExtensions([{ id: "hide-deletion", resolveCapabilities: () => ({ organizationDeletion: { enabled: false } }) }]) + await expect(caller("admin").settings.deleteOrganization({ confirmName: name })).rejects.toMatchObject({ code: "FORBIDDEN" }) + expect(await exists()).toBe(true) + }) + + it("purges at once without an extension and sends the admin to their other organization", async () => { + const { org, name, caller, exists } = await setup() + const other = await createTestOrganization() + cleanups.push(other.cleanup) + await ensureTestMembership(other.organizationId, org.actors.admin.userId, "member") + await expect(caller("admin").settings.deleteOrganization({ confirmName: ` ${name} ` })) + .resolves.toEqual({ status: "deleted", nextOrganizationId: other.organizationId }) + expect(await exists()).toBe(false) + expect(await prisma.user.findUnique({ where: { id: org.actors.member.userId } })).toBeNull() + expect(await prisma.user.findUnique({ where: { id: org.actors.admin.userId } })).not.toBeNull() + }) + + it("lets an extension replace the purge", async () => { + const { org, name, caller, exists } = await setup() + const deleteAfter = new Date("2099-01-08T00:00:00Z") + const requestOrganizationDeletion = vi.fn(async () => ({ status: "scheduled" as const, deleteAfter })) + setRuntimeExtensions([{ id: "scheduled-deletion", requestOrganizationDeletion }]) + await expect(caller("admin").settings.deleteOrganization({ confirmName: name })) + .resolves.toEqual({ status: "scheduled", deleteAfter, nextOrganizationId: null }) + expect(requestOrganizationDeletion).toHaveBeenCalledWith({ organizationId: org.organizationId, userId: org.actors.admin.userId }) + expect(await exists()).toBe(true) + }) +}) diff --git a/apps/oss/src/trpc/routers/settings.ts b/apps/oss/src/trpc/routers/settings.ts index bd030cbea..1e1a730b2 100644 --- a/apps/oss/src/trpc/routers/settings.ts +++ b/apps/oss/src/trpc/routers/settings.ts @@ -37,6 +37,7 @@ import { encryptSecret } from "../../lib/secrets" import { getStripePaymentConfigurationState, resolveStripeCheckoutTarget, stripeByokConfigured, usesStripeConnectCheckout } from "../../lib/payments/stripe" import { appLogger } from "../../lib/observability" import { actorCan } from "../../domain/actor" +import { requestOrganizationDeletion } from "../../domain/organization-deletion" import { getRuntimeCapabilities, isNonVatRegistrationAllowed } from "../../lib/runtime/extensions" import { getRuntimeEnv, getRuntimePlatform } from "../../lib/runtime/platform" import { @@ -831,6 +832,26 @@ export const settingsRouter = router({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Could not update Stripe Connect. Please try again." }) } }), + + /** + * "Delete organisation". Only a signed-in admin, never an agent key, and only after typing the + * organization's name. Returns where the user goes next when the organization is already gone. + */ + deleteOrganization: authorizedProcedure("settings:update") + .input(z.object({ confirmName: z.string().max(200) }).strict()) + .mutation(async ({ ctx, input }) => { + if (!getRuntimeCapabilities().organizationDeletion.enabled || ctx.actor.kind !== "user") { + throw new TRPCError({ code: "FORBIDDEN", message: "Organization deletion is not available" }) + } + const organization = await prisma.organization.findUniqueOrThrow({ where: { id: ctx.organizationId }, select: { name: true } }) + if (input.confirmName.trim() !== organization.name.trim()) { + throw new TRPCError({ code: "BAD_REQUEST", message: "The name does not match the organization" }) + } + const outcome = await requestOrganizationDeletion({ organizationId: ctx.organizationId, userId: ctx.user.id }) + if (outcome.status !== "deleted") return { ...outcome, nextOrganizationId: null } + const next = await prisma.member.findFirst({ where: { userId: ctx.user.id }, orderBy: { createdAt: "asc" }, select: { organizationId: true } }) + return { ...outcome, nextOrganizationId: next?.organizationId ?? null } + }), }) /** diff --git a/packages/contracts/src/runtime.ts b/packages/contracts/src/runtime.ts index 18752b4e1..4d5eba7a0 100644 --- a/packages/contracts/src/runtime.ts +++ b/packages/contracts/src/runtime.ts @@ -46,6 +46,9 @@ export const documentsCapabilitiesSchema = z.object({ artifactsRequired: z.boole export const agreementCapabilitiesSchema = z.object({ depositsEnabled: z.boolean() }).strict() +/** Whether admins see "Delete organisation" in Settings. On for self-host; a distribution can change it. */ +export const organizationDeletionCapabilitiesSchema = z.object({ enabled: z.boolean() }).strict() + export const runtimeCapabilitiesSchema = z .object({ documents: documentsCapabilitiesSchema, @@ -54,6 +57,7 @@ export const runtimeCapabilitiesSchema = z onboardingAi: onboardingAiCapabilitiesSchema, onboardingV2: z.object({ enabled: z.boolean(), nonVatEnabled: z.boolean().optional() }).strict().optional(), sellerVatConfirmation: z.object({ enabled: z.boolean() }).strict().default({ enabled: false }), + organizationDeletion: organizationDeletionCapabilitiesSchema.default({ enabled: false }), payments: paymentsCapabilitiesSchema, emailDelivery: emailDeliveryCapabilitiesSchema, }) @@ -67,6 +71,7 @@ export const runtimeCapabilityPatchSchema = z onboardingAi: onboardingAiCapabilitiesSchema.partial().optional(), onboardingV2: z.object({ enabled: z.boolean().optional(), nonVatEnabled: z.boolean().optional() }).strict().optional(), sellerVatConfirmation: z.object({ enabled: z.boolean().optional() }).strict().optional(), + organizationDeletion: organizationDeletionCapabilitiesSchema.partial().optional(), payments: paymentsCapabilitiesSchema.partial().optional(), emailDelivery: emailDeliveryCapabilitiesSchema.partial().optional(), }) From 07be065e4df91593b568ea666379b2e3df520bd4 Mon Sep 17 00:00:00 2001 From: "Martin P. Tielemans" Date: Sat, 24 Oct 2026 15:24:33 +0200 Subject: [PATCH 2/2] fix: keep the delete error out of the global mutation banner The card shows its own error, so the shared banner must not report it again. Refs QUI-252 --- apps/oss/src/components/settings/delete-organization-card.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/oss/src/components/settings/delete-organization-card.tsx b/apps/oss/src/components/settings/delete-organization-card.tsx index 36b708417..da139fd96 100644 --- a/apps/oss/src/components/settings/delete-organization-card.tsx +++ b/apps/oss/src/components/settings/delete-organization-card.tsx @@ -6,6 +6,7 @@ import { invalidateAppLayoutSession } from "../../lib/app-layout-session" import { formatDate } from "../../lib/i18n/format" import { useI18n } from "../../lib/i18n/react" import { loadPage } from "../../lib/page-navigation" +import { handledMutationError } from "../../lib/query-refresh" import { trpc } from "../../trpc/client" import { AlertDialog, @@ -34,7 +35,7 @@ export function DeleteOrganizationCard({ organizationName }: { organizationName: setBusy(true) setError(null) try { - const outcome = await trpc.settings.deleteOrganization.mutate({ confirmName }) + const outcome = await trpc.settings.deleteOrganization.mutate({ confirmName }, handledMutationError) if (outcome.status === "scheduled") { setScheduledFor(outcome.deleteAfter) setBusy(false)