From a16697cc8757b61e5b8a111d7d3e5774fb44533b Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 30 Sep 2026 16:41:31 +0530 Subject: [PATCH 1/4] chore(deleter): note the soft-delete ordering constraint on invoice delete --- core/deleter/service.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/deleter/service.go b/core/deleter/service.go index 2eb6b0391..78de9e797 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -407,6 +407,11 @@ func (d Service) deleteCustomers(ctx context.Context, id string, customers []cus 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) } From 8d1594e643ed0bffcf5cd5218b6c9071eedd4af5 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 30 Sep 2026 16:54:52 +0530 Subject: [PATCH 2/4] feat(store): billing customer reads skip soft-deleted rows --- .../postgres/billing_customer_repository.go | 6 +- .../billing_customer_repository_pg_test.go | 106 ++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 internal/store/postgres/billing_customer_repository_pg_test.go diff --git a/internal/store/postgres/billing_customer_repository.go b/internal/store/postgres/billing_customer_repository.go index a6879dcf7..98c153b5d 100644 --- a/internal/store/postgres/billing_customer_repository.go +++ b/internal/store/postgres/billing_customer_repository.go @@ -192,7 +192,7 @@ func (r BillingCustomerRepository) Create(ctx context.Context, toCreate customer } func (r BillingCustomerRepository) GetByID(ctx context.Context, id string) (customer.Customer, error) { - stmt := dialect.Select().From(TABLE_BILLING_CUSTOMERS).Where(goqu.Ex{ + stmt := fromLive(TABLE_BILLING_CUSTOMERS).Where(goqu.Ex{ "id": id, }) query, params, err := stmt.ToSQL() @@ -216,7 +216,7 @@ func (r BillingCustomerRepository) GetByID(ctx context.Context, id string) (cust } func (r BillingCustomerRepository) List(ctx context.Context, flt customer.Filter) ([]customer.Customer, error) { - stmt := dialect.Select().From(TABLE_BILLING_CUSTOMERS).Order(goqu.I("created_at").Desc()) + stmt := fromLive(TABLE_BILLING_CUSTOMERS).Order(goqu.I("created_at").Desc()) if flt.OrgID != "" { stmt = stmt.Where(goqu.Ex{ @@ -407,7 +407,7 @@ func (r BillingCustomerRepository) UpdateCreditMinByID(ctx context.Context, cust } func (r BillingCustomerRepository) GetDetailsByID(ctx context.Context, customerID string) (customer.Details, error) { - stmt := dialect.Select("credit_min", "due_in_days").From(TABLE_BILLING_CUSTOMERS).Where(goqu.Ex{ + stmt := fromLive(TABLE_BILLING_CUSTOMERS).Select("credit_min", "due_in_days").Where(goqu.Ex{ "id": customerID, }) query, params, err := stmt.ToSQL() diff --git a/internal/store/postgres/billing_customer_repository_pg_test.go b/internal/store/postgres/billing_customer_repository_pg_test.go new file mode 100644 index 000000000..dd37b04f7 --- /dev/null +++ b/internal/store/postgres/billing_customer_repository_pg_test.go @@ -0,0 +1,106 @@ +package postgres_test + +import ( + "context" + "fmt" + "testing" + + "github.com/raystack/frontier/billing/customer" + "github.com/raystack/frontier/internal/store/postgres" + "github.com/raystack/frontier/pkg/db" + "github.com/stretchr/testify/suite" +) + +// Runs the billing customer reads against a real postgres to check that a +// soft-deleted customer stays out of every read. +type BillingCustomerRepositoryPGTestSuite struct { + suite.Suite + ctx context.Context + client *db.Client + repository *postgres.BillingCustomerRepository +} + +func (s *BillingCustomerRepositoryPGTestSuite) SetupSuite() { + var err error + s.client, err = newTestClient() + if err != nil { + s.T().Fatal(err) + } + s.ctx = context.TODO() + s.repository = postgres.NewBillingCustomerRepository(s.client) +} + +func (s *BillingCustomerRepositoryPGTestSuite) TearDownSuite() { + if err := closeTestClient(s.client); err != nil { + s.T().Fatal(err) + } +} + +func (s *BillingCustomerRepositoryPGTestSuite) SetupTest() { + s.exec(`INSERT INTO organizations (name, title) VALUES ('bc-live', 'Live Org')`) + + s.customer("bc-cust-live", "bc-live", -100) + s.customer("bc-cust-gone", "bc-live", -100) + + s.exec(`UPDATE billing_customers SET deleted_at = now() WHERE name = 'bc-cust-gone'`) +} + +func (s *BillingCustomerRepositoryPGTestSuite) TearDownTest() { + queries := []string{} + for _, table := range []string{postgres.TABLE_BILLING_CUSTOMERS, postgres.TABLE_ORGANIZATIONS} { + queries = append(queries, fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", table)) + } + if err := execQueries(s.ctx, s.client, queries); err != nil { + s.T().Fatal(err) + } +} + +func (s *BillingCustomerRepositoryPGTestSuite) exec(query string, args ...any) { + s.T().Helper() + execSQL(s.T(), s.ctx, s.client, query, args...) +} + +func (s *BillingCustomerRepositoryPGTestSuite) orgID(name string) string { + s.T().Helper() + return scalarSQL(s.T(), s.ctx, s.client, `SELECT id FROM organizations WHERE name = $1`, name) +} + +func (s *BillingCustomerRepositoryPGTestSuite) customerID(name string) string { + s.T().Helper() + return scalarSQL(s.T(), s.ctx, s.client, `SELECT id FROM billing_customers WHERE name = $1`, name) +} + +func (s *BillingCustomerRepositoryPGTestSuite) customer(name, orgName string, creditMin int64) { + s.T().Helper() + s.exec(`INSERT INTO billing_customers (org_id, provider_id, name, email, credit_min) + VALUES ($1, $2, $2, $2, $3)`, s.orgID(orgName), name, creditMin) +} + +func (s *BillingCustomerRepositoryPGTestSuite) TestGetByIDSkipsDeleted() { + got, err := s.repository.GetByID(s.ctx, s.customerID("bc-cust-live")) + s.Require().NoError(err) + s.Equal("bc-cust-live", got.Name) + + _, err = s.repository.GetByID(s.ctx, s.customerID("bc-cust-gone")) + s.ErrorIs(err, customer.ErrNotFound) +} + +func (s *BillingCustomerRepositoryPGTestSuite) TestListSkipsDeleted() { + got, err := s.repository.List(s.ctx, customer.Filter{OrgID: s.orgID("bc-live")}) + s.Require().NoError(err) + s.Require().Len(got, 1) + s.Equal("bc-cust-live", got[0].Name) +} + +func (s *BillingCustomerRepositoryPGTestSuite) TestGetDetailsByIDSkipsDeleted() { + got, err := s.repository.GetDetailsByID(s.ctx, s.customerID("bc-cust-live")) + s.Require().NoError(err) + s.Equal(int64(-100), got.CreditMin) + + _, err = s.repository.GetDetailsByID(s.ctx, s.customerID("bc-cust-gone")) + s.ErrorIs(err, customer.ErrNotFound) +} + +func TestBillingCustomerRepositoryPG(t *testing.T) { + suite.Run(t, new(BillingCustomerRepositoryPGTestSuite)) +} From 043cf0fa9a660c8415ef2105700d72f0393115c3 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 30 Sep 2026 16:57:43 +0530 Subject: [PATCH 3/4] feat(store): billing transaction reads skip soft-deleted rows --- .../billing_transactions_repository.go | 10 +- ...billing_transactions_repository_pg_test.go | 121 +++++++++++++++++- 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/internal/store/postgres/billing_transactions_repository.go b/internal/store/postgres/billing_transactions_repository.go index ee7413f4f..668b450cd 100644 --- a/internal/store/postgres/billing_transactions_repository.go +++ b/internal/store/postgres/billing_transactions_repository.go @@ -246,7 +246,7 @@ func (r BillingTransactionRepository) GetByID(ctx context.Context, id string) (c if strings.TrimSpace(id) == "" { return credit.Transaction{}, credit.ErrInvalidID } - stmt := dialect.Select().From(TABLE_BILLING_TRANSACTIONS).Where(goqu.Ex{ + stmt := fromLive(TABLE_BILLING_TRANSACTIONS).Where(goqu.Ex{ "id": id, }) query, params, err := stmt.ToSQL() @@ -324,7 +324,7 @@ func (r BillingTransactionRepository) DeleteByAccountID(ctx context.Context, acc } func (r BillingTransactionRepository) List(ctx context.Context, filter credit.Filter) ([]credit.Transaction, error) { - stmt := dialect.Select().From(TABLE_BILLING_TRANSACTIONS).Order(goqu.I("created_at").Desc()) + stmt := fromLive(TABLE_BILLING_TRANSACTIONS).Order(goqu.I("created_at").Desc()) if filter.CustomerID != "" { stmt = stmt.Where(goqu.Ex{ "account_id": filter.CustomerID, @@ -370,7 +370,7 @@ func (r BillingTransactionRepository) List(ctx context.Context, filter credit.Fi func (r BillingTransactionRepository) getDebitBalance(ctx context.Context, tx *sqlx.Tx, accountID string, start *time.Time, end *time.Time) (*int64, error) { - stmt := dialect.Select(goqu.SUM("amount")).From(TABLE_BILLING_TRANSACTIONS).Where(goqu.Ex{ + stmt := fromLive(TABLE_BILLING_TRANSACTIONS).Select(goqu.SUM("amount")).Where(goqu.Ex{ "account_id": accountID, "type": credit.DebitType, }) @@ -400,7 +400,7 @@ func (r BillingTransactionRepository) getDebitBalance(ctx context.Context, tx *s func (r BillingTransactionRepository) getCreditBalance(ctx context.Context, tx *sqlx.Tx, accountID string, start *time.Time, end *time.Time) (*int64, error) { - stmt := dialect.Select(goqu.SUM("amount")).From(TABLE_BILLING_TRANSACTIONS).Where(goqu.Ex{ + stmt := fromLive(TABLE_BILLING_TRANSACTIONS).Select(goqu.SUM("amount")).Where(goqu.Ex{ "account_id": accountID, "type": credit.CreditType, }) @@ -515,7 +515,7 @@ func (r BillingTransactionRepository) GetBalanceForRange(ctx context.Context, ac func (r BillingTransactionRepository) getCreditBalanceExcludingSource(ctx context.Context, tx *sqlx.Tx, accountID string, start *time.Time, end *time.Time, excludeSource string) (*int64, error) { - stmt := dialect.Select(goqu.SUM("amount")).From(TABLE_BILLING_TRANSACTIONS).Where(goqu.Ex{ + stmt := fromLive(TABLE_BILLING_TRANSACTIONS).Select(goqu.SUM("amount")).Where(goqu.Ex{ "account_id": accountID, "type": credit.CreditType, }).Where(goqu.C("source").Neq(excludeSource)) diff --git a/internal/store/postgres/billing_transactions_repository_pg_test.go b/internal/store/postgres/billing_transactions_repository_pg_test.go index 42bf3ef38..fb14b8f80 100644 --- a/internal/store/postgres/billing_transactions_repository_pg_test.go +++ b/internal/store/postgres/billing_transactions_repository_pg_test.go @@ -2,13 +2,17 @@ package postgres_test import ( "context" + "fmt" "testing" + "time" "github.com/google/uuid" "github.com/raystack/frontier/billing/credit" + "github.com/raystack/frontier/billing/customer" "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/raystack/frontier/internal/store/postgres" "github.com/raystack/frontier/pkg/db" + "github.com/raystack/frontier/pkg/metadata" "github.com/stretchr/testify/suite" ) @@ -38,10 +42,12 @@ func (s *BillingTransactionRepositoryTestSuite) TearDownSuite() { } func (s *BillingTransactionRepositoryTestSuite) TearDownTest() { - queries := []string{ - "TRUNCATE TABLE " + postgres.TABLE_BILLING_TRANSACTIONS, + queries := []string{} + for _, table := range []string{postgres.TABLE_BILLING_TRANSACTIONS, postgres.TABLE_BILLING_CUSTOMERS, + postgres.TABLE_ORGANIZATIONS} { + queries = append(queries, fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", table)) } - if err := execQueries(context.TODO(), s.client, queries); err != nil { + if err := execQueries(s.ctx, s.client, queries); err != nil { s.T().Fatal(err) } } @@ -76,6 +82,115 @@ func (s *BillingTransactionRepositoryTestSuite) TestCreateEntry() { }) } +func (s *BillingTransactionRepositoryTestSuite) exec(query string, args ...any) { + s.T().Helper() + execSQL(s.T(), s.ctx, s.client, query, args...) +} + +// txn writes one ledger row straight into the table and returns its id. +func (s *BillingTransactionRepositoryTestSuite) txn(account, txType, source string, amount int64) string { + s.T().Helper() + return scalarSQL(s.T(), s.ctx, s.client, + `INSERT INTO billing_transactions (account_id, type, source, amount, description) + VALUES ($1, $2, $3, $4, $2) RETURNING id`, account, txType, source, amount) +} + +func (s *BillingTransactionRepositoryTestSuite) softDelete(id string) { + s.T().Helper() + s.exec(`UPDATE billing_transactions SET deleted_at = now() WHERE id = $1`, id) +} + +// customer seeds an organization and a live billing customer and returns the +// customer id, so CreateEntry can run its spending gate against a real account. +func (s *BillingTransactionRepositoryTestSuite) customer(name string, creditMin int64) string { + s.T().Helper() + s.exec(`INSERT INTO organizations (name, title) VALUES ($1, $1)`, name) + return scalarSQL(s.T(), s.ctx, s.client, + `INSERT INTO billing_customers (org_id, provider_id, name, email, credit_min) + VALUES ((SELECT id FROM organizations WHERE name = $1), $1, $1, $1, $2) RETURNING id`, name, creditMin) +} + +func (s *BillingTransactionRepositoryTestSuite) spend(account string, amount int64) error { + s.T().Helper() + _, err := s.repository.CreateEntry(s.ctx, + credit.Transaction{ID: uuid.New().String(), CustomerID: account, Type: credit.DebitType, Amount: amount, Source: "usage"}, + credit.Transaction{CustomerID: schema.PlatformOrgID.String(), Type: credit.CreditType, Amount: amount, Source: "usage"}) + return err +} + +func (s *BillingTransactionRepositoryTestSuite) TestBalanceSkipsDeletedTransactions() { + account := uuid.New().String() + s.txn(account, string(credit.CreditType), credit.SourceSystemBuyEvent, 100) + s.txn(account, string(credit.DebitType), "usage", 30) + s.txn(account, string(credit.CreditType), credit.SourceSystemOverdraftEvent, 25) + s.softDelete(s.txn(account, string(credit.CreditType), credit.SourceSystemBuyEvent, 50)) + s.softDelete(s.txn(account, string(credit.DebitType), "usage", 20)) + s.softDelete(s.txn(account, string(credit.CreditType), credit.SourceSystemOverdraftEvent, 10)) + + start := time.Now().Add(-time.Hour) + end := time.Now().Add(time.Hour) + + balance, err := s.repository.GetBalance(s.ctx, account) + s.Require().NoError(err) + s.Equal(int64(95), balance, "100 + 25 - 30, deleted rows ignored") + + balance, err = s.repository.GetBalanceForRange(s.ctx, account, start, end) + s.Require().NoError(err) + s.Equal(int64(95), balance) + + balance, err = s.repository.GetBalanceForRangeWithoutOverdraft(s.ctx, account, start, end) + s.Require().NoError(err) + s.Equal(int64(70), balance, "100 - 30, overdraft credits and deleted rows ignored") + + debited, err := s.repository.GetTotalDebitedAmount(s.ctx, account) + s.Require().NoError(err) + s.Equal(int64(30), debited) +} + +func (s *BillingTransactionRepositoryTestSuite) TestListAndGetByIDSkipDeleted() { + account := uuid.New().String() + live := s.txn(account, string(credit.CreditType), credit.SourceSystemBuyEvent, 100) + gone := s.txn(account, string(credit.CreditType), credit.SourceSystemBuyEvent, 50) + s.exec(`UPDATE billing_transactions SET metadata = '{"tag": "keep"}'::jsonb WHERE id IN ($1, $2)`, live, gone) + s.softDelete(gone) + + got, err := s.repository.List(s.ctx, credit.Filter{CustomerID: account}) + s.Require().NoError(err) + s.Require().Len(got, 1) + s.Equal(live, got[0].ID) + + got, err = s.repository.List(s.ctx, credit.Filter{CustomerID: account, Metadata: metadata.Metadata{"tag": "keep"}}) + s.Require().NoError(err, "the metadata filter still works next to the live filter") + s.Require().Len(got, 1) + s.Equal(live, got[0].ID) + + _, err = s.repository.GetByID(s.ctx, live) + s.NoError(err) + _, err = s.repository.GetByID(s.ctx, gone) + s.ErrorIs(err, credit.ErrNotFound) +} + +func (s *BillingTransactionRepositoryTestSuite) TestSpendingGateAgreesWithBalance() { + account := s.customer("bt-gate", 0) + s.txn(account, string(credit.CreditType), credit.SourceSystemBuyEvent, 100) + s.softDelete(s.txn(account, string(credit.CreditType), credit.SourceSystemBuyEvent, 50)) + + s.ErrorIs(s.spend(account, 120), credit.ErrInsufficientCredits, "the deleted 50 must not be spendable") + s.NoError(s.spend(account, 80)) + + balance, err := s.repository.GetBalance(s.ctx, account) + s.Require().NoError(err) + s.Equal(int64(20), balance) +} + +func (s *BillingTransactionRepositoryTestSuite) TestCreateEntryRefusesDeletedCustomer() { + account := s.customer("bt-gone", 0) + s.txn(account, string(credit.CreditType), credit.SourceSystemBuyEvent, 100) + s.exec(`UPDATE billing_customers SET deleted_at = now() WHERE id = $1`, account) + + s.ErrorIs(s.spend(account, 10), customer.ErrNotFound) +} + func TestBillingTransactionRepository(t *testing.T) { suite.Run(t, new(BillingTransactionRepositoryTestSuite)) } From 40b67c68100215c9fabf3b46c6a91f8c6a923285 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 30 Sep 2026 17:01:28 +0530 Subject: [PATCH 4/4] feat(store): billing invoice reads skip soft-deleted rows --- .../postgres/billing_invoice_repository.go | 7 +- .../billing_invoice_repository_pg_test.go | 152 ++++++++++++++++++ .../billing_invoice_repository_test.go | 19 +-- 3 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 internal/store/postgres/billing_invoice_repository_pg_test.go diff --git a/internal/store/postgres/billing_invoice_repository.go b/internal/store/postgres/billing_invoice_repository.go index 699b3dcdb..7c011a747 100644 --- a/internal/store/postgres/billing_invoice_repository.go +++ b/internal/store/postgres/billing_invoice_repository.go @@ -185,7 +185,7 @@ func (r BillingInvoiceRepository) Create(ctx context.Context, toCreate invoice.I } func (r BillingInvoiceRepository) GetByID(ctx context.Context, id string) (invoice.Invoice, error) { - stmt := dialect.Select().From(TABLE_BILLING_INVOICES).Where(goqu.Ex{ + stmt := fromLive(TABLE_BILLING_INVOICES).Where(goqu.Ex{ "id": id, }) query, params, err := stmt.ToSQL() @@ -209,7 +209,7 @@ func (r BillingInvoiceRepository) GetByID(ctx context.Context, id string) (invoi } func (r BillingInvoiceRepository) List(ctx context.Context, flt invoice.Filter) ([]invoice.Invoice, error) { - stmt := dialect.Select().From(TABLE_BILLING_INVOICES) + stmt := fromLive(TABLE_BILLING_INVOICES) if flt.CustomerID != "" { stmt = stmt.Where(goqu.Ex{ "customer_id": flt.CustomerID, @@ -396,7 +396,7 @@ func (r BillingInvoiceRepository) prepareDataQuery(rqlQuery *rql.Query) (string, } func (r BillingInvoiceRepository) buildBaseQuery() *goqu.SelectDataset { - return dialect.From(TABLE_BILLING_INVOICES).Prepared(true). + return fromLive(TABLE_BILLING_INVOICES).Prepared(true). InnerJoin( goqu.T(TABLE_BILLING_CUSTOMERS), goqu.On(goqu.I(TABLE_BILLING_INVOICES+".customer_id").Eq(goqu.I(TABLE_BILLING_CUSTOMERS+".id"))), @@ -405,6 +405,7 @@ func (r BillingInvoiceRepository) buildBaseQuery() *goqu.SelectDataset { goqu.T(TABLE_ORGANIZATIONS), goqu.On(goqu.I(TABLE_BILLING_CUSTOMERS+".org_id").Eq(goqu.I(TABLE_ORGANIZATIONS+".id"))), ). + Where(live(TABLE_BILLING_CUSTOMERS), live(TABLE_ORGANIZATIONS)). Select( goqu.I(TABLE_BILLING_INVOICES+".id").As("id"), goqu.I(TABLE_BILLING_INVOICES+".amount").As("amount"), diff --git a/internal/store/postgres/billing_invoice_repository_pg_test.go b/internal/store/postgres/billing_invoice_repository_pg_test.go new file mode 100644 index 000000000..60f6711fa --- /dev/null +++ b/internal/store/postgres/billing_invoice_repository_pg_test.go @@ -0,0 +1,152 @@ +package postgres_test + +import ( + "context" + "fmt" + "testing" + + "github.com/raystack/frontier/billing/invoice" + "github.com/raystack/frontier/internal/store/postgres" + "github.com/raystack/frontier/pkg/db" + "github.com/raystack/frontier/pkg/pagination" + "github.com/raystack/salt/rql" + "github.com/stretchr/testify/suite" +) + +// Runs the billing invoice reads against a real postgres to check that a +// soft-deleted invoice stays out of every read, and that the admin search also +// drops invoices whose billing customer or organization is soft-deleted. +type BillingInvoiceRepositoryPGTestSuite struct { + suite.Suite + ctx context.Context + client *db.Client + repository *postgres.BillingInvoiceRepository +} + +func (s *BillingInvoiceRepositoryPGTestSuite) SetupSuite() { + var err error + s.client, err = newTestClient() + if err != nil { + s.T().Fatal(err) + } + s.ctx = context.TODO() + s.repository = postgres.NewBillingInvoiceRepository(s.client) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) TearDownSuite() { + if err := closeTestClient(s.client); err != nil { + s.T().Fatal(err) + } +} + +func (s *BillingInvoiceRepositoryPGTestSuite) SetupTest() { + s.exec(`INSERT INTO organizations (name, title) VALUES ('bi-live', 'Live Org'), ('bi-gone', 'Gone Org')`) + + s.customer("bi-cust-live", "bi-live") + s.customer("bi-cust-gone", "bi-live") + s.customer("bi-cust-other", "bi-gone") + + s.invoice("bi-cust-live", "bi-inv-live", "paid") + s.invoice("bi-cust-live", "bi-inv-deleted", "paid") + s.invoice("bi-cust-gone", "bi-inv-orphan", "open") + s.invoice("bi-cust-other", "bi-inv-otherorg", "paid") + + s.exec(`UPDATE billing_invoices SET deleted_at = now() WHERE hosted_url = 'bi-inv-deleted'`) + s.exec(`UPDATE billing_customers SET deleted_at = now() WHERE name = 'bi-cust-gone'`) + s.exec(`UPDATE organizations SET deleted_at = now() WHERE name = 'bi-gone'`) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) TearDownTest() { + queries := []string{} + for _, table := range []string{postgres.TABLE_BILLING_INVOICES, postgres.TABLE_BILLING_CUSTOMERS, + postgres.TABLE_ORGANIZATIONS} { + queries = append(queries, fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", table)) + } + if err := execQueries(s.ctx, s.client, queries); err != nil { + s.T().Fatal(err) + } +} + +func (s *BillingInvoiceRepositoryPGTestSuite) exec(query string, args ...any) { + s.T().Helper() + execSQL(s.T(), s.ctx, s.client, query, args...) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) orgID(name string) string { + s.T().Helper() + return scalarSQL(s.T(), s.ctx, s.client, `SELECT id FROM organizations WHERE name = $1`, name) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) customerID(name string) string { + s.T().Helper() + return scalarSQL(s.T(), s.ctx, s.client, `SELECT id FROM billing_customers WHERE name = $1`, name) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) invoiceID(hostedURL string) string { + s.T().Helper() + return scalarSQL(s.T(), s.ctx, s.client, `SELECT id FROM billing_invoices WHERE hosted_url = $1`, hostedURL) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) customer(name, orgName string) { + s.T().Helper() + s.exec(`INSERT INTO billing_customers (org_id, provider_id, name, email) + VALUES ($1, $2, $2, $2)`, s.orgID(orgName), name) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) invoice(customer, hostedURL, state string) { + s.T().Helper() + s.exec(`INSERT INTO billing_invoices (customer_id, provider_id, amount, currency, hosted_url, state) + VALUES ((SELECT id FROM billing_customers WHERE name = $1), $2, 100, 'usd', $2, $3)`, + customer, hostedURL, state) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) links(invoices []invoice.Invoice) []string { + out := make([]string, 0, len(invoices)) + for _, i := range invoices { + out = append(out, i.HostedURL) + } + return out +} + +func (s *BillingInvoiceRepositoryPGTestSuite) TestGetByIDSkipsDeleted() { + got, err := s.repository.GetByID(s.ctx, s.invoiceID("bi-inv-live")) + s.Require().NoError(err) + s.Equal("bi-inv-live", got.HostedURL) + + _, err = s.repository.GetByID(s.ctx, s.invoiceID("bi-inv-deleted")) + s.ErrorIs(err, invoice.ErrNotFound) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) TestListSkipsDeletedInvoicesAndCountsAgree() { + page := pagination.NewPagination(1, 10) + got, err := s.repository.List(s.ctx, invoice.Filter{ + CustomerID: s.customerID("bi-cust-live"), + Pagination: page, + }) + s.Require().NoError(err) + s.Equal([]string{"bi-inv-live"}, s.links(got)) + s.Equal(int32(1), page.Count, "the count statement carries the same filter as the rows") +} + +func (s *BillingInvoiceRepositoryPGTestSuite) TestListReadsOnlyTheInvoiceTable() { + // List has no join, so an invoice of a soft-deleted customer still lists. + // That matches every other single-table List in the store. The admin + // search below is where the customer and organization are checked. + got, err := s.repository.List(s.ctx, invoice.Filter{CustomerID: s.customerID("bi-cust-gone")}) + s.Require().NoError(err) + s.Equal([]string{"bi-inv-orphan"}, s.links(got)) +} + +func (s *BillingInvoiceRepositoryPGTestSuite) TestSearchSkipsDeletedInvoicesCustomersAndOrgs() { + res, err := s.repository.Search(s.ctx, &rql.Query{Limit: 50}) + s.Require().NoError(err) + out := make([]string, 0, len(res)) + for _, i := range res { + out = append(out, i.InvoiceLink) + } + s.Equal([]string{"bi-inv-live"}, out) +} + +func TestBillingInvoiceRepositoryPG(t *testing.T) { + suite.Run(t, new(BillingInvoiceRepositoryPGTestSuite)) +} diff --git a/internal/store/postgres/billing_invoice_repository_test.go b/internal/store/postgres/billing_invoice_repository_test.go index 904d92ddb..91bd7fc8a 100644 --- a/internal/store/postgres/billing_invoice_repository_test.go +++ b/internal/store/postgres/billing_invoice_repository_test.go @@ -25,7 +25,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 20, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") LIMIT $1 OFFSET $2`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL)) LIMIT $1 OFFSET $2`, wantParams: []any{int64(10), int64(20)}, wantErr: false, }, @@ -42,7 +42,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 50, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE ("billing_invoices"."amount" >= $1) LIMIT $2 OFFSET $3`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL) AND ("billing_invoices"."amount" >= $1)) LIMIT $2 OFFSET $3`, wantParams: []any{int64(1000), int64(10), int64(50)}, wantErr: false, }, @@ -60,7 +60,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 30, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."state" = $1) AND ((CAST("billing_invoices"."state" AS TEXT) ILIKE $2) OR (CAST("billing_invoices"."currency" AS TEXT) ILIKE $3) OR (CAST("billing_invoices"."amount" AS TEXT) ILIKE $4) OR (CAST("organizations"."name" AS TEXT) ILIKE $5) OR (CAST("organizations"."title" AS TEXT) ILIKE $6))) LIMIT $7 OFFSET $8`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL) AND ("billing_invoices"."state" = $1) AND ((CAST("billing_invoices"."state" AS TEXT) ILIKE $2) OR (CAST("billing_invoices"."currency" AS TEXT) ILIKE $3) OR (CAST("billing_invoices"."amount" AS TEXT) ILIKE $4) OR (CAST("organizations"."name" AS TEXT) ILIKE $5) OR (CAST("organizations"."title" AS TEXT) ILIKE $6))) LIMIT $7 OFFSET $8`, wantParams: []any{"paid", "%test%", "%test%", "%test%", "%test%", "%test%", int64(10), int64(30)}, wantErr: false, }, @@ -76,7 +76,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 40, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") ORDER BY "billing_invoices"."state" DESC LIMIT $1 OFFSET $2`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL)) ORDER BY "billing_invoices"."state" DESC LIMIT $1 OFFSET $2`, wantParams: []any{int64(10), int64(40)}, wantErr: false, }, @@ -92,7 +92,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 40, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") ORDER BY "organizations"."name" ASC LIMIT $1 OFFSET $2`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL)) ORDER BY "organizations"."name" ASC LIMIT $1 OFFSET $2`, wantParams: []any{int64(10), int64(40)}, wantErr: false, }, @@ -124,7 +124,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 1, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."state" IS NULL) OR ("billing_invoices"."state" = $1)) LIMIT $2 OFFSET $3`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL) AND (("billing_invoices"."state" IS NULL) OR ("billing_invoices"."state" = $1))) LIMIT $2 OFFSET $3`, wantParams: []any{"", int64(10), int64(1)}, wantErr: false, }, @@ -140,7 +140,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 1, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."state" IS NOT NULL) AND ("billing_invoices"."state" != $1)) LIMIT $2 OFFSET $3`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL) AND (("billing_invoices"."state" IS NOT NULL) AND ("billing_invoices"."state" != $1))) LIMIT $2 OFFSET $3`, wantParams: []any{"", int64(10), int64(1)}, wantErr: false, }, @@ -157,7 +157,7 @@ func TestBillingInvoiceRepository_prepareDataQuery(t *testing.T) { Limit: 10, Offset: 1, }, - wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE ("billing_invoices"."state" LIKE $1) LIMIT $2 OFFSET $3`, + wantSQL: `SELECT "billing_invoices"."id" AS "id", "billing_invoices"."amount" AS "amount", "billing_invoices"."currency" AS "currency", "billing_invoices"."state" AS "state", "billing_invoices"."hosted_url" AS "hosted_url", "billing_invoices"."created_at" AS "created_at", "organizations"."id" AS "org_id", "organizations"."name" AS "org_name", "organizations"."title" AS "org_title" FROM "billing_invoices" INNER JOIN "billing_customers" ON ("billing_invoices"."customer_id" = "billing_customers"."id") INNER JOIN "organizations" ON ("billing_customers"."org_id" = "organizations"."id") WHERE (("billing_invoices"."deleted_at" IS NULL) AND ("billing_customers"."deleted_at" IS NULL) AND ("organizations"."deleted_at" IS NULL) AND ("billing_invoices"."state" LIKE $1)) LIMIT $2 OFFSET $3`, wantParams: []any{"%paid%", int64(10), int64(1)}, wantErr: false, }, @@ -270,7 +270,7 @@ func TestProjectRepository_ListTotalCount_PreparedSQLForwardsParams(t *testing.T // Mirror of billing_invoice_repository.go::List totalCount path. func TestBillingInvoiceRepository_ListTotalCount_PreparedSQLForwardsParams(t *testing.T) { - stmt := dialect.Select().From(TABLE_BILLING_INVOICES).Prepared(true).Where(goqu.Ex{"customer_id": "cust-1"}) + stmt := fromLive(TABLE_BILLING_INVOICES).Prepared(true).Where(goqu.Ex{"customer_id": "cust-1"}) stmt = stmt.Where(goqu.Ex{"amount": goqu.Op{"gt": 0}}) stmt = stmt.Where(goqu.Ex{"state": "paid"}) @@ -279,5 +279,6 @@ func TestBillingInvoiceRepository_ListTotalCount_PreparedSQLForwardsParams(t *te require.NoError(t, err) assert.True(t, strings.Contains(sql, "$1"), "SQL must use $N placeholders, got: %s", sql) + assert.Contains(t, sql, `"billing_invoices"."deleted_at" IS NULL`) assert.Equal(t, []any{"cust-1", int64(0), "paid"}, params) }