feat(payments): settle the direct rail through YooKassa #293
@@ -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,8 +207,10 @@ 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
|
||||
@@ -216,7 +218,15 @@ type FinanceView struct {
|
||||
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
||||
Abuse bool
|
||||
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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
@@ -93,9 +94,17 @@ func TestConsoleFinancePanel(t *testing.T) {
|
||||
if code != http.StatusOK {
|
||||
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) {
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -63,3 +64,100 @@ type LedgerEntry struct {
|
||||
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
||||
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"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -119,6 +120,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
||||
gm.GET("/ledger", s.consoleLedger)
|
||||
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
||||
}
|
||||
}
|
||||
@@ -458,7 +460,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
}
|
||||
if s.payments != nil {
|
||||
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
|
||||
view.Finance = financeView(stmt)
|
||||
view.Finance = financeView(id, stmt)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
// financeView projects an account's payments statement into the user-card finance panel, with the
|
||||
// benefit expiry and ledger times pre-formatted for the logic-free template.
|
||||
func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
||||
// financeView projects an account's payments statement into the user-card finance panel: where the
|
||||
// account stands (balances, benefits, lifetime money and chip totals), not what happened when. The
|
||||
// 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}
|
||||
for _, sg := range stmt.Segments {
|
||||
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)
|
||||
}
|
||||
// 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 {
|
||||
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
||||
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
|
||||
At: fmtTime(e.CreatedAt),
|
||||
})
|
||||
if e.ChipsDelta > 0 {
|
||||
fv.ChipsBought += e.ChipsDelta
|
||||
} else if e.Kind == "spend" {
|
||||
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
|
||||
}
|
||||
|
||||
// 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").
|
||||
func overrideName(ov payments.PurchaseOverride) string {
|
||||
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 (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -27,7 +24,7 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
back := consoleReturnTo(c.PostForm("back"), "/_gm/users/"+id.String())
|
||||
if s.payments == nil {
|
||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||
return
|
||||
@@ -69,6 +66,18 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
||||
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
|
||||
// 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
|
||||
@@ -93,27 +102,3 @@ func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (paymen
|
||||
}
|
||||
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
|
||||
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,
|
||||
spends, grants, refunds, full history — as an extension of the existing user card
|
||||
(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
|
||||
**The ledger section** `/_gm/ledger` is the all-accounts view of the money: every operation, newest
|
||||
first, filtered by date range (defaulting to the last 30 days), by **wallet** (`vk`/`telegram`/
|
||||
`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
|
||||
|
||||
|
||||
+18
-3
@@ -410,9 +410,24 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
||||
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||
полный аудит наград.
|
||||
|
||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||
`handlers_admin_console.go`). Плюс экспорт журнала.
|
||||
**Раздел журнала** `/_gm/ledger` — общий вид на деньги по всем аккаунтам: все операции, новые
|
||||
сверху, с фильтрами по диапазону дат (по умолчанию последние 30 дней), по **кошельку**
|
||||
(`vk`/`telegram`/`direct` — совпадение по пополненному сегменту или по origin бенефита), по
|
||||
**рельсу** (провайдер, который провёл платёж, поэтому траты Фишек в такой фильтр не попадают вовсе),
|
||||
по виду операции и по аккаунту. Над таблицей — итоги по **всему, что попало под фильтр**, а не по
|
||||
странице: пришло и возвращено денег по каждой валюте отдельно (рельсы считают в рублях, Голосах и
|
||||
Stars, общая сумма по ним не значила бы ничего) и начислено/списано Фишек. Суммы в строках берутся из
|
||||
снимка операции, потому что собственные колонки журнала считают Фишки, а не деньги. Пагинация,
|
||||
выгрузка в CSV и возврат оператора после refund несут одну и ту же строку фильтров, поэтому ни один
|
||||
из них не может незаметно показать срез, отличный от экрана.
|
||||
|
||||
**Кнопка возврата** живёт на строках пополнения здесь же — оператор может найти платёж фильтрами, не
|
||||
зная заранее, чей он, — и после возврата возвращает на тот же отфильтрованный вид.
|
||||
|
||||
**Карточка пользователя** показывает положение дел, а не историю: балансы сегментов, бенефиты, флаг
|
||||
риска и краткую сводку за всё время (сколько заплачено и возвращено по валютам, сколько Фишек
|
||||
начислено и потрачено), плюс ссылку в раздел журнала, уже отфильтрованный по этому аккаунту.
|
||||
Операции рисуются в одном месте, с фильтрами и страницами, а не в двух.
|
||||
|
||||
## 12. Налоги и комплаенс
|
||||
|
||||
|
||||
Reference in New Issue
Block a user