package payments import ( "context" "fmt" "math" "slices" "strings" ) // pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries // where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing] // returns before rendering the /offer/ page; the backend only produces the tables, never the marker. const pricingMarker = "<#pricing_template#>" // OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the // active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK // votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The // result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]), // so a steady-state read issues no query — only the first read after an edit reprojects. The render // sidecar fetches it and splices it into the offer markdown at the pricing marker. func (s *Service) OfferPricing(ctx context.Context) (string, error) { s.offerMu.Lock() defer s.offerMu.Unlock() if s.offerFresh { return s.offerMD, nil } md, err := s.buildOfferPricing(ctx) if err != nil { return "", err } s.offerMD = md s.offerFresh = true return md, nil } // markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing] // read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it. func (s *Service) markOfferStale() { s.offerMu.Lock() s.offerFresh = false s.offerMu.Unlock() } // buildOfferPricing loads the active catalog and projects it into the offer tables. func (s *Service) buildOfferPricing(ctx context.Context) (string, error) { entries, err := s.store.loadCatalog(ctx) if err != nil { return "", err } return projectOfferPricing(entries), nil } // projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries // the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip // price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints // only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within // each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is // 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 sorted { if isPackEntry(e) { packs = append(packs, e) } else { values = append(values, e) } } var b strings.Builder if len(packs) > 0 { b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n") b.WriteString("| --- | ---: | ---: | ---: |\n") for _, e := range packs { fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", offerCell(e.title), offerPrice(e, string(SourceDirect), CurrencyRUB), offerPrice(e, string(SourceVK), CurrencyVote), offerPrice(e, string(SourceTelegram), CurrencyStar), ) } } if len(values) > 0 { if len(packs) > 0 { b.WriteString("\n") } b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | «Фишки» |\n") b.WriteString("| --- | ---: |\n") for _, e := range values { fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip)) } } return strings.TrimRight(b.String(), "\n") } // offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]). func offerValueGroup(e catalogEntry) int { hasHints, hasNoAds, hasTournament := false, false, false for _, a := range e.atoms { switch a.atomType { case "hints": hasHints = true case "noads_days": hasNoAds = true case "tournament": hasTournament = true } } return valueGroup(hasHints, hasNoAds, hasTournament) } // valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the // admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything // carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids // an active one), so group 3 is empty today; the rank reserves their place for when the tournament // economy lands. A value with no recognised benefit atom sorts after the known groups (defensive — // the catalog shape forbids it). func valueGroup(hasHints, hasNoAds, hasTournament bool) int { switch { case hasTournament: return 3 case hasHints && hasNoAds: return 2 case hasNoAds: return 1 case hasHints: return 0 default: return 4 } } // offerSortAmount returns the entry's price in the given method and currency for ordering, or // math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading. func offerSortAmount(e catalogEntry, method string, cur Currency) int64 { if amt, ok := offerAmount(e, method, cur); ok { return amt } return math.MaxInt64 } // isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds // chips with money) rather than being a chip-priced value. func isPackEntry(e catalogEntry) bool { for _, a := range e.atoms { if a.atomType == atomChips { return true } } return false } // offerPrice formats the entry's price for the given payment method and currency as a major-unit // string, or an em dash when the entry carries no such price. func offerPrice(e catalogEntry, method string, cur Currency) string { amt, ok := offerAmount(e, method, cur) if !ok { return "—" } m, err := MoneyFromMinor(amt, cur) if err != nil { return "—" } return m.Major() } // offerAmount returns the raw minor-unit amount of the entry's price for the payment method and // currency, and whether such a price exists. func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) { for _, pr := range e.prices { if pr.method == method && pr.currency == cur { return pr.amount, true } } return 0, false } // offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as // literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows // into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not // sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the // page), the markdown table pipe and the row newline, and the link brackets (a title must never // become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the // exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&" does not // double-escape the entities the other rules emit. var offerCellReplacer = strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, "'", "'", "|", `\|`, "[", `\[`, "]", `\]`, "\n", " ", ) // offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of // the public offer (see [offerCellReplacer]). func offerCell(s string) string { return offerCellReplacer.Replace(s) }