69bddaeb9a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
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 1m49s
The receipt letter repeated what the receipt itself states. It now carries the amount, the moment of payment with its offset, and the link — the receipt is the document, and it opens without authentication, so there is nothing for the two to disagree about. Subject reworded to name what it is. The ledger and tax-export tables labelled the account link "card", meaning the user card. On pages about payments that reads as a bank card, which is exactly how it was misread. Both now show the first eight characters of the account id, so the column carries data — two rows from the same buyer are visible at a glance — while the link still holds the whole id.
214 lines
7.5 KiB
Go
214 lines
7.5 KiB
Go
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(),
|
|
AccountShort: shortID(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()
|
|
}
|