Subject: [PATCH links] account: ability to fully delete your account

From: Peter Sanchez <[email-masked]>
Date: Tue, 24 Mar 2026 16:57:20 -0600

Implements: https://todo.code.netlandish.com/~netlandish/links/115
Changelog-updated: api version to 0.12.0
Changelog-added: ability to fully delete your account.
---
 accounts/input.go                             |  28 +++
 accounts/routes.go                            |  75 +++++++
 api/graph/generated.go                        | 190 +++++++++++++++---
 api/graph/model/models_gen.go                 |   8 +-
 api/graph/schema.graphqls                     |  11 +-
 api/graph/schema.resolvers.go                 |  84 +++++++-
 billing/processors.go                         |  22 +-
 billing/routes_test.go                        |   5 +-
 cmd/migrations.go                             |   7 +
 .../0010_preserve_billing_on_delete.down.sql  |  21 ++
 .../0010_preserve_billing_on_delete.up.sql    |  19 ++
 models/audit_log.go                           |   1 +
 models/base_url.go                            |  51 +++++
 models/invoice.go                             |   4 +-
 models/models.go                              |  54 ++---
 models/user.go                                |  15 ++
 templates/delete_account.html                 |  40 ++++
 templates/settings.html                       |   6 +
 18 files changed, 566 insertions(+), 75 deletions(-)
 create mode 100644 migrations/0010_preserve_billing_on_delete.down.sql
 create mode 100644 migrations/0010_preserve_billing_on_delete.up.sql
 create mode 100644 templates/delete_account.html

diff --git a/accounts/input.go b/accounts/input.go
index e114803..782d5e0 100644
--- a/accounts/input.go
+++ b/accounts/input.go
@@ -145,3 +145,31 @@ func (p *ProfileForm) Validate(c echo.Context) error {
 	// Example, valid email address, etc.
 	return c.Validate(p)
 }
+
+// DeleteAccountForm ...
+type DeleteAccountForm struct {
+	Password     string `form:"password" validate:"required,max=100"`
+	Confirmation string `form:"confirmation" validate:"required"`
+}
+
+// Validate ...
+func (d *DeleteAccountForm) Validate(c echo.Context) error {
+	errs := validate.FormFieldBinder(c, d).
+		FailFast(false).
+		String("password", &d.Password).
+		String("confirmation", &d.Confirmation).
+		BindErrors()
+	if errs != nil {
+		return validate.GetInputErrors(errs)
+	}
+	if err := c.Validate(d); err != nil {
+		return err
+	}
+	lt := localizer.GetSessionLocalizer(c)
+	if strings.ToLower(strings.TrimSpace(d.Confirmation)) != "delete" {
+		return validate.InputErrors{
+			"Confirmation": lt.Translate("Please type 'delete' to confirm."),
+		}
+	}
+	return nil
+}
diff --git a/accounts/routes.go b/accounts/routes.go
index 3b97912..5e0aac9 100644
--- a/accounts/routes.go
+++ b/accounts/routes.go
@@ -62,6 +62,8 @@ func (s *Service) RegisterRoutes() {
 	s.Group.GET("/settings/log", s.UserLog).Name = s.RouteName("settings_log")
 	s.Group.GET("/profile", s.EditProfile).Name = s.RouteName("profile_edit")
 	s.Group.POST("/profile", s.EditProfile).Name = s.RouteName("profile_edit_post")
+	s.Group.GET("/delete-account", s.DeleteAccount).Name = s.RouteName("delete_account")
+	s.Group.POST("/delete-account", s.DeleteAccount).Name = s.RouteName("delete_account_post")
 }
 
 // EditProfile ..
@@ -271,6 +273,9 @@ func (s *Service) Settings(c echo.Context) error {
 	pd.Data["drag_drop"] = lt.Translate("Drag and drop this button to your web browser toolbar or your bookmarks.")
 	pd.Data["email_posting"] = lt.Translate("Email Posting")
 	pd.Data["email_posting_warning"] = lt.Translate("Do not share this address. Anyone with this email can post links to your account.")
+	pd.Data["danger_zone"] = lt.Translate("Danger Zone")
+	pd.Data["danger_zone_detail"] = lt.Translate("Permanently delete your account and all associated data.")
+	pd.Data["delete_account"] = lt.Translate("Delete Account")
 
 	user := gctx.User.(*models.User)
 	langTrans := map[string]string{
@@ -552,6 +557,76 @@ func (s *Service) Register(c echo.Context) error {
 	return s.Render(c, http.StatusOK, "register.html", gmap)
 }
 
+// DeleteAccount handles the account deletion page
+func (s *Service) DeleteAccount(c echo.Context) error {
+	lt := localizer.GetSessionLocalizer(c)
+	pd := localizer.NewPageData(lt.Translate("Delete Account"))
+	pd.Data["delete_account"] = lt.Translate("Delete Account")
+	pd.Data["warning"] = lt.Translate("This action is permanent and cannot be undone.")
+	pd.Data["warning_detail"] = lt.Translate("All your organizations, bookmarks, short links, listings, and related data will be permanently deleted.")
+	pd.Data["password"] = lt.Translate("Password")
+	pd.Data["confirmation_label"] = lt.Translate("Type 'delete' to confirm")
+	pd.Data["submit"] = lt.Translate("Delete My Account")
+	pd.Data["cancel"] = lt.Translate("Cancel")
+
+	gmap := gobwebs.Map{
+		"pd":             pd,
+		"settingSection": true,
+		"navFlag":        "settings",
+	}
+
+	req := c.Request()
+	if req.Method == http.MethodPost {
+		form := &DeleteAccountForm{}
+		if err := form.Validate(c); err != nil {
+			switch err.(type) {
+			case validate.InputErrors:
+				gmap["errors"] = err
+				gmap["form"] = form
+				return s.Render(c, http.StatusOK, "delete_account.html", gmap)
+			default:
+				return err
+			}
+		}
+
+		type GraphQLResponse struct {
+			Result struct {
+				Success  bool   `json:"success"`
+				ObjectID string `json:"objectId"`
+				Message  string `json:"message"`
+			} `json:"deleteAccount"`
+		}
+		var result GraphQLResponse
+		op := gqlclient.NewOperation(
+			`mutation DeleteAccount($password: String!) {
+				deleteAccount(input: {password: $password}) {
+					success
+					objectId
+					message
+				}
+			}`)
+		op.Var("password", form.Password)
+		err := links.Execute(c.Request().Context(), op, &result)
+		if err != nil {
+			if graphError, ok := err.(*gqlclient.Error); ok {
+				err = links.ParseInputErrors(c, graphError, gobwebs.Map{})
+				switch err.(type) {
+				case validate.InputErrors:
+					gmap["errors"] = err
+					gmap["form"] = form
+					return s.Render(c, http.StatusOK, "delete_account.html", gmap)
+				}
+			}
+			return err
+		}
+
+		auth.UserLogout(c)
+		messages.Success(c, lt.Translate("Your account has been permanently deleted."))
+		return c.Redirect(http.StatusMovedPermanently, c.Echo().Reverse("accounts:login"))
+	}
+	return s.Render(c, http.StatusOK, "delete_account.html", gmap)
+}
+
 // NewService return service
 func NewService(eg *echo.Group, render validate.TemplateRenderFunc) *Service {
 	baseService := server.NewService(eg, "accounts", render)
diff --git a/api/graph/generated.go b/api/graph/generated.go
index 78b7cce..bcd11e0 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -252,6 +252,7 @@ type ComplexityRoot struct {
 		AddQRCode              func(childComplexity int, input model.AddQRCodeInput) int
 		CompleteRegister       func(childComplexity int, input *model.CompleteRegisterInput) int
 		ConfirmMember          func(childComplexity int, key string) int
+		DeleteAccount          func(childComplexity int, input model.DeleteAccountInput) int
 		DeleteDomain           func(childComplexity int, id int) int
 		DeleteLink             func(childComplexity int, hash string) int
 		DeleteLinkShort        func(childComplexity int, id int) int
@@ -541,6 +542,7 @@ type MutationResolver interface {
 	Unfollow(ctx context.Context, orgSlug string) (*model.OperationPayload, error)
 	DeleteTag(ctx context.Context, input model.TagInput) (*model.DeletePayload, error)
 	RenameTag(ctx context.Context, input model.TagInput) (*model.OperationPayload, error)
+	DeleteAccount(ctx context.Context, input model.DeleteAccountInput) (*model.DeletePayload, error)
 	UpdateAdminOrg(ctx context.Context, input model.AdminUpdateOrgInput) (*models.Organization, error)
 	AddAdminDomain(ctx context.Context, input model.AdminDomainInput) (*models.Domain, error)
 	UpdateAdminDomain(ctx context.Context, input model.UpdateAdminDomainInput) (*models.Domain, error)
@@ -1545,6 +1547,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Mutation.ConfirmMember(childComplexity, args["key"].(string)), true
 
+	case "Mutation.deleteAccount":
+		if e.complexity.Mutation.DeleteAccount == nil {
+			break
+		}
+
+		args, err := ec.field_Mutation_deleteAccount_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Mutation.DeleteAccount(childComplexity, args["input"].(model.DeleteAccountInput)), true
+
 	case "Mutation.deleteDomain":
 		if e.complexity.Mutation.DeleteDomain == nil {
 			break
@@ -2993,6 +3007,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 		ec.unmarshalInputAnalyticsInput,
 		ec.unmarshalInputAuditLogInput,
 		ec.unmarshalInputCompleteRegisterInput,
+		ec.unmarshalInputDeleteAccountInput,
 		ec.unmarshalInputDomainInput,
 		ec.unmarshalInputGetAdminDomainInput,
 		ec.unmarshalInputGetAdminOrganizationsInput,
@@ -3296,6 +3311,17 @@ func (ec *executionContext) field_Mutation_confirmMember_args(ctx context.Contex
 	return args, nil
 }
 
+func (ec *executionContext) field_Mutation_deleteAccount_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNDeleteAccountInput2linksᚋapiᚋgraphᚋmodelᚐDeleteAccountInput)
+	if err != nil {
+		return nil, err
+	}
+	args["input"] = arg0
+	return args, nil
+}
+
 func (ec *executionContext) field_Mutation_deleteDomain_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
 	var err error
 	args := map[string]any{}
@@ -13025,6 +13051,91 @@ func (ec *executionContext) fieldContext_Mutation_renameTag(ctx context.Context,
 	return fc, nil
 }
 
+func (ec *executionContext) _Mutation_deleteAccount(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Mutation_deleteAccount(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		directive0 := func(rctx context.Context) (any, error) {
+			ctx = rctx // use context from middleware stack in children
+			return ec.resolvers.Mutation().DeleteAccount(rctx, fc.Args["input"].(model.DeleteAccountInput))
+		}
+
+		directive1 := func(ctx context.Context) (any, error) {
+			if ec.directives.Private == nil {
+				var zeroVal *model.DeletePayload
+				return zeroVal, errors.New("directive private is not implemented")
+			}
+			return ec.directives.Private(ctx, nil, directive0)
+		}
+
+		tmp, err := directive1(rctx)
+		if err != nil {
+			return nil, graphql.ErrorOnPath(ctx, err)
+		}
+		if tmp == nil {
+			return nil, nil
+		}
+		if data, ok := tmp.(*model.DeletePayload); ok {
+			return data, nil
+		}
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.DeletePayload`, tmp)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*model.DeletePayload)
+	fc.Result = res
+	return ec.marshalNDeletePayload2ᚖlinksᚋapiᚋgraphᚋmodelᚐDeletePayload(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Mutation_deleteAccount(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Mutation",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "success":
+				return ec.fieldContext_DeletePayload_success(ctx, field)
+			case "objectId":
+				return ec.fieldContext_DeletePayload_objectId(ctx, field)
+			case "message":
+				return ec.fieldContext_DeletePayload_message(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type DeletePayload", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Mutation_deleteAccount_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _Mutation_updateAdminOrg(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Mutation_updateAdminOrg(ctx, field)
 	if err != nil {
@@ -16919,16 +17030,16 @@ func (ec *executionContext) _Payment_orgId(ctx context.Context, field graphql.Co
 		directive1 := func(ctx context.Context) (any, error) {
 			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "BILLING")
 			if err != nil {
-				var zeroVal int
+				var zeroVal *int
 				return zeroVal, err
 			}
 			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
 			if err != nil {
-				var zeroVal int
+				var zeroVal *int
 				return zeroVal, err
 			}
 			if ec.directives.Access == nil {
-				var zeroVal int
+				var zeroVal *int
 				return zeroVal, errors.New("directive access is not implemented")
 			}
 			return ec.directives.Access(ctx, obj, directive0, scope, kind)
@@ -16941,24 +17052,21 @@ func (ec *executionContext) _Payment_orgId(ctx context.Context, field graphql.Co
 		if tmp == nil {
 			return nil, nil
 		}
-		if data, ok := tmp.(int); ok {
+		if data, ok := tmp.(*int); ok {
 			return data, nil
 		}
-		return nil, fmt.Errorf(`unexpected type %T from directive, should be int`, tmp)
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be *int`, tmp)
 	})
 	if err != nil {
 		ec.Error(ctx, err)
 		return graphql.Null
 	}
 	if resTmp == nil {
-		if !graphql.HasFieldError(ctx, fc) {
-			ec.Errorf(ctx, "must not be null")
-		}
 		return graphql.Null
 	}
-	res := resTmp.(int)
+	res := resTmp.(*int)
 	fc.Result = res
-	return ec.marshalNInt2int(ctx, field.Selections, res)
+	return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_Payment_orgId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -16995,16 +17103,16 @@ func (ec *executionContext) _Payment_orgSlug(ctx context.Context, field graphql.
 		directive1 := func(ctx context.Context) (any, error) {
 			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "BILLING")
 			if err != nil {
-				var zeroVal string
+				var zeroVal *string
 				return zeroVal, err
 			}
 			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
 			if err != nil {
-				var zeroVal string
+				var zeroVal *string
 				return zeroVal, err
 			}
 			if ec.directives.Access == nil {
-				var zeroVal string
+				var zeroVal *string
 				return zeroVal, errors.New("directive access is not implemented")
 			}
 			return ec.directives.Access(ctx, obj, directive0, scope, kind)
@@ -17017,24 +17125,21 @@ func (ec *executionContext) _Payment_orgSlug(ctx context.Context, field graphql.
 		if tmp == nil {
 			return nil, nil
 		}
-		if data, ok := tmp.(string); ok {
+		if data, ok := tmp.(*string); ok {
 			return data, nil
 		}
-		return nil, fmt.Errorf(`unexpected type %T from directive, should be string`, tmp)
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be *string`, tmp)
 	})
 	if err != nil {
 		ec.Error(ctx, err)
 		return graphql.Null
 	}
 	if resTmp == nil {
-		if !graphql.HasFieldError(ctx, fc) {
-			ec.Errorf(ctx, "must not be null")
-		}
 		return graphql.Null
 	}
-	res := resTmp.(string)
+	res := resTmp.(*string)
 	fc.Result = res
-	return ec.marshalNString2string(ctx, field.Selections, res)
+	return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) fieldContext_Payment_orgSlug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -25225,6 +25330,33 @@ func (ec *executionContext) unmarshalInputCompleteRegisterInput(ctx context.Cont
 	return it, nil
 }
 
+func (ec *executionContext) unmarshalInputDeleteAccountInput(ctx context.Context, obj any) (model.DeleteAccountInput, error) {
+	var it model.DeleteAccountInput
+	asMap := map[string]any{}
+	for k, v := range obj.(map[string]any) {
+		asMap[k] = v
+	}
+
+	fieldsInOrder := [...]string{"password"}
+	for _, k := range fieldsInOrder {
+		v, ok := asMap[k]
+		if !ok {
+			continue
+		}
+		switch k {
+		case "password":
+			ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password"))
+			data, err := ec.unmarshalNString2string(ctx, v)
+			if err != nil {
+				return it, err
+			}
+			it.Password = data
+		}
+	}
+
+	return it, nil
+}
+
 func (ec *executionContext) unmarshalInputDomainInput(ctx context.Context, obj any) (model.DomainInput, error) {
 	var it model.DomainInput
 	asMap := map[string]any{}
@@ -28827,6 +28959,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
 			if out.Values[i] == graphql.Null {
 				out.Invalids++
 			}
+		case "deleteAccount":
+			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
+				return ec._Mutation_deleteAccount(ctx, field)
+			})
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
 		case "updateAdminOrg":
 			out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
 				return ec._Mutation_updateAdminOrg(ctx, field)
@@ -29719,14 +29858,8 @@ func (ec *executionContext) _Payment(ctx context.Context, sel ast.SelectionSet,
 			}
 		case "orgId":
 			out.Values[i] = ec._Payment_orgId(ctx, field, obj)
-			if out.Values[i] == graphql.Null {
-				out.Invalids++
-			}
 		case "orgSlug":
 			out.Values[i] = ec._Payment_orgSlug(ctx, field, obj)
-			if out.Values[i] == graphql.Null {
-				out.Invalids++
-			}
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -31737,6 +31870,11 @@ func (ec *executionContext) marshalNCursor2linksᚋapiᚋgraphᚋmodelᚐCursor(
 	return v
 }
 
+func (ec *executionContext) unmarshalNDeleteAccountInput2linksᚋapiᚋgraphᚋmodelᚐDeleteAccountInput(ctx context.Context, v any) (model.DeleteAccountInput, error) {
+	res, err := ec.unmarshalInputDeleteAccountInput(ctx, v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
 func (ec *executionContext) marshalNDeletePayload2linksᚋapiᚋgraphᚋmodelᚐDeletePayload(ctx context.Context, sel ast.SelectionSet, v model.DeletePayload) graphql.Marshaler {
 	return ec._DeletePayload(ctx, sel, &v)
 }
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index c70c0ee..79ddf39 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -138,6 +138,10 @@ type CompleteRegisterInput struct {
 	Key      string `json:"key"`
 }
 
+type DeleteAccountInput struct {
+	Password string `json:"password"`
+}
+
 type DeletePayload struct {
 	Success  bool    `json:"success"`
 	ObjectID string  `json:"objectId"`
@@ -381,8 +385,8 @@ type Payment struct {
 	AmountPaid       int       `json:"amountPaid"`
 	AmountNet        int       `json:"amountNet"`
 	PaymentFee       int       `json:"paymentFee"`
-	OrgID            int       `json:"orgId"`
-	OrgSlug          string    `json:"orgSlug"`
+	OrgID            *int      `json:"orgId,omitempty"`
+	OrgSlug          *string   `json:"orgSlug,omitempty"`
 }
 
 type PaymentCursor struct {
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index f590b98..a4d3713 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -208,8 +208,8 @@ type Payment {
     amountPaid: Int!
     amountNet: Int! @admin
     paymentFee: Int!
-    orgId: Int! @access(scope: BILLING, kind: RO)
-    orgSlug: String! @access(scope: BILLING, kind: RO)
+    orgId: Int @access(scope: BILLING, kind: RO)
+    orgSlug: String @access(scope: BILLING, kind: RO)
 }
 
 type HTMLMeta {
@@ -939,6 +939,10 @@ type Query {
     getAdminDomains(input: GetAdminDomainInput): DomainCursor! @admin
 }
 
+input DeleteAccountInput {
+    password: String!
+}
+
 type Mutation {
     "Create a new organization"
     addOrganization(input: OrganizationInput!): Organization! @access(scope: ORGS, kind: RW)
@@ -995,6 +999,9 @@ type Mutation {
     deleteTag(input: TagInput!): DeletePayload! @access(scope: ORGS, kind: RW)
     renameTag(input: TagInput!): OperationPayload! @access(scope: ORGS, kind: RW)
 
+    "Delete the authenticated user's account permanently. Requires password verification."
+    deleteAccount(input: DeleteAccountInput!): DeletePayload! @private
+
     #
     # Admin only. Not open to public calls
     #
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 9610a1d..3dc1210 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -4479,6 +4479,77 @@ func (r *mutationResolver) RenameTag(ctx context.Context, input model.TagInput)
 	return payload, nil
 }
 
+func (r *mutationResolver) DeleteAccount(ctx context.Context, input model.DeleteAccountInput) (*model.DeletePayload, error) {
+	tokenUser := oauth2.ForContext(ctx)
+	if tokenUser == nil {
+		return nil, valid.ErrAuthorization
+	}
+	if tokenUser.Token.ClientID != "" {
+		return nil, fmt.Errorf("Account deletion not allowed via OAuth2 client")
+	}
+	user := tokenUser.User.(*models.User)
+	c := server.EchoForContext(ctx)
+	lang := links.GetLangFromRequest(c.Request(), user)
+	lt := localizer.GetLocalizer(lang)
+
+	validator := valid.New(ctx)
+
+	match, err := user.ComparePassword(input.Password)
+	if err != nil || !match {
+		validator.Error("%s", lt.Translate("The password you entered is incorrect.")).
+			WithField("password").WithCode(valid.ErrValidationCode)
+		return nil, nil
+	}
+
+	opts := &database.FilterOptions{
+		Filter: sq.Eq{"o.owner_id": user.ID},
+	}
+	ownedOrgs, err := models.GetOrganizations(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+	orgIDs := make([]int, len(ownedOrgs))
+	for i, o := range ownedOrgs {
+		orgIDs[i] = o.ID
+	}
+
+	subOpts := &database.FilterOptions{
+		Filter: sq.And{
+			sq.Eq{"s.org_id": orgIDs},
+			sq.Eq{"s.is_active": true},
+		},
+		Limit: 1,
+	}
+	subs, err := models.GetSubscriptions(ctx, subOpts)
+	if err != nil {
+		return nil, err
+	}
+	if len(subs) > 0 {
+		validator.Error("%s", lt.Translate("Please cancel all active subscriptions before deleting your account.")).
+			WithCode(valid.ErrValidationCode)
+		return nil, nil
+	}
+
+	baseURLIDs, err := models.GetBaseURLIDsForOrgs(ctx, orgIDs)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := user.Delete(ctx); err != nil {
+		return nil, err
+	}
+
+	if err := models.BulkUpdateCounters(ctx, baseURLIDs); err != nil {
+		srv := server.ForContext(ctx)
+		srv.Logger().Printf("Error updating base_url counters after account deletion: %v", err)
+	}
+
+	return &model.DeletePayload{
+		Success:  true,
+		ObjectID: fmt.Sprintf("%d", user.ID),
+	}, nil
+}
+
 // UpdateAdminOrgType is the resolver for the updateAdminOrgType field.
 func (r *mutationResolver) UpdateAdminOrg(ctx context.Context, input model.AdminUpdateOrgInput) (*models.Organization, error) {
 	tokenUser := oauth2.ForContext(ctx)
@@ -5126,8 +5197,8 @@ func (r *qRCodeResolver) ImageURL(ctx context.Context, obj *models.QRCode) (*str
 func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
 	return &model.Version{
 		Major:           0,
-		Minor:           11,
-		Patch:           3,
+		Minor:           12,
+		Patch:           0,
 		DeprecationDate: nil,
 	}, nil
 }
@@ -5364,8 +5435,13 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP
 			p.AmountPaid = i.AmountPaid
 			p.AmountNet = i.AmountNet
 			p.PaymentFee = i.PaymentFee
-			p.OrgSlug = i.OrgSlug
-			p.OrgID = i.OrgID
+			if i.OrgSlug.Valid {
+				p.OrgSlug = &i.OrgSlug.String
+			}
+			if i.OrgID.Valid {
+				orgID := int(i.OrgID.Int64)
+				p.OrgID = &orgID
+			}
 		}
 		payments = append(payments, p)
 	}
diff --git a/billing/processors.go b/billing/processors.go
index d73a599..5ff086e 100644
--- a/billing/processors.go
+++ b/billing/processors.go
@@ -2,6 +2,7 @@ package billing
 
 import (
 	"context"
+	"database/sql"
 	"fmt"
 	"links"
 	"links/accounts"
@@ -73,8 +74,9 @@ func ProcessCheckoutSessionCompletedTask(ctx context.Context, conf *config.Confi
 	stripeSub, err := stripeClient.Subscriptions.Get(subID, nil)
 	plan := plans[0]
 	subscription := &models.Subscription{
-		UserID:             org.OwnerID, // NOTE we assume that always the owner will pay a subscription
-		OrgID:              org.ID,
+		// assume that owner always pays for a subscription
+		UserID:             sql.NullInt64{Int64: int64(org.OwnerID), Valid: true},
+		OrgID:              sql.NullInt64{Int64: int64(org.ID), Valid: true},
 		StripeID:           subID,
 		SubscriptionPlanID: plan.ID,
 		StartDate:          time.Unix(stripeSub.CurrentPeriodStart, 0),
@@ -273,7 +275,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
 	}
 
 	opts = &database.FilterOptions{
-		Filter: sq.Eq{"o.id": sub.OrgID},
+		Filter: sq.Eq{"o.id": sub.OrgID.Int64},
 		Limit:  1,
 	}
 	orgs, err := models.GetOrganizations(ctx, opts)
@@ -322,7 +324,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
 	// Disable domain
 	opts = &database.FilterOptions{
 		Filter: sq.And{
-			sq.Eq{"org_id": sub.OrgID},
+			sq.Eq{"org_id": sub.OrgID.Int64},
 			sq.Eq{"level": models.DomainLevelUser},
 		},
 	}
@@ -334,7 +336,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
 	// Disable listing
 	opts = &database.FilterOptions{
 		Filter: sq.And{
-			sq.Eq{"l.org_id": sub.OrgID},
+			sq.Eq{"l.org_id": sub.OrgID.Int64},
 			sq.Eq{"d.level": models.DomainLevelSystem},
 		},
 		OrderBy: "l.created_on ASC",
@@ -345,7 +347,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
 		return err
 	}
 	opts = &database.FilterOptions{
-		Filter: sq.Eq{"org_id": sub.OrgID},
+		Filter: sq.Eq{"org_id": sub.OrgID.Int64},
 	}
 	if len(listings) > 0 {
 		opts.Filter = sq.And{
@@ -361,7 +363,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
 	// Disable organization members
 	opts = &database.FilterOptions{
 		Filter: sq.And{
-			sq.Eq{"org_id": sub.OrgID},
+			sq.Eq{"org_id": sub.OrgID.Int64},
 			sq.Eq{"is_active": true},
 		},
 	}
@@ -384,7 +386,7 @@ func ProcessSubscriptionDeletedTask(ctx context.Context, srv *server.Server, dat
 
 	// Send email
 	opts = &database.FilterOptions{
-		Filter: sq.Eq{"u.id": sub.UserID},
+		Filter: sq.Eq{"u.id": sub.UserID.Int64},
 		Limit:  1,
 	}
 	users, err := models.GetUsers(ctx, opts)
@@ -479,7 +481,7 @@ func ProcessSubscriptionUpdatedTask(ctx context.Context, srv *server.Server, dat
 	// The subscription was marked to be cancelled at the end of its period
 	if sub.CancelAtEnd {
 		opts = &database.FilterOptions{
-			Filter: sq.Eq{"u.id": sub.UserID},
+			Filter: sq.Eq{"u.id": sub.UserID.Int64},
 			Limit:  1,
 		}
 		users, err := models.GetUsers(ctx, opts)
@@ -509,7 +511,7 @@ func ProcessSubscriptionUpdatedTask(ctx context.Context, srv *server.Server, dat
 		}
 
 		opts = &database.FilterOptions{
-			Filter: sq.Eq{"o.id": sub.OrgID},
+			Filter: sq.Eq{"o.id": sub.OrgID.Int64},
 			Limit:  1,
 		}
 		orgs, err := models.GetOrganizations(ctx, opts)
diff --git a/billing/routes_test.go b/billing/routes_test.go
index 3082be4..f9a9c2e 100644
--- a/billing/routes_test.go
+++ b/billing/routes_test.go
@@ -1,6 +1,7 @@
 package billing_test
 
 import (
+	"database/sql"
 	"links"
 	"links/billing"
 	"links/cmd"
@@ -130,8 +131,8 @@ func TestHandlers(t *testing.T) {
 		c.Equal(http.StatusMovedPermanently, recorder.Code)
 
 		subscription := &models.Subscription{
-			UserID:             1,
-			OrgID:              1,
+			UserID:             sql.NullInt64{Int64: 1, Valid: true},
+			OrgID:              sql.NullInt64{Int64: 1, Valid: true},
 			StripeID:           "stripe",
 			SubscriptionPlanID: 1,
 			StartDate:          time.Now(),
diff --git a/cmd/migrations.go b/cmd/migrations.go
index 8e7c71a..bdb131d 100644
--- a/cmd/migrations.go
+++ b/cmd/migrations.go
@@ -87,5 +87,12 @@ func GetMigrations() []migrate.Migration {
 			0,
 			links.MigrateFS,
 		),
+		migrate.FSFileMigration(
+			"0010_preserve_billing_on_delete",
+			"migrations/0010_preserve_billing_on_delete.up.sql",
+			"migrations/0010_preserve_billing_on_delete.down.sql",
+			0,
+			links.MigrateFS,
+		),
 	}
 }
diff --git a/migrations/0010_preserve_billing_on_delete.down.sql b/migrations/0010_preserve_billing_on_delete.down.sql
new file mode 100644
index 0000000..72dfd59
--- /dev/null
+++ b/migrations/0010_preserve_billing_on_delete.down.sql
@@ -0,0 +1,21 @@
+-- Reverse: delete orphaned rows first, then restore NOT NULL + CASCADE
+DELETE FROM invoices WHERE user_id IS NULL OR org_id IS NULL;
+DELETE FROM subscriptions WHERE user_id IS NULL OR org_id IS NULL;
+
+ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_user_id_fkey;
+ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_user_id_fkey
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE subscriptions ALTER COLUMN user_id SET NOT NULL;
+ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_org_id_fkey;
+ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_org_id_fkey
+    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE CASCADE;
+ALTER TABLE subscriptions ALTER COLUMN org_id SET NOT NULL;
+
+ALTER TABLE invoices DROP CONSTRAINT invoices_user_id_fkey;
+ALTER TABLE invoices ADD CONSTRAINT invoices_user_id_fkey
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
+ALTER TABLE invoices ALTER COLUMN user_id SET NOT NULL;
+ALTER TABLE invoices DROP CONSTRAINT invoices_org_id_fkey;
+ALTER TABLE invoices ADD CONSTRAINT invoices_org_id_fkey
+    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE CASCADE;
+ALTER TABLE invoices ALTER COLUMN org_id SET NOT NULL;
diff --git a/migrations/0010_preserve_billing_on_delete.up.sql b/migrations/0010_preserve_billing_on_delete.up.sql
new file mode 100644
index 0000000..aa00f87
--- /dev/null
+++ b/migrations/0010_preserve_billing_on_delete.up.sql
@@ -0,0 +1,19 @@
+-- subscriptions: make user_id and org_id nullable, change FK to SET NULL
+ALTER TABLE subscriptions ALTER COLUMN user_id DROP NOT NULL;
+ALTER TABLE subscriptions ALTER COLUMN org_id DROP NOT NULL;
+ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_user_id_fkey;
+ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_user_id_fkey
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL;
+ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_org_id_fkey;
+ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_org_id_fkey
+    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE SET NULL;
+
+-- invoices: make user_id and org_id nullable, change FK to SET NULL
+ALTER TABLE invoices ALTER COLUMN user_id DROP NOT NULL;
+ALTER TABLE invoices ALTER COLUMN org_id DROP NOT NULL;
+ALTER TABLE invoices DROP CONSTRAINT invoices_user_id_fkey;
+ALTER TABLE invoices ADD CONSTRAINT invoices_user_id_fkey
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL;
+ALTER TABLE invoices DROP CONSTRAINT invoices_org_id_fkey;
+ALTER TABLE invoices ADD CONSTRAINT invoices_org_id_fkey
+    FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE SET NULL;
diff --git a/models/audit_log.go b/models/audit_log.go
index 4327625..17670af 100644
--- a/models/audit_log.go
+++ b/models/audit_log.go
@@ -13,6 +13,7 @@ const (
 	LOG_ACCT_EMAIL_CONF      = "account_email_confirmation"
 	LOG_ACCT_PASSWORD_CHANGE = "account_password_change"
 	LOG_ACCT_PASSWORD_RESET  = "account_password_reset"
+	LOG_ACCT_DELETED         = "account_deleted"
 
 	LOG_PROFILE_UPDATED = "profile_updated"
 
diff --git a/models/base_url.go b/models/base_url.go
index 140fdd0..926a75d 100644
--- a/models/base_url.go
+++ b/models/base_url.go
@@ -11,6 +11,7 @@ import (
 	"time"
 
 	sq "github.com/Masterminds/squirrel"
+	"github.com/lib/pq"
 	"github.com/segmentio/ksuid"
 	"netlandish.com/x/gobwebs/database"
 	"netlandish.com/x/gobwebs/timezone"
@@ -258,3 +259,53 @@ func (b *BaseURL) QueryParams() url.Values {
 func (b *BaseURL) GetID() int {
 	return b.ID
 }
+
+// GetBaseURLIDsForOrgs returns unique base_url_ids referenced by org_links
+// belonging to the given organization IDs
+func GetBaseURLIDsForOrgs(ctx context.Context, orgIDs []int) ([]int, error) {
+	if len(orgIDs) == 0 {
+		return nil, nil
+	}
+	var ids []int
+	err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
+		rows, err := sq.Select("DISTINCT base_url_id").
+			From("org_links").
+			Where(sq.Eq{"org_id": orgIDs}).
+			Where(sq.NotEq{"base_url_id": nil}).
+			PlaceholderFormat(database.GetPlaceholderFormat()).
+			RunWith(tx).
+			QueryContext(ctx)
+		if err != nil {
+			return err
+		}
+		defer rows.Close()
+		for rows.Next() {
+			var id int
+			if err := rows.Scan(&id); err != nil {
+				return err
+			}
+			ids = append(ids, id)
+		}
+		return nil
+	})
+	return ids, err
+}
+
+// BulkUpdateCounters recalculates the counter for the given base_url IDs
+// by counting PUBLIC org_links that reference each base_url
+func BulkUpdateCounters(ctx context.Context, ids []int) error {
+	if len(ids) == 0 {
+		return nil
+	}
+	query := `
+		UPDATE base_urls SET counter = (
+			SELECT count(*) FROM org_links
+			WHERE base_url_id = base_urls.id AND visibility = 'PUBLIC'
+		), updated_on = now()
+		WHERE id = ANY($1)
+	`
+	return database.WithTx(ctx, nil, func(tx *sql.Tx) error {
+		_, err := tx.ExecContext(ctx, query, pq.Array(ids))
+		return err
+	})
+}
diff --git a/models/invoice.go b/models/invoice.go
index bf98547..8da73e0 100644
--- a/models/invoice.go
+++ b/models/invoice.go
@@ -32,7 +32,7 @@ func GetInvoices(ctx context.Context, opts *database.FilterOptions) ([]*Invoice,
 				"i.amount_paid", "i.amount_net", "i.amount_refunded", "i.payment_fee", "i.hosted_invoice_url", "i.created_on",
 				"i.updated_on", "o.slug").
 			From("invoices i").
-			Join("organizations o ON o.id = i.org_id").
+			LeftJoin("organizations o ON o.id = i.org_id").
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
 			QueryContext(ctx)
@@ -196,7 +196,7 @@ func GetInvoicesTotal(ctx context.Context, opts *database.FilterOptions) (map[st
 			Columns("COALESCE(SUM(i.amount), 0)", "COALESCE(SUM(i.payment_fee), 0)",
 				"COALESCE(SUM(i.amount_net), 0)", "COALESCE(SUM(i.amount_refunded), 0)").
 			From("invoices i").
-			Join("organizations o ON o.id = i.org_id").
+			LeftJoin("organizations o ON o.id = i.org_id").
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
 			QueryContext(ctx)
diff --git a/models/models.go b/models/models.go
index 87ea649..0113e44 100644
--- a/models/models.go
+++ b/models/models.go
@@ -402,39 +402,39 @@ type SubscriptionPlan struct {
 }
 
 type Subscription struct {
-	ID                 int       `db:"id"`
-	UserID             int       `db:"user_id"`
-	OrgID              int       `db:"org_id"`
-	StripeID           string    `db:"stripe_id"`
-	SubscriptionPlanID int       `db:"subscription_plan_id"`
-	StartDate          time.Time `db:"start_date"`
-	EndDate            time.Time `db:"end_date"`
-	CancelAtEnd        bool      `db:"cancel_end"`
-	IsActive           bool      `db:"is_active"`
-	CreatedOn          time.Time `db:"created_on"`
-	UpdatedOn          time.Time `db:"updated_on"`
+	ID                 int           `db:"id"`
+	UserID             sql.NullInt64 `db:"user_id"`
+	OrgID              sql.NullInt64 `db:"org_id"`
+	StripeID           string        `db:"stripe_id"`
+	SubscriptionPlanID int           `db:"subscription_plan_id"`
+	StartDate          time.Time     `db:"start_date"`
+	EndDate            time.Time     `db:"end_date"`
+	CancelAtEnd        bool          `db:"cancel_end"`
+	IsActive           bool          `db:"is_active"`
+	CreatedOn          time.Time     `db:"created_on"`
+	UpdatedOn          time.Time     `db:"updated_on"`
 
 	PlanName sql.NullString `db:"-"`
 }
 
 type Invoice struct {
-	ID               int       `db:"id"`
-	UserID           int       `db:"user_id"`
-	Status           string    `db:"status"`
-	OrgID            int       `db:"org_id"`
-	SubscriptionID   int       `db:"subscription_id"`
-	StripeID         string    `db:"stripe_id"`
-	Currency         string    `db:"currency"`
-	Amount           int       `db:"amount"`
-	AmountPaid       int       `db:"amount_paid"`
-	AmountNet        int       `db:"amount_net"`
-	AmountRefunded   int       `db:"amount_net"`
-	PaymentFee       int       `db:"payment_fee"`
-	HostedInvoiceURL string    `db:"hosted_invoice_url"`
-	CreatedOn        time.Time `db:"created_on"`
-	UpdatedOn        time.Time `db:"updated_on"`
+	ID               int            `db:"id"`
+	UserID           sql.NullInt64  `db:"user_id"`
+	Status           string         `db:"status"`
+	OrgID            sql.NullInt64  `db:"org_id"`
+	SubscriptionID   int            `db:"subscription_id"`
+	StripeID         string         `db:"stripe_id"`
+	Currency         string         `db:"currency"`
+	Amount           int            `db:"amount"`
+	AmountPaid       int            `db:"amount_paid"`
+	AmountNet        int            `db:"amount_net"`
+	AmountRefunded   int            `db:"amount_net"`
+	PaymentFee       int            `db:"payment_fee"`
+	HostedInvoiceURL string         `db:"hosted_invoice_url"`
+	CreatedOn        time.Time      `db:"created_on"`
+	UpdatedOn        time.Time      `db:"updated_on"`
 
-	OrgSlug string `db:"-"`
+	OrgSlug sql.NullString `db:"-"`
 }
 
 type Follower struct {
diff --git a/models/user.go b/models/user.go
index 64f2c6f..4aa3a7c 100644
--- a/models/user.go
+++ b/models/user.go
@@ -229,6 +229,21 @@ func (u *User) WritePassword(ctx context.Context) error {
 	return err
 }
 
+// Delete removes the user from the database
+func (u *User) Delete(ctx context.Context) error {
+	if u.ID == 0 {
+		return fmt.Errorf("User object is not populated")
+	}
+	return database.WithTx(ctx, nil, func(tx *sql.Tx) error {
+		_, err := sq.Delete("users").
+			Where("id = ?", u.ID).
+			PlaceholderFormat(database.GetPlaceholderFormat()).
+			RunWith(tx).
+			ExecContext(ctx)
+		return err
+	})
+}
+
 // GetOrgs ...
 func (u *User) GetOrgs(ctx context.Context, perm string) ([]*Organization, error) {
 	if u.ID == 0 {
diff --git a/templates/delete_account.html b/templates/delete_account.html
new file mode 100644
index 0000000..9fdb5dd
--- /dev/null
+++ b/templates/delete_account.html
@@ -0,0 +1,40 @@
+{{template "base" .}}
+{{ define "title" }}{{ .pd.Data.delete_account }}{{ end }}
+<section class="app-header">
+  <h1 class="app-header__title">{{.pd.Data.delete_account}}</h1>
+  <a class="button primary is-small" href="{{reverse "accounts:settings"}}">{{.pd.Data.cancel}}</a>
+</section>
+
+<section class="card shadow-card">
+  <div class="alert alert-error mb-2">
+    <p><strong>{{.pd.Data.warning}}</strong></p>
+    <p>{{.pd.Data.warning_detail}}</p>
+  </div>
+  <form id="delete-account-form" method="POST" action="{{ reverse "accounts:delete_account_post"}}">
+    <input type="hidden" name="csrf" value="{{ .CSRF }}">
+    {{ if .errors._global_ -}}
+    {{- range .errors._global_ -}}
+    <p class="error">{{ . }}</p>
+    {{- end }}
+    {{- end }}
+    <div>
+      <label>{{.pd.Data.password}}</label>
+      <input type="password" name="password" required>
+      {{ with .errors.Password }}
+      <p class="error">{{ . }}</p>
+      {{ end }}
+    </div>
+    <div>
+      <label>{{.pd.Data.confirmation_label}}</label>
+      <input type="text" name="confirmation" required autocomplete="off">
+      {{ with .errors.Confirmation }}
+      <p class="error">{{ . }}</p>
+      {{ end }}
+    </div>
+  </form>
+  <footer class="is-right">
+    <button class="button error" form="delete-account-form" type="submit">{{.pd.Data.submit}}</button>
+    <a class="button dark" href="{{reverse "accounts:settings"}}">{{.pd.Data.cancel}}</a>
+  </footer>
+</section>
+{{template "base_footer" .}}
diff --git a/templates/settings.html b/templates/settings.html
index cc3f0cd..137f0d8 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -118,4 +118,10 @@
     </table>
 
 </section>
+
+<section class="card shadow-card mt-2">
+  <h3 class="text-error">{{.pd.Data.danger_zone}}</h3>
+  <p>{{.pd.Data.danger_zone_detail}}</p>
+  <a class="button error is-small" href="{{reverse "accounts:delete_account"}}">{{.pd.Data.delete_account}}</a>
+</section>
 {{template "base_footer" .}}
-- 
2.52.0