feat(admin): an all-accounts ledger section with filters and totals
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
There was no way to see the money as a whole: the ledger was only ever rendered inside one account's card, so "what came in last month" meant exporting the entire CSV and reading it elsewhere. /_gm/ledger lists every operation, newest first, filtered by date range (defaulting to the last 30 days), by wallet, by rail, by kind and by account. Wallet and rail are deliberately separate axes because they answer different questions — "what happened on VK" matches the funded segment or the benefit origin, while "what came through YooKassa" matches the settling provider, and a chip spend has no rail at all, so it drops out of that filter by construction. Above the table sit the totals for everything the filter matches, not merely the page on screen, which is the point of showing them. Money is listed per currency because the rails settle in roubles, Votes and Stars and one sum across them would mean nothing. Row amounts come from the operation snapshot, since the ledger's own columns count chips rather than money. Paging, the CSV export and a refund's way back are all built from the same encoded filter, so none of them can quietly show a different slice than the screen. The export moves here from the user card and gains the filter along with money columns; it is capped, because an append-only ledger grows forever and an unbounded export would eventually time out. The refund action moves onto the funded rows here — an operator can now find a payment by filter without knowing whose it is first — and returns to the same filtered view. The destination travels with the form but is only honoured when it is a console path, so a crafted form cannot turn it into an open redirect. The user card keeps the account's standing rather than its history: balances, benefits, the risk flag and a lifetime summary (money paid and refunded per currency, chips credited and spent), plus a link into the ledger scoped to that account. Operations are rendered in one place, with filters and paging, instead of two. Tests: the filter axes, paging that neither repeats nor drops a row, totals that describe the range rather than the page, money recovered from a snapshot and absent on a spend, and the console page, the export and the card hand-off. The existing finance-panel test now asserts the summary and that the card no longer re-renders the rows.
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
|
||||
<a href="/_gm/ledger"{{if eq .ActiveNav "ledger"}} class="active"{{end}}>Ledger</a>
|
||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{{define "content" -}}
|
||||
<h1>Ledger</h1>
|
||||
{{with .Data}}
|
||||
<form class="form" method="get" action="/_gm/ledger">
|
||||
<label>From <input type="date" name="from" value="{{.From}}"></label>
|
||||
<label>To <input type="date" name="to" value="{{.To}}"></label>
|
||||
<select name="kind">
|
||||
<option value="">any kind</option>
|
||||
{{range .Kinds}}<option value="{{.}}"{{if eq . $.Data.Kind}} selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<select name="wallet">
|
||||
<option value="">any wallet</option>
|
||||
{{range .Wallets}}<option value="{{.}}"{{if eq . $.Data.Wallet}} selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<select name="provider">
|
||||
<option value="">any rail</option>
|
||||
{{range .Providers}}<option value="{{.}}"{{if eq . $.Data.Provider}} selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<input name="user" value="{{.UserID}}" placeholder="user id" size="36">
|
||||
<button type="submit">Filter</button>
|
||||
<a class="export" href="/_gm/ledger.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||
</form>
|
||||
<p class="note">Defaults to the last 30 days. The wallet filter matches the funded segment or the
|
||||
benefit origin; the rail filter matches the settling provider, so chip spends (which have no rail)
|
||||
drop out of it. <a href="/_gm/ledger">clear filters</a></p>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Totals for the filtered range</h2>
|
||||
<p>
|
||||
Money in: {{range .Totals.MoneyIn}}<strong>{{.}}</strong> {{else}}<span class="note">none</span>{{end}}
|
||||
· Refunded: {{range .Totals.MoneyRefunded}}<strong>{{.}}</strong> {{else}}<span class="note">none</span>{{end}}
|
||||
· Chips credited: <strong>{{.Totals.ChipsIn}}</strong>
|
||||
· Chips spent: <strong>{{.Totals.ChipsOut}}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<table class="list">
|
||||
<thead><tr><th>Time</th><th>Account</th><th>Kind</th><th>Money</th><th>Chips</th><th>What</th><th>Wallet</th><th>Rail</th><th>Shop</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<td>{{.At}}</td>
|
||||
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Money}}</td>
|
||||
<td>{{.ChipsDelta}}</td>
|
||||
<td>{{.Title}}{{if .Order}} <code>{{.Order}}</code>{{end}}</td>
|
||||
<td>{{.Source}}{{if and .Origin (ne .Origin .Source)}} → {{.Origin}}{{end}}</td>
|
||||
<td>{{.Provider}}</td>
|
||||
<td>{{.Shop}}</td>
|
||||
<td>{{if .Refundable}}<form class="form" method="post" action="/_gm/users/{{.AccountID}}/refund" onsubmit="return confirm('Refund this order in full? On the direct rail this moves the money back through the provider and then revokes the chips (floored at 0); on the other rails refund on the rail first.')"><input type="hidden" name="order_id" value="{{.Order}}"><input type="hidden" name="back" value="/_gm/ledger?{{$.Data.FilterQuery}}"><button type="submit">Refund</button></form>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="10"><span class="note">no operations in this range</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/ledger?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/ledger?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -82,20 +82,14 @@
|
||||
</select></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
<h3>Ledger</h3>
|
||||
{{$uid := .ID}}
|
||||
{{if .Finance.Ledger}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Finance.Ledger}}
|
||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
|
||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||
<h3>Totals</h3>
|
||||
<p>
|
||||
Paid: {{range .Finance.Paid}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
||||
· Refunded: {{range .Finance.Refunded}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
||||
· Chips credited: <strong>{{.Finance.ChipsBought}}</strong>
|
||||
· Chips spent: <strong>{{.Finance.ChipsSpent}}</strong>
|
||||
</p>
|
||||
<p class="note"><a href="/_gm/ledger?{{.Finance.LedgerQuery}}">Open this account's operations in the ledger →</a> — every entry, with filters, paging and the refund action.</p>
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Grant benefits</h2>
|
||||
|
||||
@@ -207,16 +207,26 @@ type UserDetailView struct {
|
||||
}
|
||||
|
||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
// (newest first). Present is false when the payments domain is unwired.
|
||||
// segment, benefits per origin, the recorded refund risk, and a short lifetime summary. The
|
||||
// operations themselves live in the ledger section, which this links to pre-filtered to the
|
||||
// account — the card answers "where does this account stand", not "what happened when".
|
||||
// Present is false when the payments domain is unwired.
|
||||
type FinanceView struct {
|
||||
Present bool
|
||||
Segments []SegmentRow
|
||||
Benefits []BenefitRow
|
||||
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
||||
Abuse bool
|
||||
Loss int
|
||||
Ledger []LedgerRow
|
||||
Abuse bool
|
||||
Loss int
|
||||
// Paid is the money the account has spent, per currency, already formatted; Refunded is what
|
||||
// came back. ChipsBought counts every chip ever credited (purchases, rewarded views, grants) and
|
||||
// ChipsSpent every chip spent on a value.
|
||||
Paid []string
|
||||
Refunded []string
|
||||
ChipsBought int
|
||||
ChipsSpent int
|
||||
// LedgerQuery is the ledger-section query string that shows this account's operations.
|
||||
LedgerQuery template.URL
|
||||
}
|
||||
|
||||
// SegmentRow is one funding segment's chip balance.
|
||||
@@ -234,22 +244,6 @@ type BenefitRow struct {
|
||||
Forever bool
|
||||
}
|
||||
|
||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
||||
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
|
||||
// snapshot JSON and the pre-formatted time.
|
||||
type LedgerRow struct {
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
ChipsDelta int
|
||||
Product string
|
||||
Order string
|
||||
Provider string
|
||||
Shop string
|
||||
Snapshot string
|
||||
At string
|
||||
}
|
||||
|
||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
||||
type RelationRow struct {
|
||||
@@ -764,3 +758,53 @@ type GrantProductOption struct {
|
||||
Summary string
|
||||
Archived bool
|
||||
}
|
||||
|
||||
// LedgerView is the all-accounts financial ledger: one page of operations, the totals for
|
||||
// everything the current filter matches (not merely the page), and the filter state itself so the
|
||||
// form renders back what the operator asked for. FilterQuery is those filters URL-encoded, carried
|
||||
// into the pager, the CSV export and the refund's return link so every one of them stays on the
|
||||
// same slice of the ledger (see MessagesView.FilterQuery).
|
||||
type LedgerView struct {
|
||||
Rows []LedgerRow
|
||||
Pager Pager
|
||||
Totals LedgerTotalsRow
|
||||
From string
|
||||
To string
|
||||
UserID string
|
||||
Kind string
|
||||
Wallet string
|
||||
Provider string
|
||||
Kinds []string
|
||||
Wallets []string
|
||||
Providers []string
|
||||
FilterQuery template.URL
|
||||
}
|
||||
|
||||
// LedgerRow is one operation in the ledger list. Money is the amount the customer paid or was
|
||||
// refunded, already formatted with its currency, and empty for a row that moved no money (a chip
|
||||
// spend, an admin grant, a rewarded-video credit). Refundable marks a funded order the operator may
|
||||
// still reverse, which is what puts the Refund button on that row.
|
||||
type LedgerRow struct {
|
||||
At string
|
||||
AccountID string
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
ChipsDelta int
|
||||
Money string
|
||||
Title string
|
||||
Order string
|
||||
Provider string
|
||||
Shop string
|
||||
Refundable bool
|
||||
}
|
||||
|
||||
// LedgerTotalsRow is the summary above the table, over everything the filter matches. Money is
|
||||
// listed per currency because the rails settle in different ones and one sum across them would mean
|
||||
// nothing.
|
||||
type LedgerTotalsRow struct {
|
||||
MoneyIn []string
|
||||
MoneyRefunded []string
|
||||
ChipsIn int
|
||||
ChipsOut int
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user