package payments import ( "context" "fmt" "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. Rows keep the catalog's creation order. 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 { var packs, values []string for _, e := range entries { if isPackEntry(e) { packs = append(packs, fmt.Sprintf("| %s | %s | %s | %s |", offerCell(e.title), offerPrice(e, string(SourceDirect), CurrencyRUB), offerPrice(e, string(SourceVK), CurrencyVote), offerPrice(e, string(SourceTelegram), CurrencyStar), )) } else { values = append(values, fmt.Sprintf("| %s | %s |", offerCell(e.title), offerPrice(e, "", CurrencyChip))) } } var b strings.Builder if len(packs) > 0 { b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n") b.WriteString("| --- | --- | --- | --- |\n") b.WriteString(strings.Join(packs, "\n")) b.WriteString("\n") } if len(values) > 0 { if len(packs) > 0 { b.WriteString("\n") } b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | «Фишки» |\n") b.WriteString("| --- | --- |\n") b.WriteString(strings.Join(values, "\n")) b.WriteString("\n") } return strings.TrimRight(b.String(), "\n") } // 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 { for _, pr := range e.prices { if pr.method == method && pr.currency == cur { m, err := MoneyFromMinor(pr.amount, pr.currency) if err != nil { return "—" } return m.Major() } } return "—" } // 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) }