diff --git a/core/audit/audit.go b/core/audit/audit.go index 365508cadc..d16d501863 100644 --- a/core/audit/audit.go +++ b/core/audit/audit.go @@ -96,6 +96,7 @@ const ( ResourceDeletedEvent EventName = "app.resource.deleted" BillingAccountDetailsUpdatedEvent EventName = "app.billing.account.details.updated" + BillingCheckoutDeletedEvent EventName = "app.billing.checkout.deleted" ) var systemEvents = []EventName{ @@ -111,6 +112,7 @@ var systemEvents = []EventName{ OrgCreatedEvent, OrgDeletedEvent, OrgDisabledEvent, + BillingCheckoutDeletedEvent, } func IsSystemEvent(event EventName) bool { diff --git a/core/deleter/mocks/checkout_service.go b/core/deleter/mocks/checkout_service.go index 3f313d9d32..bc72878426 100644 --- a/core/deleter/mocks/checkout_service.go +++ b/core/deleter/mocks/checkout_service.go @@ -5,6 +5,8 @@ package mocks import ( context "context" + checkout "github.com/raystack/frontier/billing/checkout" + mock "github.com/stretchr/testify/mock" ) @@ -68,6 +70,65 @@ func (_c *CheckoutService_DeleteByCustomer_Call) RunAndReturn(run func(context.C return _c } +// List provides a mock function with given fields: ctx, filter +func (_m *CheckoutService) List(ctx context.Context, filter checkout.Filter) ([]checkout.Checkout, error) { + ret := _m.Called(ctx, filter) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []checkout.Checkout + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, checkout.Filter) ([]checkout.Checkout, error)); ok { + return rf(ctx, filter) + } + if rf, ok := ret.Get(0).(func(context.Context, checkout.Filter) []checkout.Checkout); ok { + r0 = rf(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]checkout.Checkout) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, checkout.Filter) error); ok { + r1 = rf(ctx, filter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CheckoutService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type CheckoutService_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - filter checkout.Filter +func (_e *CheckoutService_Expecter) List(ctx interface{}, filter interface{}) *CheckoutService_List_Call { + return &CheckoutService_List_Call{Call: _e.mock.On("List", ctx, filter)} +} + +func (_c *CheckoutService_List_Call) Run(run func(ctx context.Context, filter checkout.Filter)) *CheckoutService_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(checkout.Filter)) + }) + return _c +} + +func (_c *CheckoutService_List_Call) Return(_a0 []checkout.Checkout, _a1 error) *CheckoutService_List_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *CheckoutService_List_Call) RunAndReturn(run func(context.Context, checkout.Filter) ([]checkout.Checkout, error)) *CheckoutService_List_Call { + _c.Call.Return(run) + return _c +} + // NewCheckoutService creates a new instance of CheckoutService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewCheckoutService(t interface { diff --git a/core/deleter/service.go b/core/deleter/service.go index f378d09c19..fb4be96ebc 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -10,6 +10,8 @@ import ( "github.com/raystack/frontier/core/authenticate" + "github.com/raystack/frontier/billing/checkout" + "github.com/raystack/frontier/billing/invoice" "github.com/raystack/frontier/billing/customer" @@ -105,6 +107,7 @@ type InvoiceService interface { } type CheckoutService interface { + List(ctx context.Context, filter checkout.Filter) ([]checkout.Checkout, error) DeleteByCustomer(ctx context.Context, customerID string) error } @@ -328,9 +331,39 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error { 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) } + checkouts, err := d.checkoutService.List(ctx, checkout.Filter{CustomerID: c.ID}) + if err != nil { + return fmt.Errorf("failed to delete org while listing billing account checkouts[%s]: %w", c.ID, err) + } 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 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) + for _, ch := range checkouts { + attrs := map[string]string{ + "provider_id": ch.ProviderID, + "customer_id": ch.CustomerID, + "plan_id": ch.PlanID, + "product_id": ch.ProductID, + "state": ch.State, + "payment_status": ch.PaymentStatus, + } + for k, v := range attrs { + if v == "" { + delete(attrs, k) + } + } + if err := auditLogger.LogWithAttrs(audit.BillingCheckoutDeletedEvent, audit.Target{ + ID: ch.ID, + Type: "billing_checkout", + }, attrs); err != nil { + slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingCheckoutDeletedEvent, "checkout_id", ch.ID) + } + } if err := d.creditService.DeleteByAccountID(ctx, c.ID); err != nil { return fmt.Errorf("failed to delete org while deleting a billing account transactions[%s]: %w", c.ID, err) } diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index 14d559ac3a..2e96132838 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/raystack/frontier/billing/checkout" "github.com/raystack/frontier/billing/customer" "github.com/raystack/frontier/billing/invoice" "github.com/raystack/frontier/core/deleter" @@ -139,6 +140,8 @@ func TestDeleteOrganization(t *testing.T) { // billing teardown m.subSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) m.invocSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) + m.checkoutSvc.EXPECT().List(mock.Anything, checkout.Filter{CustomerID: "cust-1"}). + Return([]checkout.Checkout{{ID: "chk-1", ProviderID: "cs_1", CustomerID: "cust-1"}}, nil) m.checkoutSvc.EXPECT().DeleteByCustomer(mock.Anything, "cust-1").Return(nil) m.creditSvc.EXPECT().DeleteByAccountID(mock.Anything, "cust-1").Return(nil) m.custSvc.EXPECT().Delete(mock.Anything, "cust-1").Return(nil) @@ -282,6 +285,11 @@ func TestDeleteCustomers(t *testing.T) { Return([]customer.Customer{c}, nil) m.subSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) m.invocSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) + m.checkoutSvc.EXPECT().List(mock.Anything, checkout.Filter{CustomerID: "cust-1"}). + Return([]checkout.Checkout{ + {ID: "chk-1", ProviderID: "cs_1", CustomerID: "cust-1", PlanID: "plan-1", State: "complete", PaymentStatus: "paid"}, + {ID: "chk-2", ProviderID: "cs_2", CustomerID: "cust-1", State: "expired"}, + }, nil) m.checkoutSvc.EXPECT().DeleteByCustomer(mock.Anything, "cust-1").Return(nil) m.creditSvc.EXPECT().DeleteByAccountID(mock.Anything, "cust-1").Return(nil) m.custSvc.EXPECT().Delete(mock.Anything, "cust-1").Return(nil) @@ -298,6 +306,8 @@ func TestDeleteCustomers(t *testing.T) { Return([]customer.Customer{c}, nil) m.subSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) m.invocSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) + m.checkoutSvc.EXPECT().List(mock.Anything, checkout.Filter{CustomerID: "cust-no-provider"}). + Return([]checkout.Checkout{}, nil) m.checkoutSvc.EXPECT().DeleteByCustomer(mock.Anything, "cust-no-provider").Return(nil) m.creditSvc.EXPECT().DeleteByAccountID(mock.Anything, "cust-no-provider").Return(nil) m.custSvc.EXPECT().Delete(mock.Anything, "cust-no-provider").Return(nil) @@ -314,6 +324,8 @@ func TestDeleteCustomers(t *testing.T) { Return([]customer.Customer{c}, nil) m.subSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) m.invocSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) + m.checkoutSvc.EXPECT().List(mock.Anything, checkout.Filter{CustomerID: "cust-1"}). + Return([]checkout.Checkout{}, nil) m.checkoutSvc.EXPECT().DeleteByCustomer(mock.Anything, "cust-1"). Return(errors.New("checkout delete failed")) // strict mocks: custSvc.Delete must not be called @@ -321,6 +333,22 @@ func TestDeleteCustomers(t *testing.T) { err := m.build().DeleteCustomers(context.Background(), "org-1") assert.ErrorContains(t, err, "checkout delete failed") }) + + t.Run("checkout list failure stops the customer delete", func(t *testing.T) { + m := newMocks(t) + + c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"} + m.custSvc.EXPECT().List(mock.Anything, customer.Filter{OrgID: "org-1"}). + Return([]customer.Customer{c}, nil) + m.subSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) + m.invocSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) + m.checkoutSvc.EXPECT().List(mock.Anything, checkout.Filter{CustomerID: "cust-1"}). + Return(nil, errors.New("checkout list failed")) + // strict mocks: checkoutSvc.DeleteByCustomer and custSvc.Delete must not be called + + err := m.build().DeleteCustomers(context.Background(), "org-1") + assert.ErrorContains(t, err, "checkout list failed") + }) } func TestRemoveUsersFromOrg(t *testing.T) {