Skip to content
Merged
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
2 changes: 1 addition & 1 deletion billing/checkout/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ func (s *Service) List(ctx context.Context, filter Filter) ([]Checkout, error) {
return s.repository.List(ctx, filter)
}

// DeleteByCustomer removes all checkout records of a billing account. Checkout
// DeleteByCustomer marks every checkout of a billing account as deleted. Checkout
// sessions on the billing provider are not touched as they expire on their own.
func (s *Service) DeleteByCustomer(ctx context.Context, customerID string) error {
return s.repository.DeleteByCustomerID(ctx, customerID)
Expand Down
2 changes: 1 addition & 1 deletion billing/credit/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ func (s Service) GetByID(ctx context.Context, id string) (Transaction, error) {
return s.transactionRepository.GetByID(ctx, id)
}

// DeleteByAccountID removes all credit transactions of a billing account. It is
// DeleteByAccountID marks every credit transaction of a billing account as deleted. It is
// meant for account teardown.
func (s Service) DeleteByAccountID(ctx context.Context, accountID string) error {
return s.transactionRepository.DeleteByAccountID(ctx, accountID)
Expand Down
1 change: 1 addition & 0 deletions billing/customer/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ var (
ErrInvalidDetail = errors.New("invalid billing customer detail")
ErrDisabled = errors.New("billing customer is disabled")
ErrExistingAccountWithPendingDues = errors.New("existing account with pending dues found")
ErrHasLiveRecords = errors.New("billing account still has live subscriptions, invoices or checkouts")
)
2 changes: 1 addition & 1 deletion billing/subscription/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1102,7 +1102,7 @@ func (s *Service) ensureCreditsForPlan(ctx context.Context, sub Subscription, su
// subscriptions are canceled on the billing provider first. A subscription
// that is already gone or canceled on the provider counts as canceled, so a
// teardown that failed midway can be run again. Offline accounts have nothing
// on the provider; only their local records are removed.
// on the provider; only their local rows are marked deleted.
func (s *Service) DeleteByCustomer(ctx context.Context, customr customer.Customer) error {
subs, err := s.List(ctx, Filter{
CustomerID: customr.ID,
Expand Down
18 changes: 4 additions & 14 deletions core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,20 +442,10 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error {
// here.
func (d Service) deleteCustomers(ctx context.Context, id string, customers []customer.Customer, amounts map[string]accountTokens) error {
for _, c := range customers {
// TODO(fix): the subscription and checkout reads here skip rows with
// deleted_at. Once these deletes turn soft, a row left behind with
// deleted_at set is invisible to this loop. A subscription then blocks the
// customer delete on its foreign key, and a checkout is removed without
// its audit record below. Make these deletes soft in the same change.
// cancels active subscriptions on the billing provider and removes local records
// cancels active subscriptions on the billing provider and hides the local rows
if err := d.subService.DeleteByCustomer(ctx, c); err != nil {
return fmt.Errorf("failed to delete org while deleting a billing account subscriptions[%s]: %w", c.ID, err)
}
// TODO(fix): this delete is due to become a soft delete. An invoice will
// only be allowed to carry deleted_at once its customer already does,
// and the customer below is deleted last, so this order gets rejected.
// Delete the customer first, or run the whole loop in one transaction
// with a deferred check.
if err := d.invoiceService.DeleteByCustomer(ctx, c); err != nil {
return fmt.Errorf("failed to delete org while deleting a billing account invoices[%s]: %w", c.ID, err)
}
Expand All @@ -466,8 +456,8 @@ func (d Service) deleteCustomers(ctx context.Context, id string, customers []cus
if err := d.checkoutService.DeleteByCustomer(ctx, c.ID); err != nil {
return fmt.Errorf("failed to delete org while deleting a billing account checkouts[%s]: %w", c.ID, err)
}
// the checkout rows are gone after this; the audit records keep the
// provider references so the sessions can still be found on the provider.
// the checkout rows are hidden after this; the audit records say which
// sessions the delete covered.
// The records are only written when ctx carries the audit service (the
// API path seeds it); otherwise audit.NewLogger falls back to a noop
auditLogger := audit.NewLogger(ctx, id)
Expand Down Expand Up @@ -495,7 +485,7 @@ func (d Service) deleteCustomers(ctx context.Context, id string, customers []cus
// tokens still on the account are forfeited by this delete, so
// record the amount before the transactions are removed. The
// purchased share goes on the record too: the transaction rows are
// deleted right after, and support settles a transfer from this
// hidden right after, and support settles a transfer from this
// number later
account, err := d.resolveAccountTokens(ctx, c.ID, amounts)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/api/v1beta1connect/billing_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ func mapBillingErrorCode(err error) *connect.Error {
return connect.NewError(connect.CodeFailedPrecondition, subscription.ErrPhaseIsUpdating)
case errors.Is(err, customer.ErrExistingAccountWithPendingDues):
return connect.NewError(connect.CodeFailedPrecondition, customer.ErrExistingAccountWithPendingDues)
case errors.Is(err, customer.ErrHasLiveRecords):
return connect.NewError(connect.CodeFailedPrecondition, customer.ErrHasLiveRecords)
case errors.Is(err, customer.ErrNotFound):
return connect.NewError(connect.CodeNotFound, ErrCustomerNotFound)
case errors.Is(err, checkout.ErrAlreadySubscribed):
Expand Down
6 changes: 6 additions & 0 deletions internal/api/v1beta1connect/billing_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ func TestMapBillingError(t *testing.T) {
wantCode: connect.CodeFailedPrecondition,
wantMsg: customer.ErrExistingAccountWithPendingDues.Error(),
},
{
name: "live records block the account delete",
err: fmt.Errorf("DeleteBillingAccount.Delete: customer_id=x: %w", customer.ErrHasLiveRecords),
wantCode: connect.CodeFailedPrecondition,
wantMsg: customer.ErrHasLiveRecords.Error(),
},
{
name: "customer not found",
err: fmt.Errorf("CreateCheckout.GetBillingAccountFromOrgID: org_id=abc: %w", customer.ErrNotFound),
Expand Down
2 changes: 1 addition & 1 deletion internal/store/postgres/billing_checkout_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ func (r BillingCheckoutRepository) UpdateByID(ctx context.Context, toUpdate chec
}

func (r BillingCheckoutRepository) DeleteByCustomerID(ctx context.Context, customerID string) error {
query, params, err := dialect.Delete(TABLE_BILLING_CHECKOUTS).Where(goqu.Ex{
query, params, err := softDelete(TABLE_BILLING_CHECKOUTS).Where(goqu.Ex{
"customer_id": customerID,
}).ToSQL()
if err != nil {
Expand Down
18 changes: 18 additions & 0 deletions internal/store/postgres/billing_checkout_repository_pg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,21 @@ func (s *BillingCheckoutRepositoryPGTestSuite) TestListSkipsDeleted() {
func TestBillingCheckoutRepositoryPG(t *testing.T) {
suite.Run(t, new(BillingCheckoutRepositoryPGTestSuite))
}

func (s *BillingCheckoutRepositoryPGTestSuite) TestDeleteByCustomerIDKeepsTheRowsAndHidesThem() {
before := scalarSQL(s.T(), s.ctx, s.client,
`SELECT deleted_at::text FROM billing_checkouts WHERE provider_id = 'bch-gone-session'`)

s.Require().NoError(s.repository.DeleteByCustomerID(s.ctx, s.customerID()))

s.Equal("2", scalarSQL(s.T(), s.ctx, s.client,
`SELECT count(*)::text FROM billing_checkouts WHERE customer_id = $1 AND deleted_at IS NOT NULL`, s.customerID()))
s.Equal(before, scalarSQL(s.T(), s.ctx, s.client,
`SELECT deleted_at::text FROM billing_checkouts WHERE provider_id = 'bch-gone-session'`),
"a row deleted earlier keeps its first delete time")
got, err := s.repository.List(s.ctx, checkout.Filter{CustomerID: s.customerID()})
s.Require().NoError(err)
s.Empty(got)
_, err = s.repository.GetByID(s.ctx, s.checkoutID("bch-live-session"))
s.ErrorIs(err, checkout.ErrNotFound)
}
39 changes: 37 additions & 2 deletions internal/store/postgres/billing_customer_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/raystack/frontier/pkg/utils"

"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
"github.com/jmoiron/sqlx/types"
"github.com/raystack/frontier/billing/customer"
"github.com/raystack/frontier/pkg/auditrecord"
Expand Down Expand Up @@ -497,16 +498,40 @@ func (r BillingCustomerRepository) UpdateDetailsByID(ctx context.Context, custom
}

func (r BillingCustomerRepository) Delete(ctx context.Context, id string) error {
query, params, err := dialect.Delete(TABLE_BILLING_CUSTOMERS).
lockQuery, lockParams, err := fromLive(TABLE_BILLING_CUSTOMERS).
Select("id").
Where(goqu.Ex{"id": id}).
ForUpdate(exp.Wait).
ToSQL()
if err != nil {
return fmt.Errorf("%w: %w", errParse, err)
}
query, params, err := softDelete(TABLE_BILLING_CUSTOMERS).
Where(goqu.Ex{"id": id}).
Returning(&Customer{}).ToSQL()
if err != nil {
return fmt.Errorf("%w: %w", errParse, err)
}
liveRecordsQuery, liveRecordsParams, err := liveBillingRecordsQuery(id)
if err != nil {
return fmt.Errorf("%w: %w", errParse, err)
}

var customerModel Customer
if err = r.dbc.WithTxn(ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error {
return r.dbc.WithTimeout(ctx, TABLE_BILLING_CUSTOMERS, "Delete", func(ctx context.Context) error {
var lockedID string
if err := tx.QueryRowxContext(ctx, lockQuery, lockParams...).Scan(&lockedID); err != nil {
return err
}

var liveRecords bool
if err := tx.QueryRowxContext(ctx, liveRecordsQuery, liveRecordsParams...).Scan(&liveRecords); err != nil {
return err
}
if liveRecords {
Comment thread
AmanGIT07 marked this conversation as resolved.
return customer.ErrHasLiveRecords
}
if err := tx.QueryRowxContext(ctx, query, params...).StructScan(&customerModel); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return customer.ErrNotFound
Expand Down Expand Up @@ -542,12 +567,22 @@ func (r BillingCustomerRepository) Delete(ctx context.Context, id string) error
}); err != nil {
err = checkPostgresError(err)
switch {
case errors.Is(err, sql.ErrNoRows):
case errors.Is(err, customer.ErrNotFound), errors.Is(err, sql.ErrNoRows):
return customer.ErrNotFound
case errors.Is(err, customer.ErrHasLiveRecords):
return customer.ErrHasLiveRecords
default:
return fmt.Errorf("%w: %w", errTxn, err)
}
}

return nil
}

func liveBillingRecordsQuery(customerID string) (string, []any, error) {
return dialect.Select(goqu.Or(
goqu.L("EXISTS (SELECT 1 FROM billing_subscriptions WHERE customer_id = ? AND deleted_at IS NULL)", customerID),
goqu.L("EXISTS (SELECT 1 FROM billing_invoices WHERE customer_id = ? AND deleted_at IS NULL)", customerID),
goqu.L("EXISTS (SELECT 1 FROM billing_checkouts WHERE customer_id = ? AND deleted_at IS NULL)", customerID),
)).ToSQL()
}
Loading
Loading