2c2316fb5e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange values, grouped and price-sorted the same way projectOfferPricing lists them, so the /catalog console mirrors what a buyer sees. Internal cosmetics only — no product behaviour change. The value-group order moves to a shared helper (valueGroup) so the offer and the admin list cannot drift.
219 lines
8.1 KiB
Go
219 lines
8.1 KiB
Go
package payments
|
|
|
|
import (
|
|
"cmp"
|
|
"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 {
|
|
var packs, values []catalogEntry
|
|
for _, e := range entries {
|
|
if isPackEntry(e) {
|
|
packs = append(packs, e)
|
|
} else {
|
|
values = append(values, e)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
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)
|
|
}
|