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,182 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// base is a fixed clock instant for the deterministic tests.
|
||||
var base = time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// seededService builds a Service whose read cache already holds st for id, so the read methods
|
||||
// resolve without a database (the store's db is nil). The clock is pinned to base.
|
||||
func seededService(id uuid.UUID, st walletState) *Service {
|
||||
store := NewStore(nil)
|
||||
store.cache.put(id, st)
|
||||
return &Service{store: store, clock: func() time.Time { return base }}
|
||||
}
|
||||
|
||||
func TestStackNoAds(t *testing.T) {
|
||||
future := base.Add(48 * time.Hour)
|
||||
past := base.Add(-time.Hour)
|
||||
tests := []struct {
|
||||
name string
|
||||
current *time.Time
|
||||
addDays int
|
||||
want *time.Time
|
||||
}{
|
||||
{"fresh term", nil, 3, new(base.Add(72 * time.Hour))},
|
||||
{"stack onto future", new(future), 2, new(future.Add(48 * time.Hour))},
|
||||
{"lapsed term restarts from now", new(past), 1, new(base.Add(24 * time.Hour))},
|
||||
{"zero days unchanged", new(future), 0, new(future)},
|
||||
{"zero days, nil stays nil", nil, 0, nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := stackNoAds(tc.current, tc.addDays, base)
|
||||
if (got == nil) != (tc.want == nil) || (got != nil && !got.Equal(*tc.want)) {
|
||||
t.Errorf("stackNoAds = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineNoAds(t *testing.T) {
|
||||
pFuture := base.Add(24 * time.Hour)
|
||||
sFuture := base.Add(72 * time.Hour) // 3 days remaining
|
||||
// primary future + secondary future: primary end + secondary's remaining (72h).
|
||||
if got := combineNoAds(new(pFuture), new(sFuture), base); !got.Equal(pFuture.Add(72 * time.Hour)) {
|
||||
t.Errorf("combine both future = %v, want %v", got, pFuture.Add(72*time.Hour))
|
||||
}
|
||||
// primary nil + secondary future: now + secondary's remaining.
|
||||
if got := combineNoAds(nil, new(sFuture), base); !got.Equal(base.Add(72 * time.Hour)) {
|
||||
t.Errorf("combine nil primary = %v, want %v", got, base.Add(72*time.Hour))
|
||||
}
|
||||
// secondary lapsed: primary unchanged.
|
||||
if got := combineNoAds(new(pFuture), new(base.Add(-time.Hour)), base); !got.Equal(pFuture) {
|
||||
t.Errorf("combine lapsed secondary = %v, want %v", got, pFuture)
|
||||
}
|
||||
// secondary nil: primary unchanged.
|
||||
if got := combineNoAds(new(pFuture), nil, base); !got.Equal(pFuture) {
|
||||
t.Errorf("combine nil secondary = %v, want %v", got, pFuture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDraws(t *testing.T) {
|
||||
st := walletState{chips: map[Source]int{SourceDirect: 30, SourceVK: 50, SourceTelegram: 5}}
|
||||
// price met entirely by the first (direct) segment.
|
||||
if draws, ok := planDraws(st, []Source{SourceDirect, SourceVK}, 20); !ok || len(draws) != 1 || draws[0] != (sourceAmount{SourceDirect, 20}) {
|
||||
t.Errorf("single-segment draw = %v ok=%v", draws, ok)
|
||||
}
|
||||
// price spills from direct into vk by priority.
|
||||
draws, ok := planDraws(st, []Source{SourceDirect, SourceVK, SourceTelegram}, 60)
|
||||
want := []sourceAmount{{SourceDirect, 30}, {SourceVK, 30}}
|
||||
if !ok || len(draws) != 2 || draws[0] != want[0] || draws[1] != want[1] {
|
||||
t.Errorf("priority spill draw = %v ok=%v, want %v", draws, ok, want)
|
||||
}
|
||||
// insufficient total.
|
||||
if _, ok := planDraws(st, []Source{SourceDirect, SourceVK, SourceTelegram}, 200); ok {
|
||||
t.Error("expected insufficient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletSegments(t *testing.T) {
|
||||
id := uuid.New()
|
||||
st := walletState{chips: map[Source]int{SourceDirect: 100, SourceVK: 50}}
|
||||
svc := seededService(id, st)
|
||||
present := []Source{SourceDirect, SourceVK}
|
||||
|
||||
// Web/native: both attached segments shown, both spendable.
|
||||
got, err := svc.Wallet(context.Background(), id, NewContext("direct", "web"), present)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Segments) != 2 || !got.Segments[0].Spendable || got.Segments[0].Source != SourceDirect {
|
||||
t.Errorf("direct wallet segments = %+v", got.Segments)
|
||||
}
|
||||
// VK Android: only vk shown, spendable.
|
||||
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "android"), present)
|
||||
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
|
||||
t.Errorf("vk-android wallet = %+v", got.Segments)
|
||||
}
|
||||
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible.
|
||||
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
|
||||
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable {
|
||||
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdFreeByContext(t *testing.T) {
|
||||
id := uuid.New()
|
||||
future := base.Add(48 * time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
// A vk-origin no-ads term applies inside VK and out on web, but a direct-origin term never
|
||||
// applies inside VK (the compliance wall).
|
||||
svc := seededService(id, walletState{benefits: map[Source]benefitState{
|
||||
SourceVK: {adsPaidUntil: new(future)},
|
||||
SourceDirect: {adsPaidUntil: new(future)},
|
||||
}})
|
||||
present := []Source{SourceDirect, SourceVK}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cxt Context
|
||||
want bool
|
||||
}{
|
||||
{"vk term inside vk", NewContext("vk", "android"), true},
|
||||
{"vk term inside vk-ios (applies while frozen)", NewContext("vk", "ios"), true},
|
||||
{"terms on web", NewContext("direct", "web"), true},
|
||||
{"untrusted fail-closed", NewContext("", ""), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := svc.AdFree(ctx, id, tc.cxt, present)
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("AdFree = %v (err %v), want %v", got, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Compliance: ONLY a direct-origin term, checked inside VK, must not suppress ads.
|
||||
svc2 := seededService(id, walletState{benefits: map[Source]benefitState{SourceDirect: {adsPaidUntil: new(future)}}})
|
||||
if got, _ := svc2.AdFree(ctx, id, NewContext("vk", "android"), present); got {
|
||||
t.Error("direct-origin no-ads must NOT apply inside VK (compliance wall)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintsAvailableByContext(t *testing.T) {
|
||||
id := uuid.New()
|
||||
svc := seededService(id, walletState{benefits: map[Source]benefitState{
|
||||
SourceDirect: {hints: 3},
|
||||
SourceVK: {hints: 2},
|
||||
}})
|
||||
present := []Source{SourceDirect, SourceVK}
|
||||
ctx := context.Background()
|
||||
|
||||
// Web: both applicable origins summed.
|
||||
if n, _ := svc.HintsAvailable(ctx, id, NewContext("direct", "web"), present); n != 5 {
|
||||
t.Errorf("web hints = %d, want 5", n)
|
||||
}
|
||||
// VK: only vk-origin hints.
|
||||
if n, _ := svc.HintsAvailable(ctx, id, NewContext("vk", "android"), present); n != 2 {
|
||||
t.Errorf("vk hints = %d, want 2", n)
|
||||
}
|
||||
// Untrusted: none.
|
||||
if n, _ := svc.HintsAvailable(ctx, id, NewContext("", ""), present); n != 0 {
|
||||
t.Errorf("untrusted hints = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpendHintUntrustedNoOp(t *testing.T) {
|
||||
id := uuid.New()
|
||||
svc := seededService(id, walletState{benefits: map[Source]benefitState{SourceDirect: {hints: 3}}})
|
||||
// Untrusted context: no applicable origin, so nothing is spent and the DB (nil) is never hit.
|
||||
spent, err := svc.SpendHint(context.Background(), id, NewContext("", ""), []Source{SourceDirect})
|
||||
if err != nil || spent {
|
||||
t.Errorf("untrusted SpendHint = %v (err %v), want false", spent, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user