1c06d1d0d1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Stand up the internal chip/benefit mechanic behind the narrow payments interface: context-aware balances and benefits, an atomic chip spend, admin grants as zero-price value sales, the one-directional store-compliance gate (VK/TG same- origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and per-origin hint and no-ads application with term stacking. Reads are served from an in-process, account-keyed write-through cache (mirroring the suspension gate), so hot paths issue no query to the payments schema. Flip the online-game hint wallet and the ad-banner suppression from the deprecated accounts.hint_balance / paid_account columns to the payments benefit (a hint balance no longer suppresses the banner — only a no-ads benefit does), and fold chip segments and benefits by origin on account merge, inside the merge tx. Add the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus its codec unit test; no wallet UI yet. Bring the frozen owner decisions log into the repo at docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it from PLAN.md; record the read-cache design and the present-sources interface in PLAN.md and docs/PAYMENTS.md (+ RU mirror).
203 lines
7.2 KiB
Go
203 lines
7.2 KiB
Go
package payments
|
|
|
|
import (
|
|
"slices"
|
|
"time"
|
|
)
|
|
|
|
// Source is the platform axis a chip balance is segmented by (where it was funded) and a
|
|
// benefit is stamped with (where it was bought — its origin). The two roles share this value
|
|
// set but mean different things (see docs/PAYMENTS.md §3); Source names the axis for both.
|
|
type Source string
|
|
|
|
const (
|
|
// SourceVK is the VK platform: Votes purchases and VK rewarded ads fund here.
|
|
SourceVK Source = "vk"
|
|
// SourceTelegram is the Telegram platform: Stars purchases fund here.
|
|
SourceTelegram Source = "telegram"
|
|
// SourceDirect is the open web / native context: Robokassa purchases fund here.
|
|
SourceDirect Source = "direct"
|
|
)
|
|
|
|
// Valid reports whether s is one of the three known platform sources.
|
|
func (s Source) Valid() bool {
|
|
switch s {
|
|
case SourceVK, SourceTelegram, SourceDirect:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SubtypeIOS is the one device subtype the gate keys on: VK on iOS is frozen for spending
|
|
// (Apple forbids spending virtual currency on digital goods outside IAP). It is trusted only
|
|
// for VK, where it rides inside the signed launch parameters.
|
|
const SubtypeIOS = "ios"
|
|
|
|
// SourceForIdentityKind maps a backend identity kind to the payments source whose segment that
|
|
// identity makes available: a vk/telegram identity to its own source, a durable email identity
|
|
// to the direct source (the web/native recovery anchor). A robot identity — or any unknown
|
|
// kind — maps to no source (second return false). Callers use it to build the present set the
|
|
// payments interface takes, since payments cannot read the account schema.
|
|
func SourceForIdentityKind(kind string) (Source, bool) {
|
|
switch kind {
|
|
case "vk":
|
|
return SourceVK, true
|
|
case "telegram":
|
|
return SourceTelegram, true
|
|
case "email":
|
|
return SourceDirect, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// PresentSources maps an account's identity kinds to the payments sources they make available
|
|
// (§6), de-duplicated: vk→vk, telegram→telegram, email→direct; a robot or unknown kind maps to
|
|
// nothing. Callers pass the kinds from account.Identities so the gate — which holds no
|
|
// cross-schema identity knowledge — can resolve which segments are awake.
|
|
func PresentSources(kinds []string) []Source {
|
|
var out []Source
|
|
for _, k := range kinds {
|
|
if s, ok := SourceForIdentityKind(k); ok && !has(out, s) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Context is the trusted execution context the store-compliance gate keys on: the platform
|
|
// Kind (the source the wrapper enforces) plus, for VK, the trusted Subtype (only the VK-iOS
|
|
// freeze depends on it). The zero Context (empty Kind) is an untrusted platform — the gate is
|
|
// fail-closed there: no spend, no purchase, no foreign-origin benefit, view only.
|
|
type Context struct {
|
|
Kind Source
|
|
Subtype string
|
|
}
|
|
|
|
// NewContext builds a Context from the session platform's kind and subtype strings (as carried
|
|
// on the trusted X-Platform signal). An unrecognised or empty kind yields an untrusted Context.
|
|
func NewContext(kind, subtype string) Context {
|
|
k := Source(kind)
|
|
if !k.Valid() {
|
|
return Context{}
|
|
}
|
|
return Context{Kind: k, Subtype: subtype}
|
|
}
|
|
|
|
// Trusted reports whether the platform is trusted (a known kind). An untrusted context denies
|
|
// every spend/purchase and the application of any foreign origin.
|
|
func (c Context) Trusted() bool { return c.Kind.Valid() }
|
|
|
|
// vkFrozen reports whether this is the VK-iOS spend freeze: VK context on the trusted iOS
|
|
// subtype. A previously bought benefit still applies there, but no spend or purchase is possible.
|
|
func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS }
|
|
|
|
// spendPriority is the fixed draw order when several segments are spendable in one context
|
|
// (D7): the "home" direct segment first, the store-funded segments after.
|
|
var spendPriority = []Source{SourceDirect, SourceVK, SourceTelegram}
|
|
|
|
// has reports whether present contains s.
|
|
func has(present []Source, s Source) bool {
|
|
return slices.Contains(present, s)
|
|
}
|
|
|
|
// spendableSources returns the chip segments that may be SPENT in the context, in draw-priority
|
|
// order, restricted to the sources the account actually has (present). It is empty when the
|
|
// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named
|
|
// segment is spendable; on web/native all attached segments are, drained direct→vk→tg.
|
|
func spendableSources(c Context, present []Source) []Source {
|
|
if !c.Trusted() || c.vkFrozen() {
|
|
return nil
|
|
}
|
|
switch c.Kind {
|
|
case SourceVK, SourceTelegram:
|
|
if has(present, c.Kind) {
|
|
return []Source{c.Kind}
|
|
}
|
|
return nil
|
|
default: // direct (web/native)
|
|
var out []Source
|
|
for _, s := range spendPriority {
|
|
if has(present, s) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
|
|
// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is
|
|
// NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG
|
|
// only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a
|
|
// store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg.
|
|
func applicableOrigins(c Context, present []Source) []Source {
|
|
if !c.Trusted() {
|
|
return nil
|
|
}
|
|
switch c.Kind {
|
|
case SourceVK, SourceTelegram:
|
|
if has(present, c.Kind) {
|
|
return []Source{c.Kind}
|
|
}
|
|
return nil
|
|
default: // direct (web/native)
|
|
var out []Source
|
|
for _, s := range spendPriority {
|
|
if has(present, s) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// visibleSources returns the segments the wallet shows in the context, regardless of whether
|
|
// they are spendable: inside a store only the same-named segment (the others are invisible
|
|
// there); on web/native or an untrusted context all three (untrusted shows them view-only).
|
|
func visibleSources(c Context) []Source {
|
|
switch c.Kind {
|
|
case SourceVK, SourceTelegram:
|
|
return []Source{c.Kind}
|
|
default:
|
|
return spendPriority
|
|
}
|
|
}
|
|
|
|
// Segment is one chip balance the wallet shows: the source, its chip count, and whether it can
|
|
// be spent in the current context (false for a frozen VK-iOS balance or an untrusted platform).
|
|
type Segment struct {
|
|
Source Source
|
|
Chips int
|
|
Spendable bool
|
|
}
|
|
|
|
// BenefitView is the benefit state applicable in the current context: whether ads are off (and
|
|
// until when, or forever) and how many hints are available. It aggregates over the origins
|
|
// applicable in the context (§5).
|
|
type BenefitView struct {
|
|
AdsForever bool
|
|
AdsPaidUntil *time.Time
|
|
Hints int
|
|
}
|
|
|
|
// WalletView is the read model returned to the wallet: the visible segments plus the
|
|
// context-applicable benefits.
|
|
type WalletView struct {
|
|
Segments []Segment
|
|
Benefits BenefitView
|
|
}
|
|
|
|
// benefitDelta is the benefit change a spend or grant applies to one origin: hints added, a
|
|
// no-ads term in whole days (stacked from max(now, current end)), and the perpetual forever
|
|
// flag (which overrides terms).
|
|
type benefitDelta struct {
|
|
hintsAdd int
|
|
noAdsDays int
|
|
forever bool
|
|
}
|
|
|
|
// zero reports whether the delta changes nothing.
|
|
func (d benefitDelta) zero() bool { return d.hintsAdd == 0 && d.noAdsDays == 0 && !d.forever }
|