diff --git a/PLAN.md b/PLAN.md index f1cf9a3..55a4138 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,7 +36,7 @@ status — without re-deriving decisions. | E4 | Durability (PITR) | 2 | DONE | | E5 | Payment intake | 2 | DONE | | E6 | Ads | 2 | DONE | -| E7 | Admin, reports & catalog | 2 | TODO | +| E7 | Admin, reports & catalog | 2 | DONE | | E8 | Guest limits | — | TODO | | E9 | Tournament fee | future | TODO | @@ -695,7 +695,7 @@ it tunes without a store release. ## E7 — Admin, reports & catalog -**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds), +**Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds), E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32. **Delivery & baked decisions (this planning round).** A linear PR stack into `development`. @@ -763,15 +763,16 @@ ledger export — plus the **configurable product catalog editor** (D32). products + prices and delete only never-transacted ones; grant concrete values raw or by-product (origin-picked, never chips/tournament); refund an order in full; export the ledger. -**PR stack (linear into `development`).** +**PR stack (linear into `development`, all merged).** -1. **Per-user financial panel** (read-only ledger/segments/benefits on the user card; retires the - `PaidAccount`/`HintBalance` display). -2. **Catalog editor** (product/atom/price CRUD + archive/unarchive + delete-if-clean + shape - validation). -3. **Admin grant** (raw + by-product, refuse chips/tournament, origin-picked, `admin_grant` + - snapshot). -4. **Manual refund** (full order via E5 `Refund`) + **ledger export** (CSV/JSON). +1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card; + retired the `PaidAccount`/`HintBalance` display. +2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean + + shape validation. +3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw + + by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot. +4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull` + (idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`. **Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds diff --git a/backend/README.md b/backend/README.md index 083dcc3..6a7e1c0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -150,7 +150,11 @@ backed by the FK); a `tournament`-bearing product is composable but not sellable also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a defined **value product** (a reward bundle, including an archived one), origin-picked; both write an `admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament` -atom (never grant currency; no tournament target yet). The shared wire +atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund** +action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the +rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export** +(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax + +reconciliation. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 98a425a..b5a4ec7 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -73,15 +73,18 @@ {{else}}

no balances or benefits

{{end}}

Ledger

+{{$uid := .ID}} {{if .Finance.Ledger}} - + {{range .Finance.Ledger}} - + + {{end}}
TimeKindSourceOriginChipsOrderProviderDetail
TimeKindSourceOriginChipsOrderProviderDetail
{{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{end}}
{{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{end}}{{if and (eq .Kind "fund") .Order}}
{{end}}
+

Export the full ledger (CSV) — all accounts, for tax + reconciliation.

{{else}}

no ledger entries

{{end}} {{else}}

payments not enabled

{{end}} diff --git a/backend/internal/inttest/admin_refund_test.go b/backend/internal/inttest/admin_refund_test.go new file mode 100644 index 0000000..bb03d80 --- /dev/null +++ b/backend/internal/inttest/admin_refund_test.go @@ -0,0 +1,77 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "strings" + "testing" + + "scrabble/backend/internal/payments" +) + +// TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is +// refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries +// the fund + refund rows; the refund POST is CSRF-guarded. +func TestConsoleRefundAndExport(t *testing.T) { + ctx := context.Background() + srv, _, pay := bannerServer(t) + h := srv.Handler() + id := provisionAccount(t) + const origin = "http://admin.test" + base := "http://admin.test/_gm/users/" + id.String() + + // Fund a pack: 100 chips + a fund ledger row. + prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) + res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") + if err != nil { + t.Fatalf("order: %v", err) + } + paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) + if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil { + t.Fatalf("fund: %v", err) + } + + // CSRF: a refund without the origin header is refused. + if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden { + t.Fatalf("refund without origin = %d, want 403", code) + } + + // Refund the order in full → 100 chips revoked (none spent). + if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") { + t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips")) + } + stmt, err := pay.AccountStatement(ctx, id) + if err != nil { + t.Fatalf("statement: %v", err) + } + for _, sg := range stmt.Segments { + if sg.Source == payments.SourceDirect && sg.Chips != 0 { + t.Errorf("after refund: direct chips = %d, want 0", sg.Chips) + } + } + kinds := map[string]int{} + for _, e := range stmt.Ledger { + kinds[e.Kind]++ + } + if kinds["fund"] != 1 || kinds["refund"] != 1 { + t.Errorf("ledger kinds = %v, want one fund + one refund", kinds) + } + + // A second refund of the same order is idempotent. + if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") { + t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded")) + } + + // Export the whole ledger as CSV: the header, this account, and its fund + refund rows. + code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "") + if code != http.StatusOK { + t.Fatalf("export = %d, want 200", code) + } + for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} { + if !strings.Contains(body, want) { + t.Errorf("ledger CSV missing %q", want) + } + } +} diff --git a/backend/internal/payments/refund_admin.go b/backend/internal/payments/refund_admin.go new file mode 100644 index 0000000..56a0829 --- /dev/null +++ b/backend/internal/payments/refund_admin.go @@ -0,0 +1,41 @@ +package payments + +import ( + "context" + + "github.com/google/uuid" +) + +// providerAdmin tags an operator-initiated refund in the ledger, distinct from a rail's own refund +// (robokassa / vk / telegram). The refund idempotency key (providerAdmin, order id) allows exactly +// one manual full refund per order. +const providerAdmin = "admin" + +// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded +// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is +// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund +// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it. +func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) { + o, err := s.store.orderByID(ctx, orderID) + if err != nil { + return RefundOutcome{}, err + } + refunded, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency)) + if err != nil { + return RefundOutcome{}, err + } + return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock()) +} + +// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the +// account it belongs to alongside the entry fields. +type LedgerExportRow struct { + AccountID string + LedgerEntry +} + +// LedgerExport reads the entire append-only ledger (all accounts, newest first) for a CSV/JSON +// export — tax reporting and future rail reconciliation. Uncached, admin-only. +func (s *Service) LedgerExport(ctx context.Context) ([]LedgerExportRow, error) { + return s.store.allLedger(ctx) +} diff --git a/backend/internal/payments/store_statement.go b/backend/internal/payments/store_statement.go index dc18516..33cb623 100644 --- a/backend/internal/payments/store_statement.go +++ b/backend/internal/payments/store_statement.go @@ -77,6 +77,36 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat return out, nil } +// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export. +func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) { + var rows []model.Ledger + if err := postgres.SELECT(table.Ledger.AllColumns). + FROM(table.Ledger). + ORDER_BY(table.Ledger.CreatedAt.DESC()). + QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("payments: export ledger: %w", err) + } + out := make([]LedgerExportRow, 0, len(rows)) + for _, r := range rows { + out = append(out, LedgerExportRow{ + AccountID: r.AccountID.String(), + LedgerEntry: LedgerEntry{ + Kind: r.Kind, + Source: derefStr(r.Source), + Origin: derefStr(r.Origin), + ChipsDelta: int(r.ChipsDelta), + ProductID: derefUUID(r.ProductID), + OrderID: derefUUID(r.OrderID), + Provider: derefStr(r.Provider), + ProviderPaymentID: derefStr(r.ProviderPaymentID), + Snapshot: derefStr(r.Snapshot), + CreatedAt: r.CreatedAt, + }, + }) + } + return out, nil +} + // derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column). func derefStr(p *string) string { if p == nil { diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index ef997a2..ac7b17e 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -60,6 +60,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/remove-email", s.consoleRemoveEmail) gm.POST("/users/:id/grant", s.consoleGrant) gm.POST("/users/:id/grant-product", s.consoleGrantProduct) + gm.POST("/users/:id/refund", s.consoleRefund) gm.POST("/users/:id/delete", s.consoleDeleteUser) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) @@ -111,6 +112,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.csv", s.consoleLedgerExport) } } diff --git a/backend/internal/server/handlers_admin_refund.go b/backend/internal/server/handlers_admin_refund.go new file mode 100644 index 0000000..b804f88 --- /dev/null +++ b/backend/internal/server/handlers_admin_refund.go @@ -0,0 +1,71 @@ +package server + +import ( + "encoding/csv" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// consoleRefund refunds a paid order in full at the operator's request. The operator performs the +// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); +// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent. +func (s *Server) consoleRefund(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + if s.payments == nil { + s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back) + return + } + orderID, err := uuid.Parse(strings.TrimSpace(c.PostForm("order_id"))) + if err != nil { + s.renderConsoleMessage(c, "Refund failed", "no order to refund", back) + return + } + out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID) + if err != nil { + s.renderConsoleMessage(c, "Refund failed", err.Error(), back) + return + } + if out.AlreadyRefunded { + s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back) + return + } + s.publishBannerChange(id) + msg := fmt.Sprintf("revoked %d chips", out.Revoked) + if out.Loss > 0 { + msg += fmt.Sprintf("; %d chips were already spent (recorded as a loss + abuse flag)", out.Loss) + } + s.renderConsoleMessage(c, "Refunded", msg, back) +} + +// 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() +}