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 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() }