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:
@@ -10,6 +10,7 @@ import (
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// bannerDTO is the advertising-banner block attached to an eligible viewer's
|
||||
@@ -55,7 +56,21 @@ type bannerTimingsDTO struct {
|
||||
// a language switch).
|
||||
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
||||
r := profileResponseFor(acc)
|
||||
r.Banner = s.bannerFor(ctx, acc)
|
||||
// Resolve the payments gate once (execution context + present sources) and feed it to both
|
||||
// the hint count and the banner. The profile hint balance now comes from the payments benefit
|
||||
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy
|
||||
// value from profileResponseFor (zeroed in production) stands.
|
||||
cxt, present, err := s.walletGate(ctx, acc.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
} else if s.payments != nil {
|
||||
if hints, herr := s.payments.HintsAvailable(ctx, acc.ID, cxt, present); herr == nil {
|
||||
r.HintBalance = hints
|
||||
} else {
|
||||
s.log.Warn("profile: hint balance read failed", zap.String("account", acc.ID.String()), zap.Error(herr))
|
||||
}
|
||||
}
|
||||
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||
r.DictVersions = s.currentDictVersions()
|
||||
return r
|
||||
@@ -115,7 +130,7 @@ func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, a
|
||||
// language, falling back to its interface language and then English. A failure
|
||||
// reading roles or campaigns is logged and treated as "no banner" so the profile
|
||||
// response still succeeds.
|
||||
func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO {
|
||||
func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *bannerDTO {
|
||||
if s.ads == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -124,16 +139,24 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
|
||||
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
|
||||
// hides the banner).
|
||||
// An urgent campaign is shown to every viewer; otherwise the banner is suppressed by the
|
||||
// no_banner role or by an active no-ads benefit applicable in the viewer's context (the
|
||||
// payments gate — a hint balance no longer suppresses the banner). An untrusted platform is
|
||||
// fail-closed by the gate, so it does not suppress the banner.
|
||||
if !urgent {
|
||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
||||
if err != nil {
|
||||
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
||||
adFree := false
|
||||
if s.payments != nil {
|
||||
if adFree, err = s.payments.AdFree(ctx, acc.ID, cxt, present); err != nil {
|
||||
s.log.Warn("banner: ad-free check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if hasNoBanner || adFree {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user