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 }