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 "—" } // offerCell escapes an owner-entered title for a markdown table cell: a literal pipe would break the // column layout, and a newline would break the row. func offerCell(s string) string { s = strings.ReplaceAll(s, "\n", " ") return strings.ReplaceAll(s, "|", "\\|") }