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 purchase freeze: VK context on the trusted iOS // subtype. Apple's ToS forbids only BUYING in-app values there, so a purchase (money -> chips) is // refused; earning chips (rewarded ads) and SPENDING chips already in the VK wallet — earned or // bought on the same account elsewhere (e.g. VK Android) — are legal and stay allowed. 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 only when the // platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so // spending VK-wallet chips there is allowed. 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() { 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 mirrors spendableSources (both gate only on a trusted // platform now that the VK-iOS freeze is purchase-only). 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 }