b6c2598710
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 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with its price. Move /offer/ off the static landing container to the render sidecar: it splices the live catalog price list (§4.4) into the owner-edited ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one renderer, no drift, always matching the current catalog with no redeploy. - backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list) projects the active catalog into two markdown tables — chip packs priced per rail (roubles / VK votes / Telegram Stars) and chip-priced values — through payments.Money so no float reaches the page. Cached in memory: warmed at boot, marked stale on every catalog mutation, so a served render issues no query. - renderer: GET /offer/ fetches the tables and substitutes them at the <#pricing_template#> marker, then renders; offer_ru.md is baked into the image and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed. - caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/ handlers and the vite emit-offer plugin. - offer: fill §4.3 (the chip-payment wording) and drop the in-page back link. - landing footer: a feedback link (the offer's Telegram contact) beside the offer link. - docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe, unit + integration + node tests.
126 lines
4.6 KiB
Go
126 lines
4.6 KiB
Go
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, "|", "\\|")
|
|
}
|