1507ceb793
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or one account — and show the user a localized reason on their next attempt, so a provider outage or a misconfig is explained instead of a silent dead button. - rail kill switch (payments.rail_status, per rail direct:web / direct:android / vk / telegram): enabled + a per-language message, edited on the catalog page. Fail-open — a rail with no row stays enabled, so payments are never accidentally killed. The intake gate (CanPurchase in handleWalletOrder, before the order) returns payment_unavailable + the localized message, orthogonal to the security gates. - per-account override (payments.account_payment_override, a row only for non-default): allow / deny / default, edited on the user card. "allow" bypasses ONLY the ops rail switch, never the security gates (trusted platform, the email anchor, the VK-iOS freeze, the min client version). - wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe); the gateway forwards a backend domain-error message; the client shows it on a payment_unavailable buy attempt. - admin: rail toggles on the catalog page, the override control on the user card. - tests: the pure gate (unit, TDD), the store + gate + override end-to-end (integration, migration 00016), the client (svelte-check / vitest). - docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays cabinet-side (owner decision) — no itemized-receipt code. Contour-safe: additive migration (two new tables, no wipe), the wire add is additive, and fail-open so nothing is disabled until an operator acts.
67 lines
4.7 KiB
Go Template
67 lines
4.7 KiB
Go Template
{{define "content" -}}
|
||
<h1>Product catalog</h1>
|
||
{{with .Data}}
|
||
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
|
||
<section class="panel"><h2>Add product</h2>
|
||
<form class="form col" method="post" action="/_gm/catalog">
|
||
<label>Title <input type="text" name="title" maxlength="120" required></label>
|
||
<fieldset><legend>Atoms (quantity; blank = none)</legend>
|
||
<label>Chips <input type="number" name="chips" min="0"></label>
|
||
<label>Hints <input type="number" name="hints" min="0"></label>
|
||
<label>No-ads days <input type="number" name="noads" min="0"></label>
|
||
<label>Tournament <input type="number" name="tournament" min="0"></label>
|
||
</fieldset>
|
||
<fieldset><legend>Prices (minor units; blank = none)</legend>
|
||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
|
||
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
|
||
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
|
||
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
|
||
</fieldset>
|
||
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
|
||
<div><button type="submit">Add</button></div>
|
||
</form>
|
||
</section>
|
||
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||
<div><button type="submit">Save</button></div>
|
||
</form>
|
||
</section>
|
||
<section class="panel"><h2>Payment availability (kill switch)</h2>
|
||
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in “temporarily unavailable”. Fail-open: a rail you never touch stays on. A per-user “allow” override (on a user card) bypasses this switch.</p>
|
||
{{range .Rails}}
|
||
<form class="form col" method="post" action="/_gm/catalog/rail-status">
|
||
<input type="hidden" name="rail" value="{{.Rail}}">
|
||
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> — purchases enabled</label>
|
||
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
|
||
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
|
||
<div><button type="submit">Save</button></div>
|
||
</form>
|
||
{{end}}
|
||
</section>
|
||
<section class="panel"><h2>Products</h2>
|
||
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||
<table class="list">
|
||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||
<tbody>
|
||
{{range .Products}}
|
||
<tr>
|
||
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
|
||
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
|
||
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
|
||
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
|
||
<td class="row-actions">
|
||
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
|
||
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
|
||
</td>
|
||
</tr>
|
||
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
{{end}}
|
||
{{- end}}
|