feat(offer): live catalog price list in the public offer, served by the render sidecar
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
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.
This commit is contained in:
@@ -4,6 +4,7 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
|
||||
t.Error("deactivated product must not appear in the storefront")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
|
||||
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
|
||||
// with its per-rail prices, and archiving it through the service drops it from the next read.
|
||||
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
|
||||
svc := newPaymentsService()
|
||||
ctx := context.Background()
|
||||
title := "OfferTest " + uuid.NewString()
|
||||
id, err := svc.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: title,
|
||||
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
|
||||
Prices: []payments.PriceLine{
|
||||
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
|
||||
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
|
||||
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
|
||||
},
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create pack: %v", err)
|
||||
}
|
||||
|
||||
md, err := svc.OfferPricing(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("offer pricing: %v", err)
|
||||
}
|
||||
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
|
||||
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
|
||||
}
|
||||
|
||||
// Archiving through the service marks the cache stale; the next read must reproject without it.
|
||||
if err := svc.SetProductActive(ctx, id, false); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
md, err = svc.OfferPricing(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("offer pricing after archive: %v", err)
|
||||
}
|
||||
if strings.Contains(md, title) {
|
||||
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,11 @@ func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active boo
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return s.store.createProduct(ctx, in, active, s.clock())
|
||||
id, err := s.store.createProduct(ctx, in, active, s.clock())
|
||||
if err == nil {
|
||||
s.markOfferStale()
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
||||
@@ -166,7 +170,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.updateProduct(ctx, id, in, s.clock())
|
||||
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
||||
@@ -181,11 +189,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.store.setProductActive(ctx, id, active, s.clock())
|
||||
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
||||
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
||||
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||
return s.store.deleteProduct(ctx, id)
|
||||
if err := s.store.deleteProduct(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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, "|", "\\|")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
|
||||
// value render into the two tables, pack table first, money formatted through Money.
|
||||
func TestProjectOfferPricing(t *testing.T) {
|
||||
entries := []catalogEntry{
|
||||
{
|
||||
id: uuid.New(),
|
||||
title: "50 «Фишек»",
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
|
||||
prices: []priceRow{
|
||||
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
|
||||
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
|
||||
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: uuid.New(),
|
||||
title: "200 подсказок",
|
||||
atoms: []atomQty{{atomType: "hints", quantity: 200}},
|
||||
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
|
||||
},
|
||||
}
|
||||
md := projectOfferPricing(entries)
|
||||
for _, want := range []string{
|
||||
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
|
||||
"| 50 «Фишек» | 200.00 | 30 | 100 |",
|
||||
"| Наименование | «Фишки» |",
|
||||
"| 200 подсказок | 50 |",
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("projection missing %q\n---\n%s", want, md)
|
||||
}
|
||||
}
|
||||
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
|
||||
t.Errorf("the pack table must precede the values table:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
|
||||
// title carrying a pipe is escaped so the table layout survives.
|
||||
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
|
||||
entries := []catalogEntry{{
|
||||
id: uuid.New(),
|
||||
title: "Bonus | pack",
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
|
||||
// A roubles price only — no VK, no Telegram.
|
||||
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
|
||||
}}
|
||||
md := projectOfferPricing(entries)
|
||||
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
|
||||
t.Errorf("want row %q in:\n%s", want, md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
|
||||
// headers), so the offer's pricing marker is replaced with nothing.
|
||||
func TestProjectOfferPricingEmpty(t *testing.T) {
|
||||
if got := projectOfferPricing(nil); got != "" {
|
||||
t.Errorf("empty catalog must project to empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -19,6 +20,13 @@ import (
|
||||
type Service struct {
|
||||
store *Store
|
||||
clock func() time.Time
|
||||
|
||||
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
|
||||
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
|
||||
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
|
||||
offerMu sync.Mutex
|
||||
offerMD string
|
||||
offerFresh bool
|
||||
}
|
||||
|
||||
// NewService constructs a Service over store with a wall-clock time source.
|
||||
|
||||
@@ -74,6 +74,9 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
||||
u.POST("/wallet/reward", s.handleWalletReward)
|
||||
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
|
||||
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
|
||||
s.internal.GET("/offer/pricing", s.handleOfferPricing)
|
||||
}
|
||||
if s.payments != nil {
|
||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||
|
||||
@@ -112,6 +112,22 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, catalogDTOFrom(view))
|
||||
}
|
||||
|
||||
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
|
||||
// projected from the active products. The render sidecar fetches it and splices it into the offer
|
||||
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
|
||||
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
|
||||
// the steady state). Called by the renderer, not the gateway.
|
||||
func (s *Server) handleOfferPricing(c *gin.Context) {
|
||||
md, err := s.payments.OfferPricing(c.Request.Context())
|
||||
if err != nil {
|
||||
s.log.Error("offer pricing projection failed", zap.Error(err))
|
||||
c.String(http.StatusInternalServerError, "offer pricing unavailable")
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||||
c.String(http.StatusOK, md)
|
||||
}
|
||||
|
||||
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
||||
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
|
||||
// unconfigured), which gates the client's "watch for chips" button.
|
||||
|
||||
Reference in New Issue
Block a user