-
Notifications
You must be signed in to change notification settings - Fork 45
feat(deleter): serve CheckOrganizationDelete for delete eligibility #1894
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -517,41 +517,10 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error { | |
| return err | ||
| } | ||
|
|
||
| // each plan resolves at most once per call, and only when a running | ||
| // subscription actually references it | ||
| paidPlans := map[string]bool{} | ||
|
|
||
| var blockers []Blocker | ||
| for _, c := range customers { | ||
| if !c.IsOffline() { | ||
| bs, err := d.subscriptionBlockers(ctx, c, paidPlans) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| blockers = append(blockers, bs...) | ||
|
|
||
| bs, err = d.invoiceBlockers(ctx, c) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| blockers = append(blockers, bs...) | ||
| } | ||
|
|
||
| balance, err := d.creditService.GetBalance(ctx, c.ID) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err) | ||
| } | ||
| // the balance goes below zero when the account has an overdraft | ||
| // floor (credit_min under zero, the postpaid setup) and tokens were | ||
| // spent on credit. That debt is money owed, so it must be settled | ||
| // before the org can go | ||
| if balance < 0 { | ||
| blockers = append(blockers, Blocker{ | ||
| Type: BlockerNegativeTokenBalance, | ||
| Subject: c.ID, | ||
| Message: fmt.Sprintf("billing account[%s] owes %d tokens: contact support to settle the balance, then retry the delete", c.ID, -balance), | ||
| }) | ||
| } | ||
| // the delete is about to happen, so judge invoices on fresh provider data | ||
| blockers, err := d.collectBlockers(ctx, customers, true) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if len(blockers) > 0 { | ||
| return &BlockedError{OrgID: id, Blockers: blockers} | ||
|
|
@@ -563,6 +532,7 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error { | |
| // runs only after every blocker is clear and never touches a paid | ||
| // subscription, so a blocked delete never costs the caller a plan they | ||
| // pay for | ||
| paidPlans := map[string]bool{} | ||
| for _, c := range customers { | ||
| if c.IsOffline() { | ||
| continue | ||
|
|
@@ -594,7 +564,7 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error { | |
| canceled = true | ||
| } | ||
| if canceled { | ||
| bs, err := d.invoiceBlockers(ctx, c) | ||
| bs, err := d.invoiceBlockers(ctx, c, true) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
@@ -607,6 +577,69 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error { | |
| return nil | ||
| } | ||
|
|
||
| // CheckOrganizationDelete reports everything that currently blocks deleting | ||
| // the organization, without changing anything: no subscription gets canceled | ||
| // and the invoice rows are read as they are (they sync from the provider on | ||
| // a timer). The answer is advisory — DeleteOrganization re-checks against | ||
| // fresh provider data before actually deleting. | ||
| func (d Service) CheckOrganizationDelete(ctx context.Context, id string) ([]Blocker, error) { | ||
| if _, err := d.orgService.GetRaw(ctx, id); err != nil { | ||
| return nil, err | ||
| } | ||
| customers, err := d.customerService.List(ctx, customer.Filter{ | ||
| OrgID: id, | ||
| }) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return d.collectBlockers(ctx, customers, false) | ||
| } | ||
|
|
||
| // collectBlockers gathers the blockers across the org's billing accounts: | ||
| // running subscriptions on a paid plan, invoices that still ask for money, | ||
| // and token debt. fromProvider judges the invoices on data read straight | ||
| // from the billing provider instead of the local rows. | ||
| func (d Service) collectBlockers(ctx context.Context, customers []customer.Customer, fromProvider bool) ([]Blocker, error) { | ||
| // each plan resolves at most once per call, and only when a running | ||
| // subscription actually references it | ||
| paidPlans := map[string]bool{} | ||
|
|
||
| var blockers []Blocker | ||
| for _, c := range customers { | ||
| if !c.IsOffline() { | ||
| bs, err := d.subscriptionBlockers(ctx, c, paidPlans) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| blockers = append(blockers, bs...) | ||
|
|
||
| bs, err = d.invoiceBlockers(ctx, c, fromProvider) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| blockers = append(blockers, bs...) | ||
| } | ||
|
|
||
| balance, err := d.creditService.GetBalance(ctx, c.ID) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err) | ||
| } | ||
| // the balance goes below zero when the account has an overdraft | ||
| // floor (credit_min under zero, the postpaid setup) and tokens were | ||
| // spent on credit. That debt is money owed, so it must be settled | ||
| // before the org can go | ||
| if balance < 0 { | ||
| blockers = append(blockers, Blocker{ | ||
| Type: BlockerNegativeTokenBalance, | ||
| Subject: c.ID, | ||
| SubjectType: SubjectBillingAccount, | ||
| Message: fmt.Sprintf("billing account[%s] owes %d tokens: contact support to settle the balance, then retry the delete", c.ID, -balance), | ||
| }) | ||
| } | ||
| } | ||
| return blockers, nil | ||
| } | ||
|
|
||
| // subscriptionBlockers returns a blocker for every running subscription on a | ||
| // paid plan; the caller downgrades those to the standard plan. Running | ||
| // free-plan subscriptions are not blockers, the delete cancels them itself. | ||
|
|
@@ -633,9 +666,10 @@ func (d Service) subscriptionBlockers(ctx context.Context, c customer.Customer, | |
|
|
||
| func paidSubscriptionBlocker(sub subscription.Subscription) Blocker { | ||
| return Blocker{ | ||
| Type: BlockerActiveSubscription, | ||
| Subject: sub.ID, | ||
| Message: fmt.Sprintf("subscription[%s] is %s on a paid plan: downgrade to the standard plan, then retry the delete", sub.ID, sub.State), | ||
| Type: BlockerActiveSubscription, | ||
| Subject: sub.ID, | ||
| SubjectType: SubjectSubscription, | ||
| Message: fmt.Sprintf("subscription[%s] is %s on a paid plan: downgrade to the standard plan, then retry the delete", sub.ID, sub.State), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -661,14 +695,29 @@ func (d Service) isPaidPlan(ctx context.Context, planID string, cache map[string | |
| // still asks for money. Open and uncollectible invoices the caller can pay. | ||
| // A draft is money the provider is still preparing to charge — the provider | ||
| // finalizes it shortly — and deleting before that would silently lose the | ||
| // charge, so it blocks too. The answer comes straight from the billing | ||
| // provider, so a just-paid invoice does not block and a just-created one | ||
| // does. | ||
| func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Blocker, error) { | ||
| invoices, err := d.invoiceService.ListPayableOnProvider(ctx, c) | ||
| // charge, so it blocks too. | ||
| // | ||
| // fromProvider reads the invoices straight from the billing provider, so a | ||
| // just-paid invoice does not block a delete and a just-created one does. | ||
| // Without it the local rows answer, cheap enough for every check call. | ||
| func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer, fromProvider bool) ([]Blocker, error) { | ||
| var invoices []invoice.Invoice | ||
| var err error | ||
| if fromProvider { | ||
| invoices, err = d.invoiceService.ListPayableOnProvider(ctx, c) | ||
| } else { | ||
| var all []invoice.Invoice | ||
| all, err = d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID, NonZeroOnly: true}) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The check path reads local rows with NonZeroOnly (amount > 0), while the delete path (ListPayableOnProvider) keeps any invoice whose Total is not 0. So the two disagree on a negative-total invoice: the check drops it and reports can_delete, but the real delete keeps it and blocks. The client greys nothing, the user clicks delete, and the delete is refused, which is the opposite of what the check promised. The root filter is in #1857 (Total == 0). Keying both off the amount actually due would keep the check and the delete in step. |
||
| for _, inv := range all { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This ranges over all before the err from List is checked a few lines down. On the usual (nil result, err) contract it is a harmless no-op, but if List ever returns partial rows alongside an error, those rows get filtered before the error surfaces. Move the err check above the loop, like the provider branch does. |
||
| if inv.State == invoice.DraftState || inv.State == invoice.OpenState || inv.State == invoice.UncollectibleState { | ||
| invoices = append(invoices, inv) | ||
| } | ||
| } | ||
| } | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err) | ||
| } | ||
|
|
||
| var blockers []Blocker | ||
| for _, inv := range invoices { | ||
| // an invoice the sync has not stored yet carries no local id | ||
|
|
@@ -681,9 +730,10 @@ func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Bl | |
| message = fmt.Sprintf("invoice[%s] is still being prepared by the billing provider: retry the delete once it finalizes, then pay it", subject) | ||
| } | ||
| blockers = append(blockers, Blocker{ | ||
| Type: BlockerUnpaidInvoice, | ||
| Subject: subject, | ||
| Message: message, | ||
| Type: BlockerUnpaidInvoice, | ||
| Subject: subject, | ||
| SubjectType: SubjectInvoice, | ||
| Message: message, | ||
| }) | ||
| } | ||
| return blockers, nil | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
collectBlockers now keeps its own paidPlans map and this cancel loop starts a fresh one, so each free-plan subscription's plan is resolved twice per delete: once while collecting blockers, once here. It is minor, and partly the cost of making collectBlockers self-contained for the check path, but threading one cache through both would avoid the extra GetByID calls.