diff --git a/controllers/productController.go b/controllers/productController.go index 5643cc8..9209502 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -402,6 +402,10 @@ func (ctl *ProductController) GetAllProducts(c *fiber.Ctx) error { if err == nil && isAppRequest(c) { details = services.InStockOnlyGrouped(details) + // And nothing a shopper cannot be charged for. An unpriced product is + // unfinished, not free, and ₹0 on a phone reads as the latter. + details = services.PricedOnly(details) + // One product, three sizes — not three products. // // A shop stocking Aachi Baby Fryums in 100g, 500g and 1kg has three diff --git a/services/productVisibility.go b/services/productVisibility.go index 30eff0d..82f887e 100644 --- a/services/productVisibility.go +++ b/services/productVisibility.go @@ -117,3 +117,43 @@ func WithoutVariantChildren(groups []models.Tenantproducts, isChild map[int]bool } return out } + +// PricedOnly drops the products a shopper cannot be charged for. +// +// The companion to InStockOnlyGrouped, and the same argument: being in a shop's +// catalogue is not the same as being on sale. A product with no price is not +// free, it is unfinished — but ₹0 on a phone reads as free, and a shopper who +// taps it either expects to pay nothing or decides the shop is broken. +// +// ── Where the ₹0 products come from ───────────────────────────────────────── +// +// Import used to write no price at all, and 159 of the 1,572 catalogue rows +// still carry no price for it to copy — loose produce has none by design, and +// some scraped rows never had one. So an import lands at ₹0, the merchant adds +// stock, and the product goes on sale at nothing. Measured 2026-09-03: Kmart's +// entire customer-facing catalogue was five products, every one at ₹0. +// +// The merchant is not left guessing: the console lists these as "unpriced" with +// a Set price button, and pricing one puts it on sale immediately. +// +// Deliberately NOT applied to the console, for the same reason as the stock +// filter — an unpriced product is precisely what a merchant needs to see in +// order to fix it. +func PricedOnly(groups []models.Tenantproducts) []models.Tenantproducts { + out := make([]models.Tenantproducts, 0, len(groups)) + for _, group := range groups { + kept := make([]models.Products, 0, len(group.Products)) + for _, product := range group.Products { + // `Price` is the effective figure the same query computed — the + // outlet's own price, falling back to the master. Filtering on the + // number that is DISPLAYED means the list and the label cannot + // disagree. + if product.Price > 0 { + kept = append(kept, product) + } + } + group.Products = kept + out = append(out, group) + } + return out +} diff --git a/services/productVisibility_test.go b/services/productVisibility_test.go index 83fc27e..b3d8b74 100644 --- a/services/productVisibility_test.go +++ b/services/productVisibility_test.go @@ -715,3 +715,55 @@ func TestFilteringChildrenDoesNotDisturbTheCallersSlice(t *testing.T) { t.Errorf("the caller's slice was modified") } } + +/* +A product with no price is unfinished, not free. + +Import writes whatever the catalogue carries, and 159 of the 1,572 catalogue +rows carry no price — loose produce has none by design, some scraped rows never +had one. So a product can reach a shelf at ₹0 and go on sale at nothing. + +Measured 2026-09-03: Kmart's entire customer-facing catalogue was five products, +every one at ₹0, all with stock. +*/ + +func TestAnUnpricedProductIsNotOfferedToShoppers(t *testing.T) { + groups := []models.Tenantproducts{{Products: []models.Products{ + {Productid: 1, Price: 45}, + {Productid: 2, Price: 0}, + {Productid: 3, Price: 12.5}, + }}} + got := PricedOnly(groups) + if len(got[0].Products) != 2 { + t.Fatalf("expected the two priced products, got %v", got[0].Products) + } + for _, product := range got[0].Products { + if product.Price <= 0 { + t.Errorf("product %d has no price and was still offered", product.Productid) + } + } +} + +func TestANegativePriceIsNotAPrice(t *testing.T) { + got := PricedOnly([]models.Tenantproducts{{Products: []models.Products{{Productid: 1, Price: -5}}}}) + if len(got[0].Products) != 0 { + t.Error("a negative price was treated as sellable") + } +} + +func TestAShopWithNothingPricedStillExists(t *testing.T) { + // The shop is open; it has nothing ready to sell. Dropping the group would + // say the shop is not there, which is a different and wrong statement. + got := PricedOnly([]models.Tenantproducts{{Products: []models.Products{{Productid: 1, Price: 0}}}}) + if len(got) != 1 || len(got[0].Products) != 0 { + t.Errorf("expected the shop to survive with nothing on offer, got %v", got) + } +} + +func TestPricingFilterDoesNotDisturbTheCallersSlice(t *testing.T) { + original := []models.Tenantproducts{{Products: []models.Products{{Productid: 1, Price: 10}, {Productid: 2, Price: 0}}}} + _ = PricedOnly(original) + if len(original[0].Products) != 2 { + t.Error("the caller's slice was modified") + } +}