Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions billing/invoice/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const (
DraftState State = "draft"
OpenState State = "open"
PaidState State = "paid"
// UncollectibleState marks an invoice the provider has written off; it
// can still be paid.
UncollectibleState State = "uncollectible"
)

type Invoice struct {
Expand Down
47 changes: 47 additions & 0 deletions billing/invoice/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,53 @@ func (s *Service) isCreditOverdraftDayOfInvoice() bool {
return time.Now().UTC().Day() == s.creditOverdraftInvoiceDay
}

// ListPayableOnProvider returns the customer's invoices that still ask for
// money — open, uncollectible, or being prepared as drafts — read straight
// from the billing provider so the answer is current. Zero-amount invoices
// are skipped. Where a local row exists for the invoice it keeps its local
// id; an invoice the sync has not seen yet is returned with an empty id and
// only its provider reference. Unlike SyncWithProvider this touches no local
// rows and reads only three small filtered pages, so it is cheap enough for
// a request path.
func (s *Service) ListPayableOnProvider(ctx context.Context, customr customer.Customer) ([]Invoice, error) {
localInvoices, err := s.repository.List(ctx, Filter{
CustomerID: customr.ID,
})
if err != nil {
return nil, err
}
localByProviderID := make(map[string]Invoice, len(localInvoices))
for _, inv := range localInvoices {
localByProviderID[inv.ProviderID] = inv
}

var payable []Invoice
for _, status := range []State{DraftState, OpenState, UncollectibleState} {
stripeInvoices := s.stripeClient.Invoices.List(&stripe.InvoiceListParams{
Customer: stripe.String(customr.ProviderID),
Status: stripe.String(string(status)),
ListParams: stripe.ListParams{
Context: ctx,
},
})
for stripeInvoices.Next() {
stripeInvoice := stripeInvoices.Invoice()
if stripeInvoice.Total == 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This skips an invoice only when its Total is exactly 0. Two normal Stripe cases still get through and turn into fake blockers: a negative-total invoice (a proration credit the customer is owed), and an open invoice already fully covered by the customer's credit balance (Total is positive but nothing is due). Both leave the org stuck with an unpaid-invoice message the caller cannot act on. The field that means "still owes money" is AmountRemaining (or AmountDue), not Total.

continue
}
inv := stripeInvoiceToInvoice(customr.ID, stripeInvoice)
if local, ok := localByProviderID[stripeInvoice.ID]; ok {
inv.ID = local.ID
}
payable = append(payable, inv)
}
if err := stripeInvoices.Err(); err != nil {
return nil, fmt.Errorf("failed to list %s invoices: %w", status, billingerrors.TranslateStripeError(err))
}
}
return payable, nil
}

func (s *Service) SyncWithProvider(ctx context.Context, customr customer.Customer) error {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down
2 changes: 1 addition & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ func buildAPIDependencies(
cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService,
groupService, membershipService, policyService, roleService, invitationService, userService, userPATService,
serviceUserService, customerService, subscriptionService, invoiceService, checkoutService,
creditService, orgKycService,
creditService, orgKycService, planService,
)

// we should default it with a stdout logger repository as postgres can start to bloat really fast
Expand Down
2 changes: 2 additions & 0 deletions core/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const (

BillingAccountDetailsUpdatedEvent EventName = "app.billing.account.details.updated"
BillingCheckoutDeletedEvent EventName = "app.billing.checkout.deleted"
BillingTokensForfeitedEvent EventName = "app.billing.tokens.forfeited"
)

var systemEvents = []EventName{
Expand All @@ -113,6 +114,7 @@ var systemEvents = []EventName{
OrgDeletedEvent,
OrgDisabledEvent,
BillingCheckoutDeletedEvent,
BillingTokensForfeitedEvent,
}

func IsSystemEvent(event EventName) bool {
Expand Down
37 changes: 34 additions & 3 deletions core/deleter/deleter.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
package deleter

import "fmt"
import "strings"

var (
ErrDeleteNotAllowed = fmt.Errorf("deletion not allowed for billed accounts")
// Reasons an organization delete can be blocked. The API error carries
// them as violation types, so a client can tell the reasons apart
// without reading the message text.
const (
BlockerActiveSubscription = "ACTIVE_SUBSCRIPTION"
BlockerUnpaidInvoice = "UNPAID_INVOICE"
BlockerNegativeTokenBalance = "NEGATIVE_TOKEN_BALANCE"
)

// Blocker is one reason an organization cannot be deleted right now.
type Blocker struct {
// Type is one of the Blocker* constants.
Type string
// Subject is the id of the entity behind the reason.
Subject string
// Message says what blocks the delete and how to clear it.
Message string
}

// BlockedError carries every blocker the up-front check found, so the
// caller gets one checklist instead of discovering blockers one retry at
// a time.
type BlockedError struct {
OrgID string
Blockers []Blocker
}

func (e *BlockedError) Error() string {
msgs := make([]string, 0, len(e.Blockers))
for _, b := range e.Blockers {
msgs = append(msgs, b.Message)
}
return "organization cannot be deleted yet: " + strings.Join(msgs, "; ")
}
Loading
Loading