feat(payments): chip wallet, store-compliance gate and benefit application
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
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).
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user