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
15 changes: 15 additions & 0 deletions billing/invoice/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package invoice

import (
"fmt"
"slices"
"time"

"github.com/raystack/frontier/pkg/pagination"
Expand Down Expand Up @@ -43,8 +44,22 @@ 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"
)

// PayableStates are the invoice states that can still ask the customer for
// money: open and uncollectible invoices are payable now, a draft becomes
// payable once the provider finalizes it. The delete blockers and the
// provider-side payable listing must agree on this set.
var PayableStates = []State{DraftState, OpenState, UncollectibleState}

// IsPayable reports whether the invoice's state is one of PayableStates.
func (i Invoice) IsPayable() bool {
return slices.Contains(PayableStates, i.State)
}

type Invoice struct {
ID string
CustomerID string
Expand Down
53 changes: 53 additions & 0 deletions billing/invoice/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,59 @@ 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, expands nothing, and reads one status-filtered listing per payable
// state — a customer with very many payable invoices pages through more
// requests, but the cost scales with what is still owed, not with the whole
// invoice history.
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 PayableStates {
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()
// AmountRemaining is what the customer still owes; Total is not:
// a negative total is a credit owed to the customer, and an open
// invoice fully covered by their credit balance has a positive
// total with nothing due. Neither asks for money.
if stripeInvoice.AmountRemaining <= 0 {
Comment thread
whoAbhishekSah marked this conversation as resolved.
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