From b6af682381eb1e884e740ea85221a626e259bbc0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 14 Jul 2026 10:23:42 +0200 Subject: [PATCH] refactor(payments): one canonical catalog order for storefront, offer, admin; admin active/all toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet storefront listed products in creation order — its purchases and its chip-exchange values were unsorted, unlike the admin console and the public offer. Extract the ordering (chip packs first by rouble price, then chip-priced values grouped hints → no-ads → combo → tournament and by chip price) into one comparator, compareCatalogRank over a small rank key, and apply it at all three sites (projectCatalog, projectOfferPricing, SortAdminCatalog) — so the subgroup ranking (valueGroup) and the full order now live in one place. The rewarded "watch for chips" CTA is a wallet-driven element rendered above the packs, so it is unaffected and stays on top. Also add the admin catalog active/all toggle: AdminCatalog takes includeInactive, the console shows active products by default and ?all=1 lists archived ones too (the detail and grant forms keep loading all products). Tests: a storefront canonical-order unit test (reproduced the unsorted bug first); an integration test for the active/all toggle. Existing offer/admin order tests unchanged — behaviour preserved. --- .../templates/pages/catalog.gohtml | 1 + backend/internal/adminconsole/views.go | 3 + .../internal/inttest/catalog_editor_test.go | 33 +++++++++- backend/internal/payments/catalog.go | 5 ++ backend/internal/payments/catalog_admin.go | 24 +++----- backend/internal/payments/catalog_order.go | 61 +++++++++++++++++++ backend/internal/payments/catalog_test.go | 50 +++++++++++++++ backend/internal/payments/offer.go | 20 ++---- .../internal/payments/store_catalog_admin.go | 10 ++- .../internal/server/handlers_admin_catalog.go | 14 +++-- 10 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 backend/internal/payments/catalog_order.go diff --git a/backend/internal/adminconsole/templates/pages/catalog.gohtml b/backend/internal/adminconsole/templates/pages/catalog.gohtml index a738175..42d081b 100644 --- a/backend/internal/adminconsole/templates/pages/catalog.gohtml +++ b/backend/internal/adminconsole/templates/pages/catalog.gohtml @@ -22,6 +22,7 @@

Products

+

Showing {{if .ShowAll}}all products (active + archived) — active only{{else}}active products — show all (incl. archived){{end}}.

diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index cdc8676..d39619d 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -677,6 +677,9 @@ type FeedbackDetailView struct { // CatalogView is the product-catalog list page. type CatalogView struct { Products []ProductRow + // ShowAll reports whether archived products are listed alongside active ones (the active/all + // toggle); it drives the toggle link and the list heading. + ShowAll bool } // ProductRow is one product in the catalog list: its composition, prices, the archived flag diff --git a/backend/internal/inttest/catalog_editor_test.go b/backend/internal/inttest/catalog_editor_test.go index 02379a7..12c7666 100644 --- a/backend/internal/inttest/catalog_editor_test.go +++ b/backend/internal/inttest/catalog_editor_test.go @@ -16,7 +16,7 @@ import ( // productByTitle finds a catalog product by its (unique in the test) title. func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct { t.Helper() - all, err := pay.AdminCatalog(context.Background()) + all, err := pay.AdminCatalog(context.Background(), true) if err != nil { t.Fatalf("admin catalog: %v", err) } @@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) { if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") { t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product")) } - if _, err := pay.AdminCatalog(ctx); err != nil { + if _, err := pay.AdminCatalog(ctx, true); err != nil { t.Fatalf("catalog: %v", err) } @@ -95,3 +95,32 @@ func TestConsoleCatalogEditor(t *testing.T) { t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete")) } } + +// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and +// includes the archived ones under ?all=1 (the active/all toggle). +func TestConsoleCatalogActiveToggle(t *testing.T) { + srv, _, _ := bannerServer(t) + h := srv.Handler() + const origin = "http://admin.test" + const catalog = "http://admin.test/_gm/catalog" + + // An active value and an archived one (created without the active flag). + if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK { + t.Fatal("create active failed") + } + if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK { + t.Fatal("create archived failed") + } + + // Default view: active only. + if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") { + t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)", + strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived")) + } + + // ?all=1: active and archived. + if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") { + t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)", + strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived")) + } +} diff --git a/backend/internal/payments/catalog.go b/backend/internal/payments/catalog.go index abebfff..46da876 100644 --- a/backend/internal/payments/catalog.go +++ b/backend/internal/payments/catalog.go @@ -38,6 +38,11 @@ const atomChips = "chips" // method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has // no method, so only values show; buying is gated server-side regardless. func projectCatalog(entries []catalogEntry, cxt Context) CatalogView { + // Present products in the canonical listing order shared with the offer and the admin console + // (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the + // client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result + // it does not reuse. + sortCatalogEntries(entries) var out CatalogView for _, e := range entries { isPack := false diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go index e2d351c..ef4c850 100644 --- a/backend/internal/payments/catalog_admin.go +++ b/backend/internal/payments/catalog_admin.go @@ -1,7 +1,6 @@ package payments import ( - "cmp" "context" "errors" "fmt" @@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error { return nil } -// AdminCatalog lists every product (active and archived) with its composition, prices and whether it -// has been transacted, for the admin editor. Read uncached, straight from the catalog tables. -func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) { - return s.store.adminCatalog(ctx) +// AdminCatalog lists products with their composition, prices and whether each has been transacted, +// for the admin editor. includeInactive adds the archived products to the active ones (the console's +// active/all toggle); with it false only active products are returned. Read uncached, straight from +// the catalog tables. +func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) { + return s.store.adminCatalog(ctx, includeInactive) } // SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products @@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) { // products the same as active ones (the admin list shows both). func SortAdminCatalog(products []AdminProduct) { slices.SortStableFunc(products, func(a, b AdminProduct) int { - if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb { - if pa { - return -1 // packs (sales) before values (chip exchange) - } - return 1 - } else if pa { - return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB)) - } - if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 { - return d - } - return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip)) + return compareCatalogRank(adminRank(a), adminRank(b)) }) } diff --git a/backend/internal/payments/catalog_order.go b/backend/internal/payments/catalog_order.go new file mode 100644 index 0000000..4e93e4f --- /dev/null +++ b/backend/internal/payments/catalog_order.go @@ -0,0 +1,61 @@ +package payments + +import ( + "cmp" + "slices" +) + +// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its +// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by +// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured +// row sorts last rather than leading). +type catalogRank struct { + pack bool + group int + amount int64 +} + +// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]), +// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs +// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo → +// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share +// one section). Callers use it through [entryRank] / [adminRank], which build the key from each +// product shape, so the subgroup and ordering rules live in exactly one place. +func compareCatalogRank(a, b catalogRank) int { + if a.pack != b.pack { + if a.pack { + return -1 // packs (sales) before values (chip exchange) + } + return 1 + } + if !a.pack { + if d := cmp.Compare(a.group, b.group); d != 0 { + return d + } + } + return cmp.Compare(a.amount, b.amount) +} + +// entryRank builds the canonical rank of a storefront / offer catalog entry. +func entryRank(e catalogEntry) catalogRank { + if isPackEntry(e) { + return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)} + } + return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)} +} + +// adminRank builds the canonical rank of an admin catalog product. +func adminRank(p AdminProduct) catalogRank { + if adminIsPack(p) { + return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)} + } + return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)} +} + +// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order +// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order. +func sortCatalogEntries(entries []catalogEntry) { + slices.SortStableFunc(entries, func(a, b catalogEntry) int { + return compareCatalogRank(entryRank(a), entryRank(b)) + }) +} diff --git a/backend/internal/payments/catalog_test.go b/backend/internal/payments/catalog_test.go index 132fa9a..d751857 100644 --- a/backend/internal/payments/catalog_test.go +++ b/backend/internal/payments/catalog_test.go @@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) { } } +// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order +// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped +// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin +// console, regardless of catalog creation order. +func TestProjectCatalog_CanonicalOrder(t *testing.T) { + pack := func(title string, rub int64) catalogEntry { + return catalogEntry{ + id: uuid.New(), + title: title, + atoms: []atomQty{{atomType: atomChips, quantity: 1}}, + prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}}, + } + } + value := func(title string, chips int64, atoms ...string) catalogEntry { + e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}} + for _, a := range atoms { + e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1}) + } + return e + } + // Deliberately shuffled on input; the projection must impose the canonical order. + entries := []catalogEntry{ + pack("packDear", 30000), + value("bundle", 500, "hints", "noads_days"), + pack("packCheap", 10000), + value("adsOnly", 150, "noads_days"), + value("hintsBig", 200, "hints"), + value("hintsSmall", 50, "hints"), + } + got := projectCatalog(entries, Context{Kind: SourceDirect}) + want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"} + if len(got.Products) != len(want) { + t.Fatalf("projected %d products, want %d", len(got.Products), len(want)) + } + for i, p := range got.Products { + if p.Title != want[i] { + t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products)) + } + } +} + +// productTitles extracts the projected product titles for a failure message. +func productTitles(products []CatalogProduct) []string { + out := make([]string, len(products)) + for i, p := range products { + out[i] = p.Title + } + return out +} + // TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first, // ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and // ascending by chip price within a group. Stable and applied to active + archived alike. diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go index f4c6392..c89b36e 100644 --- a/backend/internal/payments/offer.go +++ b/backend/internal/payments/offer.go @@ -1,7 +1,6 @@ package payments import ( - "cmp" "context" "fmt" "math" @@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) { // omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles // show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash. func projectOfferPricing(entries []catalogEntry) string { + // Order the whole catalog once by the shared canonical rank, then split it into the two offer + // tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the + // caller's slice is left untouched. + sorted := slices.Clone(entries) + sortCatalogEntries(sorted) var packs, values []catalogEntry - for _, e := range entries { + for _, e := range sorted { if isPackEntry(e) { packs = append(packs, e) } else { @@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string { } } - // Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price - // sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties. - slices.SortStableFunc(packs, func(a, b catalogEntry) int { - return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB)) - }) - slices.SortStableFunc(values, func(a, b catalogEntry) int { - if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 { - return d - } - return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip)) - }) - var b strings.Builder if len(packs) > 0 { b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") diff --git a/backend/internal/payments/store_catalog_admin.go b/backend/internal/payments/store_catalog_admin.go index c63f8c1..57a5ffe 100644 --- a/backend/internal/payments/store_catalog_admin.go +++ b/backend/internal/payments/store_catalog_admin.go @@ -19,11 +19,17 @@ import ( // row references — it must be archived instead, to keep the append-only ledger resolvable. var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete") -// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag. -func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) { +// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the +// archived products to the active ones; with it false only active products are returned. +func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) { + where := table.Product.Active.IS_TRUE() + if includeInactive { + where = postgres.Bool(true) + } var prods []model.Product if err := postgres.SELECT(table.Product.AllColumns). FROM(table.Product). + WHERE(where). ORDER_BY(table.Product.CreatedAt.ASC()). QueryContext(ctx, s.db, &prods); err != nil { return nil, fmt.Errorf("payments: admin list products: %w", err) diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go index 9912d08..732d72f 100644 --- a/backend/internal/server/handlers_admin_catalog.go +++ b/backend/internal/server/handlers_admin_catalog.go @@ -33,10 +33,12 @@ var priceFields = []struct { {"price_chip", "", payments.CurrencyChip}, } -// consoleCatalog lists every product (active and archived) with its composition, prices, transacted -// flag, and the inline create form. +// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the +// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the +// active/all toggle). func (s *Server) consoleCatalog(c *gin.Context) { - products, err := s.payments.AdminCatalog(c.Request.Context()) + showAll := c.Query("all") == "1" + products, err := s.payments.AdminCatalog(c.Request.Context(), showAll) if err != nil { s.consoleError(c, err) return @@ -44,7 +46,7 @@ func (s *Server) consoleCatalog(c *gin.Context) { // Order the list like the public offer: sales (chip packs) first, then the chip-exchange values, // grouped and price-sorted the same way, so the console mirrors what a buyer sees. payments.SortAdminCatalog(products) - var view adminconsole.CatalogView + view := adminconsole.CatalogView{ShowAll: showAll} for _, p := range products { view.Products = append(view.Products, catalogRow(p)) } @@ -69,7 +71,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) { if !ok { return } - products, err := s.payments.AdminCatalog(c.Request.Context()) + products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product if err != nil { s.consoleError(c, err) return @@ -242,7 +244,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) { // bundles, including archived ones — chips and tournament products are excluded). func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView { fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}} - products, err := s.payments.AdminCatalog(ctx) + products, err := s.payments.AdminCatalog(ctx, true) if err != nil { return fv } -- 2.52.0
TitleStatusAtomsPrices