The problem
When we delete an organization, deleter.Service.DeleteOrganization removes things in this order:
- Policies (this includes the org owners)
- Projects, groups, service users, roles, invitations
- Billing data: subscriptions, invoices, the Stripe customer, then the
billing_customers row
- The org itself
Step 3 can fail because of two tables we never clean up:
billing_checkouts has a foreign key to billing_customers. We never delete checkout rows — the checkout repository does not even have a delete method. So if the org ever opened a Stripe checkout page (almost every paying org has), the hard delete of the customer row fails with a foreign key error.
org_kyc has a foreign key to organizations. We never delete the KYC row, so the org row delete can fail the same way.
When this happens, the damage is already done. The owners and projects were deleted in steps 1 and 2, and the Stripe customer was deleted just before the failing row. The org is now half-deleted: it still exists, but nobody owns it, and its billing record points to a Stripe customer that no longer exists. Retrying does not help — the same foreign key fails every time, and every billing API call for the org keeps failing with No such customer from Stripe.
Two smaller gaps in the same flow:
- For billing accounts not linked to Stripe (offline accounts), we skip deleting their local subscription and invoice rows. If such rows exist, the customer delete fails the same way.
- Token transactions (
billing_transactions) have no foreign key, so they are silently left behind after a delete.
The fix
- Add a delete method for checkouts and call it in
DeleteCustomers before deleting the billing customer row.
- Delete the
org_kyc row as part of the org delete.
- Always delete local subscription and invoice rows, whether or not the account is on Stripe.
- Delete the org's token transactions along with the billing customer.
- Reorder the flow: billing teardown first, identity (policies, projects, and so on) after, org row last. Billing talks to an external system, so it is the step most likely to fail. If it fails first, the org still has its owners and a retry can finish the job cleanly.
- Make every step safe to retry. If Stripe says a customer or subscription is already gone, treat that as success. The customer delete already does this; the subscription path should too.
Why this way
Deleting an org touches many tables and an external system, and we cannot wrap Stripe calls in a database transaction. So the realistic goal is not "all or nothing" — it is "any failure leaves the org retryable, never broken". Cleaning up every referencing table removes the foreign key failures, and the reordering plus retry-safety means a failed delete can always be run again to completion.
Note: these are hard deletes
All the deletes above remove rows permanently. We considered soft delete (keeping rows and marking them with deleted_at) and decided against it:
- Some tables have a
deleted_at column, but no query in the codebase filters on it. Soft delete would require updating every Get/List query in every affected repository, and missing one means deleted orgs keep showing up in lists and sync jobs.
- Names are globally unique (
organizations.name, projects.name). A soft-deleted org would hold its name forever, so nobody could reuse it without new partial unique indexes.
- The foreign key chain makes it all-or-nothing:
billing_customers.org_id points to organizations.id, so we cannot keep billing rows while hard-deleting the org. Either the whole chain goes soft or the whole chain goes hard.
- Frontier already has a reversible option: disabling an org keeps everything and can be undone. Delete is the permanent purge, and it should behave like one.
If a deleted record is ever needed later, there are still two places to find it:
- Audit records — the delete flow writes an audit record for each deleted entity (who, what, when, and key fields).
- Stripe — Stripe permanently keeps its own copy of every customer, subscription, invoice, and checkout, even after deletion.
The problem
When we delete an organization,
deleter.Service.DeleteOrganizationremoves things in this order:billing_customersrowStep 3 can fail because of two tables we never clean up:
billing_checkoutshas a foreign key tobilling_customers. We never delete checkout rows — the checkout repository does not even have a delete method. So if the org ever opened a Stripe checkout page (almost every paying org has), the hard delete of the customer row fails with a foreign key error.org_kychas a foreign key toorganizations. We never delete the KYC row, so the org row delete can fail the same way.When this happens, the damage is already done. The owners and projects were deleted in steps 1 and 2, and the Stripe customer was deleted just before the failing row. The org is now half-deleted: it still exists, but nobody owns it, and its billing record points to a Stripe customer that no longer exists. Retrying does not help — the same foreign key fails every time, and every billing API call for the org keeps failing with
No such customerfrom Stripe.Two smaller gaps in the same flow:
billing_transactions) have no foreign key, so they are silently left behind after a delete.The fix
DeleteCustomersbefore deleting the billing customer row.org_kycrow as part of the org delete.Why this way
Deleting an org touches many tables and an external system, and we cannot wrap Stripe calls in a database transaction. So the realistic goal is not "all or nothing" — it is "any failure leaves the org retryable, never broken". Cleaning up every referencing table removes the foreign key failures, and the reordering plus retry-safety means a failed delete can always be run again to completion.
Note: these are hard deletes
All the deletes above remove rows permanently. We considered soft delete (keeping rows and marking them with
deleted_at) and decided against it:deleted_atcolumn, but no query in the codebase filters on it. Soft delete would require updating every Get/List query in every affected repository, and missing one means deleted orgs keep showing up in lists and sync jobs.organizations.name,projects.name). A soft-deleted org would hold its name forever, so nobody could reuse it without new partial unique indexes.billing_customers.org_idpoints toorganizations.id, so we cannot keep billing rows while hard-deleting the org. Either the whole chain goes soft or the whole chain goes hard.If a deleted record is ever needed later, there are still two places to find it: