From 6fd013262ed80dee4f96e9a1b0eddeb0b96fc5f9 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Mon, 3 Aug 2026 15:19:38 +0530 Subject: [PATCH 1/3] fix(billing): include plans without products in plan listings --- .../store/postgres/billing_plan_repository.go | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/internal/store/postgres/billing_plan_repository.go b/internal/store/postgres/billing_plan_repository.go index 4dc965eb6..2b97e5a0d 100644 --- a/internal/store/postgres/billing_plan_repository.go +++ b/internal/store/postgres/billing_plan_repository.go @@ -54,20 +54,22 @@ type PlanProductRow struct { PlanUpdatedAt time.Time `db:"plan_updated_at"` PlanDeletedAt *time.Time `db:"plan_deleted_at"` - ProductID string `db:"product_id"` - ProductProviderID string `db:"product_provider_id"` + // product columns are pointers because a left join leaves them null for a + // plan that has no products + ProductID *string `db:"product_id"` + ProductProviderID *string `db:"product_provider_id"` ProductPlanIDs pq.StringArray `db:"product_plan_ids"` - ProductName string `db:"product_name"` + ProductName *string `db:"product_name"` ProductTitle *string `db:"product_title"` ProductDescription *string `db:"product_description"` - ProductBehavior string `db:"product_behavior"` + ProductBehavior *string `db:"product_behavior"` ProductConfig BehaviorConfig `db:"product_config"` - ProductState string `db:"product_state"` + ProductState *string `db:"product_state"` ProductMetadata types.NullJSONText `db:"product_metadata"` - ProductCreatedAt time.Time `db:"product_created_at"` - ProductUpdatedAt time.Time `db:"product_updated_at"` + ProductCreatedAt *time.Time `db:"product_created_at"` + ProductUpdatedAt *time.Time `db:"product_updated_at"` ProductDeletedAt *time.Time `db:"product_deleted_at"` } @@ -91,20 +93,34 @@ func (pr PlanProductRow) getPlan() (plan.Plan, error) { return pln.transform() } +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +func derefTime(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + return *t +} + func (pr PlanProductRow) getProduct() (product.Product, error) { prod := Product{ - ID: pr.ProductID, - ProviderID: pr.ProductProviderID, + ID: derefString(pr.ProductID), + ProviderID: derefString(pr.ProductProviderID), PlanIDs: pr.ProductPlanIDs, - Name: pr.ProductName, + Name: derefString(pr.ProductName), Title: pr.ProductTitle, Description: pr.ProductDescription, - Behavior: pr.ProductBehavior, + Behavior: derefString(pr.ProductBehavior), Config: pr.ProductConfig, - State: pr.ProductState, + State: derefString(pr.ProductState), Metadata: pr.ProductMetadata, - CreatedAt: pr.ProductCreatedAt, - UpdatedAt: pr.ProductUpdatedAt, + CreatedAt: derefTime(pr.ProductCreatedAt), + UpdatedAt: derefTime(pr.ProductUpdatedAt), DeletedAt: pr.ProductDeletedAt, } @@ -353,8 +369,9 @@ func (r BillingPlanRepository) List(ctx context.Context, filter plan.Filter) ([] func (r BillingPlanRepository) ListWithProducts(ctx context.Context, filter plan.Filter) ([]plan.Plan, error) { pln := goqu.T(TABLE_BILLING_PLANS).As("plan") prd := goqu.T(TABLE_BILLING_PRODUCTS).As("product") + // a left join keeps plans that have no products; an inner join would drop them stmt := dialect.From(pln). - Join( + LeftJoin( prd, goqu.On( goqu.L("CAST(plan.id AS text)").Eq(goqu.L("ANY(product.plan_ids)")), @@ -437,19 +454,19 @@ func (r BillingPlanRepository) ListWithProducts(ctx context.Context, filter plan if err != nil { return nil, err } - - prod, err := row.getProduct() - if err != nil { - return nil, err + if existing, ok := planMap[pln.ID]; ok { + pln = existing } - planInMap, exists := planMap[pln.ID] - if exists { - planInMap.Products = append(planInMap.Products, prod) - } else { + // a left join gives a null product id for a plan that has no products + if row.ProductID != nil { + prod, err := row.getProduct() + if err != nil { + return nil, err + } pln.Products = append(pln.Products, prod) - planMap[pln.ID] = pln } + planMap[pln.ID] = pln } plans := []plan.Plan{} From fade520fe796590f5ff6954cafbe6818a23c2acf Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 4 Aug 2026 11:35:12 +0530 Subject: [PATCH 2/3] fix(billing): select plan deleted_at from plans table in ListWithProducts --- internal/store/postgres/billing_plan_repository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/store/postgres/billing_plan_repository.go b/internal/store/postgres/billing_plan_repository.go index 2b97e5a0d..c60a80ab0 100644 --- a/internal/store/postgres/billing_plan_repository.go +++ b/internal/store/postgres/billing_plan_repository.go @@ -388,7 +388,7 @@ func (r BillingPlanRepository) ListWithProducts(ctx context.Context, filter plan pln.Col("metadata").As("plan_metadata"), pln.Col("created_at").As("plan_created_at"), pln.Col("updated_at").As("plan_updated_at"), - prd.Col("deleted_at").As("plan_deleted_at"), + pln.Col("deleted_at").As("plan_deleted_at"), prd.Col("id").As("product_id"), prd.Col("provider_id").As("product_provider_id"), prd.Col("name").As("product_name"), From 81de9441bd12bb14117724ae6bbb259f9a9a4733 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 4 Aug 2026 11:49:33 +0530 Subject: [PATCH 3/3] refactor(billing): reuse null converter helpers in plan product row --- .../store/postgres/billing_plan_repository.go | 28 +++++-------------- internal/store/postgres/null_converters.go | 9 ++++++ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/internal/store/postgres/billing_plan_repository.go b/internal/store/postgres/billing_plan_repository.go index c60a80ab0..d4062b143 100644 --- a/internal/store/postgres/billing_plan_repository.go +++ b/internal/store/postgres/billing_plan_repository.go @@ -93,34 +93,20 @@ func (pr PlanProductRow) getPlan() (plan.Plan, error) { return pln.transform() } -func derefString(s *string) string { - if s == nil { - return "" - } - return *s -} - -func derefTime(t *time.Time) time.Time { - if t == nil { - return time.Time{} - } - return *t -} - func (pr PlanProductRow) getProduct() (product.Product, error) { prod := Product{ - ID: derefString(pr.ProductID), - ProviderID: derefString(pr.ProductProviderID), + ID: ptrToString(pr.ProductID), + ProviderID: ptrToString(pr.ProductProviderID), PlanIDs: pr.ProductPlanIDs, - Name: derefString(pr.ProductName), + Name: ptrToString(pr.ProductName), Title: pr.ProductTitle, Description: pr.ProductDescription, - Behavior: derefString(pr.ProductBehavior), + Behavior: ptrToString(pr.ProductBehavior), Config: pr.ProductConfig, - State: derefString(pr.ProductState), + State: ptrToString(pr.ProductState), Metadata: pr.ProductMetadata, - CreatedAt: derefTime(pr.ProductCreatedAt), - UpdatedAt: derefTime(pr.ProductUpdatedAt), + CreatedAt: ptrToTime(pr.ProductCreatedAt), + UpdatedAt: ptrToTime(pr.ProductUpdatedAt), DeletedAt: pr.ProductDeletedAt, } diff --git a/internal/store/postgres/null_converters.go b/internal/store/postgres/null_converters.go index 5751188bb..a5e47b5d3 100644 --- a/internal/store/postgres/null_converters.go +++ b/internal/store/postgres/null_converters.go @@ -3,6 +3,7 @@ package postgres import ( "database/sql" "encoding/json" + "time" "github.com/jmoiron/sqlx/types" "github.com/raystack/frontier/pkg/metadata" @@ -74,6 +75,14 @@ func ptrToString(ptr *string) string { return "" } +// ptrToTime safely converts a time pointer to a time.Time, returning zero time if nil +func ptrToTime(ptr *time.Time) time.Time { + if ptr != nil { + return *ptr + } + return time.Time{} +} + // unmarshalNullJSONText unmarshals NullJSONText to map[string]any func unmarshalNullJSONText(metadata types.NullJSONText) (map[string]any, error) { if !metadata.Valid {