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/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/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/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/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/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
<a href="/_gm/grafana/">Grafana ↗</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>
|
</select></label>
|
||||||
<div><button type="submit">Save</button></div>
|
<div><button type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
<h3>Ledger</h3>
|
<h3>Totals</h3>
|
||||||
{{$uid := .ID}}
|
<p>
|
||||||
{{if .Finance.Ledger}}
|
Paid: {{range .Finance.Paid}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
||||||
<table class="list">
|
· Refunded: {{range .Finance.Refunded}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
||||||
<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>
|
· Chips credited: <strong>{{.Finance.ChipsBought}}</strong>
|
||||||
<tbody>
|
· Chips spent: <strong>{{.Finance.ChipsSpent}}</strong>
|
||||||
{{range .Finance.Ledger}}
|
</p>
|
||||||
<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>
|
<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>
|
||||||
<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}}
|
|
||||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Grant benefits</h2>
|
<section class="panel"><h2>Grant benefits</h2>
|
||||||
|
|||||||
@@ -207,8 +207,10 @@ type UserDetailView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
// 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
|
// segment, benefits per origin, the recorded refund risk, and a short lifetime summary. The
|
||||||
// (newest first). Present is false when the payments domain is unwired.
|
// 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 {
|
type FinanceView struct {
|
||||||
Present bool
|
Present bool
|
||||||
Segments []SegmentRow
|
Segments []SegmentRow
|
||||||
@@ -216,7 +218,15 @@ type FinanceView struct {
|
|||||||
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
||||||
Abuse bool
|
Abuse bool
|
||||||
Loss int
|
Loss int
|
||||||
Ledger []LedgerRow
|
// 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.
|
// SegmentRow is one funding segment's chip balance.
|
||||||
@@ -234,22 +244,6 @@ type BenefitRow struct {
|
|||||||
Forever bool
|
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
|
// 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.
|
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
||||||
type RelationRow struct {
|
type RelationRow struct {
|
||||||
@@ -764,3 +758,53 @@ type GrantProductOption struct {
|
|||||||
Summary string
|
Summary string
|
||||||
Archived bool
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedLedgerRow writes one ledger row directly, at a chosen age, so a test can stand up a history
|
||||||
|
// that spans a date range without waiting for one.
|
||||||
|
func seedLedgerRow(t *testing.T, acc uuid.UUID, kind, source, provider string, chips int, snapshot string, age time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
var providerPaymentID any
|
||||||
|
if provider != "" {
|
||||||
|
providerPaymentID = "pp-" + uuid.NewString()
|
||||||
|
}
|
||||||
|
if _, err := testDB.ExecContext(context.Background(), `
|
||||||
|
INSERT INTO payments.ledger (ledger_id, account_id, kind, source, origin, chips_delta,
|
||||||
|
provider, provider_payment_id, snapshot, created_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$4,$5,NULLIF($6,''),$7,$8::jsonb, now() - $9::interval)`,
|
||||||
|
uuid.New(), acc, kind, source, chips, provider, providerPaymentID, snapshot,
|
||||||
|
fmt.Sprintf("%d seconds", int(age.Seconds()))); err != nil {
|
||||||
|
t.Fatalf("seed ledger row: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerReport reads a filtered page through the service, the way the console does.
|
||||||
|
func ledgerReport(t *testing.T, pay *payments.Service, f payments.LedgerFilter, limit, offset int) payments.LedgerReport {
|
||||||
|
t.Helper()
|
||||||
|
rep, err := pay.LedgerReportPage(context.Background(), f, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ledger report: %v", err)
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLedgerReportFilters covers each filter axis over one seeded history, including the property
|
||||||
|
// that matters most for an operator: the totals describe the whole filtered range, not the page.
|
||||||
|
func TestLedgerReportFilters(t *testing.T) {
|
||||||
|
acc, other := provisionAccount(t), provisionAccount(t)
|
||||||
|
// Two recent purchases on different rails, one older purchase outside a narrow range, a spend
|
||||||
|
// (no rail at all) and a refund.
|
||||||
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"100 chips"}`, time.Hour)
|
||||||
|
seedLedgerRow(t, acc, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"50 chips"}`, 2*time.Hour)
|
||||||
|
seedLedgerRow(t, other, "fund", "direct", "yookassa", 30, `{"amount_minor":4900,"currency":"RUB","title":"30 chips"}`, 40*24*time.Hour)
|
||||||
|
seedLedgerRow(t, acc, "spend", "direct", "", -20, `{"title":"hints"}`, 30*time.Minute)
|
||||||
|
seedLedgerRow(t, acc, "refund", "direct", "yookassa", -100, `{"amount_minor":14900,"currency":"RUB"}`, 10*time.Minute)
|
||||||
|
|
||||||
|
pay := newPaymentsService()
|
||||||
|
// Scoped to this test's own account: the suite shares one database and other tests seed recent
|
||||||
|
// ledger rows, so an unscoped count would be a coin toss.
|
||||||
|
recent := payments.LedgerFilter{From: time.Now().Add(-24 * time.Hour), AccountID: acc}
|
||||||
|
|
||||||
|
t.Run("date range excludes older rows", func(t *testing.T) {
|
||||||
|
rep := ledgerReport(t, pay, recent, 100, 0)
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
if r.AccountID == other {
|
||||||
|
t.Error("a row from outside the range was returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("account", func(t *testing.T) {
|
||||||
|
rep := ledgerReport(t, pay, payments.LedgerFilter{AccountID: other}, 100, 0)
|
||||||
|
if rep.Total != 1 {
|
||||||
|
t.Fatalf("rows for the other account = %d, want 1", rep.Total)
|
||||||
|
}
|
||||||
|
rep = ledgerReport(t, pay, payments.LedgerFilter{AccountID: acc}, 100, 0)
|
||||||
|
if rep.Total != 4 {
|
||||||
|
t.Fatalf("rows for the account = %d, want 4", rep.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("kind", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Kind = "spend"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
if r.Kind != "spend" {
|
||||||
|
t.Errorf("kind filter returned a %q row", r.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rep.Total == 0 {
|
||||||
|
t.Error("kind filter matched nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wallet", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Wallet = "vk"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
if rep.Total != 1 {
|
||||||
|
t.Fatalf("vk wallet rows = %d, want 1", rep.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rail excludes spends, which have none", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Provider = "yookassa"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
if r.Kind == "spend" {
|
||||||
|
t.Error("a chip spend matched a rail filter, but a spend has no rail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("totals cover the range, not the page", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
full := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
firstPage := ledgerReport(t, pay, f, 1, 0)
|
||||||
|
if len(firstPage.Rows) != 1 {
|
||||||
|
t.Fatalf("page rows = %d, want 1", len(firstPage.Rows))
|
||||||
|
}
|
||||||
|
if firstPage.Total != full.Total {
|
||||||
|
t.Errorf("page total = %d, want the full match count %d", firstPage.Total, full.Total)
|
||||||
|
}
|
||||||
|
if firstPage.Totals.ChipsIn != full.Totals.ChipsIn || firstPage.Totals.ChipsOut != full.Totals.ChipsOut {
|
||||||
|
t.Errorf("page totals = %+v, want the whole range's %+v", firstPage.Totals, full.Totals)
|
||||||
|
}
|
||||||
|
// 100 + 50 credited, 20 spent + 100 refunded revoked.
|
||||||
|
if full.Totals.ChipsIn != 150 || full.Totals.ChipsOut != 120 {
|
||||||
|
t.Errorf("chip totals = in %d / out %d, want 150 / 120", full.Totals.ChipsIn, full.Totals.ChipsOut)
|
||||||
|
}
|
||||||
|
// Money is kept per currency; summing roubles and Votes together would be meaningless.
|
||||||
|
var rub, vote bool
|
||||||
|
for _, m := range full.Totals.MoneyIn {
|
||||||
|
switch m.Currency() {
|
||||||
|
case payments.CurrencyRUB:
|
||||||
|
rub = true
|
||||||
|
if m.Minor() != 14900 {
|
||||||
|
t.Errorf("RUB in = %d, want 14900", m.Minor())
|
||||||
|
}
|
||||||
|
case payments.CurrencyVote:
|
||||||
|
vote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !rub || !vote {
|
||||||
|
t.Errorf("money in = %v, want both a RUB and a VOTE total", full.Totals.MoneyIn)
|
||||||
|
}
|
||||||
|
if len(full.Totals.MoneyRefunded) != 1 || full.Totals.MoneyRefunded[0].Minor() != 14900 {
|
||||||
|
t.Errorf("money refunded = %v, want one 149.00 RUB total", full.Totals.MoneyRefunded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("money is recovered from the snapshot", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Kind = "fund"
|
||||||
|
f.Wallet = "direct"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
if len(rep.Rows) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1", len(rep.Rows))
|
||||||
|
}
|
||||||
|
r := rep.Rows[0]
|
||||||
|
if !r.HasMoney() || r.Money.Minor() != 14900 || r.Money.Currency() != payments.CurrencyRUB {
|
||||||
|
t.Errorf("row money = %v, want 149.00 RUB", r.Money)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a spend carries no money", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Kind = "spend"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
if len(rep.Rows) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1", len(rep.Rows))
|
||||||
|
}
|
||||||
|
if rep.Rows[0].HasMoney() {
|
||||||
|
t.Errorf("a chip spend reported money %v", rep.Rows[0].Money)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLedgerReportPaging checks paging walks the range without repeating or dropping a row.
|
||||||
|
func TestLedgerReportPaging(t *testing.T) {
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
for i := range 5 {
|
||||||
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 10,
|
||||||
|
`{"amount_minor":1000,"currency":"RUB"}`, time.Duration(i+1)*time.Minute)
|
||||||
|
}
|
||||||
|
pay := newPaymentsService()
|
||||||
|
f := payments.LedgerFilter{AccountID: acc}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for offset := 0; offset < 5; offset += 2 {
|
||||||
|
rep := ledgerReport(t, pay, f, 2, offset)
|
||||||
|
if rep.Total != 5 {
|
||||||
|
t.Fatalf("total = %d, want 5 at every offset", rep.Total)
|
||||||
|
}
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
key := r.ProviderPaymentID
|
||||||
|
if seen[key] {
|
||||||
|
t.Errorf("row %s appeared on two pages", key)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) != 5 {
|
||||||
|
t.Errorf("walked %d rows across the pages, want 5", len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLedgerConsolePageAndExport drives the console: the page renders under a filter, the CSV export
|
||||||
|
// honours that same filter, and the user card links to the ledger scoped to the account instead of
|
||||||
|
// listing the rows itself.
|
||||||
|
func TestLedgerConsolePageAndExport(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
acc, other := provisionAccount(t), provisionAccount(t)
|
||||||
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"Pack A"}`, time.Hour)
|
||||||
|
seedLedgerRow(t, other, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"Pack B"}`, time.Hour)
|
||||||
|
|
||||||
|
rec := consoleGet(t, srv, "/_gm/ledger")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("ledger page = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{"Pack A", "Pack B", "149.00 RUB", "Totals for the filtered range"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("ledger page does not show %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering to one account must drop the other's operation from the page.
|
||||||
|
rec = consoleGet(t, srv, "/_gm/ledger?user="+acc.String())
|
||||||
|
if body := rec.Body.String(); !strings.Contains(body, "Pack A") || strings.Contains(body, "Pack B") {
|
||||||
|
t.Error("the user filter did not scope the ledger page to one account")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pager links must carry the filters, or paging would silently widen the view.
|
||||||
|
if body := rec.Body.String(); strings.Contains(body, "page=") && !strings.Contains(body, "user="+acc.String()+"&page=") {
|
||||||
|
t.Error("a pager link dropped the active filters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The export honours the same filter as the screen it is linked from.
|
||||||
|
rec = consoleGet(t, srv, "/_gm/ledger.csv?user="+acc.String())
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("ledger export = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
csv := rec.Body.String()
|
||||||
|
if !strings.Contains(csv, "Pack A") || strings.Contains(csv, "Pack B") {
|
||||||
|
t.Error("the CSV export ignored the filter")
|
||||||
|
}
|
||||||
|
if !strings.Contains(csv, "amount_minor,currency") {
|
||||||
|
t.Error("the CSV export has no money columns")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user card is a summary now: totals and a link into the ledger, not the rows themselves.
|
||||||
|
rec = consoleGet(t, srv, "/_gm/users/"+acc.String())
|
||||||
|
card := rec.Body.String()
|
||||||
|
if !strings.Contains(card, "/_gm/ledger?user="+acc.String()) {
|
||||||
|
t.Error("the user card does not link to its ledger slice")
|
||||||
|
}
|
||||||
|
if !strings.Contains(card, "Chips credited") {
|
||||||
|
t.Error("the user card shows no totals summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,8 @@ func TestAccountStatement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
|
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
|
||||||
// grant surface as the segment balance, the benefit and the ledger rows.
|
// grant surface as the segment balance, the benefit and the lifetime summary. The operations
|
||||||
|
// themselves belong to the ledger section, which the card links to scoped to this account.
|
||||||
func TestConsoleFinancePanel(t *testing.T) {
|
func TestConsoleFinancePanel(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
srv, _, pay := bannerServer(t)
|
srv, _, pay := bannerServer(t)
|
||||||
@@ -93,9 +94,17 @@ func TestConsoleFinancePanel(t *testing.T) {
|
|||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
t.Fatalf("user card = %d, want 200", code)
|
t.Fatalf("user card = %d, want 200", code)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
|
for _, want := range []string{
|
||||||
|
"Finance", "Chips (direct)", "Benefits (direct)", "5 hints",
|
||||||
|
"Chips credited", "149.00 RUB", // the lifetime summary: chips in, money paid
|
||||||
|
"/_gm/ledger?user=" + id.String(), // the hand-off to the operations view
|
||||||
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("finance panel missing %q", want)
|
t.Errorf("finance panel missing %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The card must not re-render the operations the ledger section owns.
|
||||||
|
if strings.Contains(body, "admin_grant") {
|
||||||
|
t.Error("the user card still lists ledger rows; they belong to /_gm/ledger now")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package payments
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -63,3 +64,100 @@ type LedgerEntry struct {
|
|||||||
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
||||||
return s.store.accountStatement(ctx, accountID)
|
return s.store.accountStatement(ctx, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LedgerFilter narrows the operator's ledger report. A zero value matches everything; each set field
|
||||||
|
// narrows further. From is inclusive and To exclusive, so a day range does not double-count a row on
|
||||||
|
// the boundary.
|
||||||
|
//
|
||||||
|
// Wallet and Provider are deliberately separate axes, because they answer different questions.
|
||||||
|
// Wallet ("what happened on VK") matches the row's funded segment or the origin a benefit was bought
|
||||||
|
// in; Provider ("what came through YooKassa") matches the rail that settled it — and a chip spend has
|
||||||
|
// no rail at all, so a provider filter excludes spends by construction.
|
||||||
|
type LedgerFilter struct {
|
||||||
|
From time.Time
|
||||||
|
To time.Time
|
||||||
|
AccountID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
Wallet string
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerReportRow is one ledger row for the operator report: the entry itself, the account it
|
||||||
|
// belongs to (the per-account report already knows that, the all-accounts one does not) and the
|
||||||
|
// money the row moved, recovered from the snapshot — the ledger's own columns count chips.
|
||||||
|
type LedgerReportRow struct {
|
||||||
|
LedgerEntry
|
||||||
|
AccountID uuid.UUID
|
||||||
|
// Money is what the customer actually paid or was refunded, or the zero Money for a row that
|
||||||
|
// moved no money (a spend, an admin grant, a rewarded-video credit).
|
||||||
|
Money Money
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasMoney reports whether the row moved real money.
|
||||||
|
func (r LedgerReportRow) HasMoney() bool { return r.Money.Currency() != "" }
|
||||||
|
|
||||||
|
// LedgerTotals sums everything a filter matches, not merely the page on screen. Money is listed per
|
||||||
|
// currency because the rails settle in different ones (roubles, Votes, Stars) and summing across
|
||||||
|
// them would be meaningless.
|
||||||
|
type LedgerTotals struct {
|
||||||
|
MoneyIn []Money
|
||||||
|
MoneyRefunded []Money
|
||||||
|
ChipsIn int
|
||||||
|
ChipsOut int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerReport is one page of the filtered ledger plus the totals for the whole filtered range and
|
||||||
|
// how many rows it matches.
|
||||||
|
type LedgerReport struct {
|
||||||
|
Rows []LedgerReportRow
|
||||||
|
Totals LedgerTotals
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportLimit caps the CSV export. The ledger is append-only and grows forever, so an unbounded
|
||||||
|
// export would eventually time out; the filter is the way to narrow a real accounting export.
|
||||||
|
const exportLimit = 100_000
|
||||||
|
|
||||||
|
// LedgerReportPage reads one page of the filtered ledger together with the totals for everything the
|
||||||
|
// filter matches. It is the all-accounts operator view: uncached, admin-only, not a hot path.
|
||||||
|
func (s *Service) LedgerReportPage(ctx context.Context, f LedgerFilter, limit, offset int) (LedgerReport, error) {
|
||||||
|
rows, total, err := s.store.ledgerPage(ctx, f, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return LedgerReport{}, err
|
||||||
|
}
|
||||||
|
totals, err := s.store.ledgerTotals(ctx, f)
|
||||||
|
if err != nil {
|
||||||
|
return LedgerReport{}, err
|
||||||
|
}
|
||||||
|
return LedgerReport{Rows: rows, Totals: totals, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilteredLedgerExport reads the whole filtered ledger for the CSV export, capped at exportLimit.
|
||||||
|
func (s *Service) FilteredLedgerExport(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
|
||||||
|
return s.store.filteredLedger(ctx, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoneyFromLedgerSnapshot recovers the money a ledger row moved from its snapshot, returning the
|
||||||
|
// zero Money when the row carries none (a spend, a grant) or the snapshot predates the fields. It is
|
||||||
|
// exported for callers folding their own totals out of a Statement's history.
|
||||||
|
func MoneyFromLedgerSnapshot(snapshot string) Money { return moneyFromSnapshot(snapshot) }
|
||||||
|
|
||||||
|
// moneyFromSnapshot recovers the money a ledger row moved from its snapshot, returning the zero
|
||||||
|
// Money when the row carries none (a spend, a grant) or the snapshot predates the fields.
|
||||||
|
func moneyFromSnapshot(snapshot string) Money {
|
||||||
|
if snapshot == "" {
|
||||||
|
return Money{}
|
||||||
|
}
|
||||||
|
var snap struct {
|
||||||
|
Amount int64 `json:"amount_minor"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil || snap.Currency == "" {
|
||||||
|
return Money{}
|
||||||
|
}
|
||||||
|
m, err := MoneyFromMinor(snap.Amount, Currency(snap.Currency))
|
||||||
|
if err != nil {
|
||||||
|
return Money{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledgerColumns is the projection every ledger report row is read through, joined to the order so a
|
||||||
|
// row carries the merchant channel its payment used.
|
||||||
|
const ledgerColumns = `l.account_id, l.kind, l.source, l.origin, l.chips_delta, l.product_id,
|
||||||
|
l.order_id, l.provider, l.provider_payment_id, COALESCE(o.shop, ''), l.snapshot, l.created_at`
|
||||||
|
|
||||||
|
// ledgerWhere renders a LedgerFilter as a SQL predicate plus its arguments. Every value is bound as
|
||||||
|
// a parameter; only the fixed fragments are concatenated.
|
||||||
|
//
|
||||||
|
// The wallet filter deliberately matches either side of a row. A row names up to two wallets — the
|
||||||
|
// segment whose chips moved (source) and, for a benefit purchase, where that benefit was bought
|
||||||
|
// (origin) — and an operator asking "what happened on VK" means both.
|
||||||
|
func ledgerWhere(f LedgerFilter) (string, []any) {
|
||||||
|
var clauses []string
|
||||||
|
var args []any
|
||||||
|
add := func(clause string, v any) {
|
||||||
|
args = append(args, v)
|
||||||
|
clauses = append(clauses, fmt.Sprintf(clause, len(args)))
|
||||||
|
}
|
||||||
|
if !f.From.IsZero() {
|
||||||
|
add("l.created_at >= $%d", f.From)
|
||||||
|
}
|
||||||
|
if !f.To.IsZero() {
|
||||||
|
add("l.created_at < $%d", f.To)
|
||||||
|
}
|
||||||
|
if f.AccountID != uuid.Nil {
|
||||||
|
add("l.account_id = $%d", f.AccountID)
|
||||||
|
}
|
||||||
|
if f.Kind != "" {
|
||||||
|
add("l.kind = $%d", f.Kind)
|
||||||
|
}
|
||||||
|
if f.Provider != "" {
|
||||||
|
add("l.provider = $%d", f.Provider)
|
||||||
|
}
|
||||||
|
if f.Wallet != "" {
|
||||||
|
args = append(args, f.Wallet)
|
||||||
|
clauses = append(clauses, fmt.Sprintf("(l.source = $%d OR l.origin = $%d)", len(args), len(args)))
|
||||||
|
}
|
||||||
|
if len(clauses) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerPage reads one page of the filtered ledger, newest first, together with how many rows the
|
||||||
|
// filter matches in total (which is what the pager needs — the page itself cannot report it).
|
||||||
|
func (s *Store) ledgerPage(ctx context.Context, f LedgerFilter, limit, offset int) ([]LedgerReportRow, int, error) {
|
||||||
|
where, args := ledgerWhere(f)
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM payments.ledger l`+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: count ledger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `SELECT ` + ledgerColumns + `
|
||||||
|
FROM payments.ledger l
|
||||||
|
LEFT JOIN payments.orders o ON o.order_id = l.order_id` + where + `
|
||||||
|
ORDER BY l.created_at DESC, l.ledger_id DESC
|
||||||
|
LIMIT $%d OFFSET $%d`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(q, len(args)-1, len(args)), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []LedgerReportRow
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
r LedgerReportRow
|
||||||
|
accountID uuid.UUID
|
||||||
|
source, origin, provider, paymentID sql.NullString
|
||||||
|
productID, orderID sql.NullString
|
||||||
|
snapshot sql.NullString
|
||||||
|
shop string
|
||||||
|
chipsDelta int32
|
||||||
|
createdAt time.Time
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&accountID, &r.Kind, &source, &origin, &chipsDelta, &productID,
|
||||||
|
&orderID, &provider, &paymentID, &shop, &snapshot, &createdAt); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: scan ledger row: %w", err)
|
||||||
|
}
|
||||||
|
r.AccountID = accountID
|
||||||
|
r.LedgerEntry = LedgerEntry{
|
||||||
|
Kind: r.Kind,
|
||||||
|
Source: source.String,
|
||||||
|
Origin: origin.String,
|
||||||
|
ChipsDelta: int(chipsDelta),
|
||||||
|
ProductID: productID.String,
|
||||||
|
OrderID: orderID.String,
|
||||||
|
Provider: provider.String,
|
||||||
|
ProviderPaymentID: paymentID.String,
|
||||||
|
Shop: shop,
|
||||||
|
Snapshot: snapshot.String,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
r.Money = moneyFromSnapshot(snapshot.String)
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerTotals sums what the filter matches — across every matching row, not just the page on
|
||||||
|
// screen, which is the point of showing them at all. Money is summed per currency from the row
|
||||||
|
// snapshot, since the ledger's own columns count chips, not money.
|
||||||
|
func (s *Store) ledgerTotals(ctx context.Context, f LedgerFilter) (LedgerTotals, error) {
|
||||||
|
where, args := ledgerWhere(f)
|
||||||
|
out := LedgerTotals{}
|
||||||
|
|
||||||
|
if err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COALESCE(SUM(l.chips_delta) FILTER (WHERE l.chips_delta > 0), 0),
|
||||||
|
COALESCE(-SUM(l.chips_delta) FILTER (WHERE l.chips_delta < 0), 0)
|
||||||
|
FROM payments.ledger l`+where, args...).Scan(&out.ChipsIn, &out.ChipsOut); err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: sum ledger chips: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The money predicate is folded into the same WHERE, because the filter's own clause list may be
|
||||||
|
// empty and an "AND ..." tacked onto a missing WHERE is a syntax error.
|
||||||
|
moneyWhere := where
|
||||||
|
const moneyOnly = `l.kind IN ('fund','refund') AND l.snapshot->>'amount_minor' IS NOT NULL`
|
||||||
|
if moneyWhere == "" {
|
||||||
|
moneyWhere = " WHERE " + moneyOnly
|
||||||
|
} else {
|
||||||
|
moneyWhere += " AND " + moneyOnly
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT l.kind, l.snapshot->>'currency',
|
||||||
|
COALESCE(SUM((l.snapshot->>'amount_minor')::bigint), 0)
|
||||||
|
FROM payments.ledger l`+moneyWhere+`
|
||||||
|
GROUP BY 1, 2`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var kind string
|
||||||
|
var currency sql.NullString
|
||||||
|
var minor int64
|
||||||
|
if err := rows.Scan(&kind, ¤cy, &minor); err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: scan ledger money: %w", err)
|
||||||
|
}
|
||||||
|
m, err := MoneyFromMinor(minor, Currency(currency.String))
|
||||||
|
if err != nil {
|
||||||
|
continue // an unknown currency in an old snapshot must not break the report
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "fund":
|
||||||
|
out.MoneyIn = append(out.MoneyIn, m)
|
||||||
|
case "refund":
|
||||||
|
out.MoneyRefunded = append(out.MoneyRefunded, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filteredLedger reads the whole filtered ledger for the CSV export — unpaginated by design, since
|
||||||
|
// an export of the page on screen would be useless for accounting.
|
||||||
|
func (s *Store) filteredLedger(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
|
||||||
|
rows, _, err := s.ledgerPage(ctx, f, exportLimit, 0)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -119,6 +120,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||||
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
||||||
|
gm.GET("/ledger", s.consoleLedger)
|
||||||
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,7 +460,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
|
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
|
||||||
view.Finance = financeView(stmt)
|
view.Finance = financeView(id, stmt)
|
||||||
} else {
|
} else {
|
||||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -470,9 +472,11 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// financeView projects an account's payments statement into the user-card finance panel, with the
|
// financeView projects an account's payments statement into the user-card finance panel: where the
|
||||||
// benefit expiry and ledger times pre-formatted for the logic-free template.
|
// account stands (balances, benefits, lifetime money and chip totals), not what happened when. The
|
||||||
func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
// operations themselves are the ledger section's job, which the panel links to pre-filtered to this
|
||||||
|
// account — one place renders them, with filters and paging, instead of two.
|
||||||
|
func financeView(id uuid.UUID, stmt payments.Statement) adminconsole.FinanceView {
|
||||||
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
|
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
|
||||||
for _, sg := range stmt.Segments {
|
for _, sg := range stmt.Segments {
|
||||||
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
|
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
|
||||||
@@ -484,16 +488,52 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
|||||||
}
|
}
|
||||||
fv.Benefits = append(fv.Benefits, row)
|
fv.Benefits = append(fv.Benefits, row)
|
||||||
}
|
}
|
||||||
|
// The lifetime totals are folded from the history the statement already carries, so the summary
|
||||||
|
// costs no extra query. Money is kept per currency: the rails settle in roubles, Votes and Stars,
|
||||||
|
// and one sum across them would mean nothing.
|
||||||
|
paid, refunded := map[payments.Currency]int64{}, map[payments.Currency]int64{}
|
||||||
for _, e := range stmt.Ledger {
|
for _, e := range stmt.Ledger {
|
||||||
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
if e.ChipsDelta > 0 {
|
||||||
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
fv.ChipsBought += e.ChipsDelta
|
||||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
|
} else if e.Kind == "spend" {
|
||||||
At: fmtTime(e.CreatedAt),
|
fv.ChipsSpent -= e.ChipsDelta
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
m := payments.MoneyFromLedgerSnapshot(e.Snapshot)
|
||||||
|
if m.Currency() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch e.Kind {
|
||||||
|
case "fund":
|
||||||
|
paid[m.Currency()] += m.Minor()
|
||||||
|
case "refund":
|
||||||
|
refunded[m.Currency()] += m.Minor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fv.Paid = fmtMoneyTotals(paid)
|
||||||
|
fv.Refunded = fmtMoneyTotals(refunded)
|
||||||
|
fv.LedgerQuery = template.URL(url.Values{"user": {id.String()}}.Encode())
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtMoneyTotals renders per-currency minor-unit sums as display strings, in a stable order so the
|
||||||
|
// panel does not reshuffle between reloads (map iteration is random).
|
||||||
|
func fmtMoneyTotals(totals map[payments.Currency]int64) []string {
|
||||||
|
currencies := make([]string, 0, len(totals))
|
||||||
|
for c := range totals {
|
||||||
|
currencies = append(currencies, string(c))
|
||||||
|
}
|
||||||
|
sort.Strings(currencies)
|
||||||
|
out := make([]string, 0, len(currencies))
|
||||||
|
for _, c := range currencies {
|
||||||
|
m, err := payments.MoneyFromMinor(totals[payments.Currency(c)], payments.Currency(c))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fmtMoney(m))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
|
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
|
||||||
func overrideName(ov payments.PurchaseOverride) string {
|
func overrideName(ov payments.PurchaseOverride) string {
|
||||||
switch ov {
|
switch ov {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledgerPageSize is how many operations one ledger page shows.
|
||||||
|
const ledgerPageSize = 100
|
||||||
|
|
||||||
|
// ledgerDefaultDays is how far back the ledger looks when the operator has not chosen a range. A
|
||||||
|
// month is the reporting period that matters (it is what a tax filing covers) and it keeps the
|
||||||
|
// default view bounded on a ledger that only ever grows.
|
||||||
|
const ledgerDefaultDays = 30
|
||||||
|
|
||||||
|
// ledgerDateFormat is the date form the filter accepts and renders (an <input type="date"> value).
|
||||||
|
const ledgerDateFormat = "2006-01-02"
|
||||||
|
|
||||||
|
// ledgerKinds, ledgerWallets and ledgerProviders are the filter's fixed option lists. They are
|
||||||
|
// spelled out rather than derived from the data so the form is stable on an empty ledger and an
|
||||||
|
// operator can see what exists at all.
|
||||||
|
var (
|
||||||
|
ledgerKinds = []string{"fund", "spend", "admin_grant", "refund"}
|
||||||
|
ledgerWallets = []string{string(payments.SourceDirect), string(payments.SourceVK), string(payments.SourceTelegram)}
|
||||||
|
ledgerProviders = []string{providerYooKassa, providerRobokassa, providerVK, providerTelegram, "vk_ads", "admin"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledgerFilterFrom reads the ledger filter out of the query string, defaulting the range to the last
|
||||||
|
// ledgerDefaultDays. An unparseable date or id is treated as unset rather than as an error: a
|
||||||
|
// hand-edited URL should narrow nothing, not break the page.
|
||||||
|
func ledgerFilterFrom(c *gin.Context, now time.Time) (payments.LedgerFilter, adminconsole.LedgerView) {
|
||||||
|
view := adminconsole.LedgerView{
|
||||||
|
Kinds: ledgerKinds,
|
||||||
|
Wallets: ledgerWallets,
|
||||||
|
Providers: ledgerProviders,
|
||||||
|
}
|
||||||
|
f := payments.LedgerFilter{}
|
||||||
|
|
||||||
|
from, to := strings.TrimSpace(c.Query("from")), strings.TrimSpace(c.Query("to"))
|
||||||
|
if from == "" && to == "" {
|
||||||
|
from = now.AddDate(0, 0, -ledgerDefaultDays).Format(ledgerDateFormat)
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(ledgerDateFormat, from); err == nil {
|
||||||
|
f.From, view.From = t, from
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(ledgerDateFormat, to); err == nil {
|
||||||
|
// The range is inclusive of the chosen end date, so it runs to the start of the next day.
|
||||||
|
f.To, view.To = t.AddDate(0, 0, 1), to
|
||||||
|
}
|
||||||
|
if id, err := uuid.Parse(strings.TrimSpace(c.Query("user"))); err == nil {
|
||||||
|
f.AccountID, view.UserID = id, id.String()
|
||||||
|
}
|
||||||
|
if v := c.Query("kind"); slicesHas(ledgerKinds, v) {
|
||||||
|
f.Kind, view.Kind = v, v
|
||||||
|
}
|
||||||
|
if v := c.Query("wallet"); slicesHas(ledgerWallets, v) {
|
||||||
|
f.Wallet, view.Wallet = v, v
|
||||||
|
}
|
||||||
|
if v := c.Query("provider"); slicesHas(ledgerProviders, v) {
|
||||||
|
f.Provider, view.Provider = v, v
|
||||||
|
}
|
||||||
|
view.FilterQuery = template.URL(ledgerFilterQuery(view))
|
||||||
|
return f, view
|
||||||
|
}
|
||||||
|
|
||||||
|
// slicesHas reports whether v is one of the allowed options. The filter only ever accepts a value
|
||||||
|
// from its own list, so a crafted query string cannot reach the store with something unexpected.
|
||||||
|
func slicesHas(allowed []string, v string) bool {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerFilterQuery renders the active filters as an escaped query fragment. Every link that must
|
||||||
|
// stay on the same slice of the ledger — the pager, the CSV export, a refund's way back — is built
|
||||||
|
// from it, which is what keeps them in step with the form.
|
||||||
|
func ledgerFilterQuery(v adminconsole.LedgerView) string {
|
||||||
|
q := url.Values{}
|
||||||
|
for key, val := range map[string]string{
|
||||||
|
"from": v.From, "to": v.To, "user": v.UserID,
|
||||||
|
"kind": v.Kind, "wallet": v.Wallet, "provider": v.Provider,
|
||||||
|
} {
|
||||||
|
if val != "" {
|
||||||
|
q.Set(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLedger renders the all-accounts financial ledger: one page of operations under the current
|
||||||
|
// filter, and the totals for everything that filter matches — the picture no per-account card can
|
||||||
|
// give. The page number rides the same query string as the filters, so paging never silently widens
|
||||||
|
// the view.
|
||||||
|
func (s *Server) consoleLedger(c *gin.Context) {
|
||||||
|
f, view := ledgerFilterFrom(c, time.Now())
|
||||||
|
page, _ := strconv.Atoi(c.Query("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
report, err := s.payments.LedgerReportPage(c.Request.Context(), f, ledgerPageSize, (page-1)*ledgerPageSize)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.Pager = adminconsole.NewPager(page, ledgerPageSize, report.Total)
|
||||||
|
view.Totals = ledgerTotalsRow(report.Totals)
|
||||||
|
for _, r := range report.Rows {
|
||||||
|
view.Rows = append(view.Rows, ledgerRow(r))
|
||||||
|
}
|
||||||
|
s.renderConsole(c, "ledger", "ledger", "Ledger", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerRow projects one report row for the logic-free template: times and money pre-formatted, the
|
||||||
|
// product title lifted out of the snapshot, and the refund affordance decided here rather than in
|
||||||
|
// the template — only a funded order can be reversed.
|
||||||
|
func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow {
|
||||||
|
row := adminconsole.LedgerRow{
|
||||||
|
At: fmtTime(r.CreatedAt),
|
||||||
|
AccountID: r.AccountID.String(),
|
||||||
|
Kind: r.Kind,
|
||||||
|
Source: r.Source,
|
||||||
|
Origin: r.Origin,
|
||||||
|
ChipsDelta: r.ChipsDelta,
|
||||||
|
Order: r.OrderID,
|
||||||
|
Provider: r.Provider,
|
||||||
|
Shop: r.Shop,
|
||||||
|
Refundable: r.Kind == "fund" && r.OrderID != "",
|
||||||
|
Title: snapshotTitle(r.Snapshot),
|
||||||
|
}
|
||||||
|
if r.HasMoney() {
|
||||||
|
row.Money = fmtMoney(r.Money)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerTotalsRow pre-formats the period totals for the template.
|
||||||
|
func ledgerTotalsRow(t payments.LedgerTotals) adminconsole.LedgerTotalsRow {
|
||||||
|
out := adminconsole.LedgerTotalsRow{ChipsIn: t.ChipsIn, ChipsOut: t.ChipsOut}
|
||||||
|
for _, m := range t.MoneyIn {
|
||||||
|
out.MoneyIn = append(out.MoneyIn, fmtMoney(m))
|
||||||
|
}
|
||||||
|
for _, m := range t.MoneyRefunded {
|
||||||
|
out.MoneyRefunded = append(out.MoneyRefunded, fmtMoney(m))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtMoney renders an amount with its currency, e.g. "149.00 RUB".
|
||||||
|
func fmtMoney(m payments.Money) string {
|
||||||
|
return fmt.Sprintf("%s %s", m.Major(), m.Currency())
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotTitle lifts the sold product's title out of a ledger row snapshot, so the table names what
|
||||||
|
// was bought instead of only its id. It returns "" when the snapshot carries no title.
|
||||||
|
func snapshotTitle(snapshot string) string {
|
||||||
|
if snapshot == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var snap struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return snap.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLedgerExport streams the filtered ledger as a CSV attachment for tax reporting and rail
|
||||||
|
// reconciliation. It honours the same filters as the page it is linked from — an export that
|
||||||
|
// silently covered a different range than the screen would be worse than no export.
|
||||||
|
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
||||||
|
f, _ := ledgerFilterFrom(c, time.Now())
|
||||||
|
rows, err := s.payments.FilteredLedgerExport(c.Request.Context(), f)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{
|
||||||
|
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
|
||||||
|
"amount_minor", "currency", "product_id", "order_id", "provider", "provider_payment_id", "shop", "snapshot",
|
||||||
|
})
|
||||||
|
for _, r := range rows {
|
||||||
|
amount, currency := "", ""
|
||||||
|
if r.HasMoney() {
|
||||||
|
amount, currency = strconv.FormatInt(r.Money.Minor(), 10), string(r.Money.Currency())
|
||||||
|
}
|
||||||
|
_ = w.Write([]string{
|
||||||
|
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID.String(), r.Kind, r.Source, r.Origin,
|
||||||
|
strconv.Itoa(r.ChipsDelta), amount, currency,
|
||||||
|
r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Shop, r.Snapshot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
@@ -2,12 +2,9 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -27,7 +24,7 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
back := "/_gm/users/" + id.String()
|
back := consoleReturnTo(c.PostForm("back"), "/_gm/users/"+id.String())
|
||||||
if s.payments == nil {
|
if s.payments == nil {
|
||||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
return
|
return
|
||||||
@@ -69,6 +66,18 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
|||||||
s.renderConsoleMessage(c, "Refunded", msg, back)
|
s.renderConsoleMessage(c, "Refunded", msg, back)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleReturnTo picks where the result page links back to: the caller's requested destination when
|
||||||
|
// it is a console page, else the fallback. A refund can be started from the account card or from the
|
||||||
|
// ledger (where the operator wants to land back on the same filtered slice), so the destination
|
||||||
|
// travels with the form — but only ever as a console-relative path, never as an arbitrary URL a
|
||||||
|
// crafted form could point elsewhere.
|
||||||
|
func consoleReturnTo(requested, fallback string) string {
|
||||||
|
if strings.HasPrefix(requested, "/_gm/") && !strings.HasPrefix(requested, "/_gm//") {
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
|
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
|
||||||
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
|
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
|
||||||
// must never claim a refund that did not happen. The reversal is then recorded under the provider's
|
// must never claim a refund that did not happen. The reversal is then recorded under the provider's
|
||||||
@@ -93,27 +102,3 @@ func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (paymen
|
|||||||
}
|
}
|
||||||
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
|
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
|
|
||||||
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
|
|
||||||
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
|
||||||
rows, err := s.payments.LedgerExport(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
s.consoleError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
||||||
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
|
|
||||||
w := csv.NewWriter(c.Writer)
|
|
||||||
_ = w.Write([]string{
|
|
||||||
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
|
|
||||||
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
|
|
||||||
})
|
|
||||||
for _, r := range rows {
|
|
||||||
_ = w.Write([]string{
|
|
||||||
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
|
|
||||||
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-3
@@ -418,9 +418,23 @@ origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume
|
|||||||
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
||||||
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
|
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
|
||||||
|
|
||||||
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
**The ledger section** `/_gm/ledger` is the all-accounts view of the money: every operation, newest
|
||||||
spends, grants, refunds, full history — as an extension of the existing user card
|
first, filtered by date range (defaulting to the last 30 days), by **wallet** (`vk`/`telegram`/
|
||||||
(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
|
`direct` — matching the funded segment or the benefit origin), by **rail** (the settling provider, so
|
||||||
|
chip spends drop out by construction), by kind and by account. Above the table sit the totals for
|
||||||
|
**everything the filter matches**, not merely the page: money in and refunded per currency (the rails
|
||||||
|
settle in roubles, Votes and Stars, so one sum across them would mean nothing) and chips credited and
|
||||||
|
spent. The row amounts come from the ledger snapshot, since the ledger's own columns count chips.
|
||||||
|
Paging, the CSV export and a refund's way back all carry the same filter query, so none of them can
|
||||||
|
silently show a different slice than the screen.
|
||||||
|
|
||||||
|
The **refund action** lives on the funded rows here — the operator can find a payment by filter
|
||||||
|
without knowing whose it is first — and returns to the same filtered view afterwards.
|
||||||
|
|
||||||
|
**The user card** carries the account's standing rather than its history: segment balances, benefits,
|
||||||
|
the refund-risk flag, and a short lifetime summary (money paid and refunded per currency, chips
|
||||||
|
credited and spent), with a link into the ledger section pre-filtered to that account. One place
|
||||||
|
renders operations, with filters and paging, instead of two.
|
||||||
|
|
||||||
## 12. Taxes and compliance
|
## 12. Taxes and compliance
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -410,9 +410,24 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
|||||||
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||||
полный аудит наград.
|
полный аудит наград.
|
||||||
|
|
||||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
**Раздел журнала** `/_gm/ledger` — общий вид на деньги по всем аккаунтам: все операции, новые
|
||||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
сверху, с фильтрами по диапазону дат (по умолчанию последние 30 дней), по **кошельку**
|
||||||
`handlers_admin_console.go`). Плюс экспорт журнала.
|
(`vk`/`telegram`/`direct` — совпадение по пополненному сегменту или по origin бенефита), по
|
||||||
|
**рельсу** (провайдер, который провёл платёж, поэтому траты Фишек в такой фильтр не попадают вовсе),
|
||||||
|
по виду операции и по аккаунту. Над таблицей — итоги по **всему, что попало под фильтр**, а не по
|
||||||
|
странице: пришло и возвращено денег по каждой валюте отдельно (рельсы считают в рублях, Голосах и
|
||||||
|
Stars, общая сумма по ним не значила бы ничего) и начислено/списано Фишек. Суммы в строках берутся из
|
||||||
|
снимка операции, потому что собственные колонки журнала считают Фишки, а не деньги. Пагинация,
|
||||||
|
выгрузка в CSV и возврат оператора после refund несут одну и ту же строку фильтров, поэтому ни один
|
||||||
|
из них не может незаметно показать срез, отличный от экрана.
|
||||||
|
|
||||||
|
**Кнопка возврата** живёт на строках пополнения здесь же — оператор может найти платёж фильтрами, не
|
||||||
|
зная заранее, чей он, — и после возврата возвращает на тот же отфильтрованный вид.
|
||||||
|
|
||||||
|
**Карточка пользователя** показывает положение дел, а не историю: балансы сегментов, бенефиты, флаг
|
||||||
|
риска и краткую сводку за всё время (сколько заплачено и возвращено по валютам, сколько Фишек
|
||||||
|
начислено и потрачено), плюс ссылку в раздел журнала, уже отфильтрованный по этому аккаунту.
|
||||||
|
Операции рисуются в одном месте, с фильтрами и страницами, а не в двух.
|
||||||
|
|
||||||
## 12. Налоги и комплаенс
|
## 12. Налоги и комплаенс
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user