feat(gl): expose ledger application and grpc operations
This commit is contained in:
@@ -18,6 +18,13 @@ type Row interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
type Rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...any) error
|
||||
Err() error
|
||||
Close()
|
||||
}
|
||||
|
||||
type Tx interface {
|
||||
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||
QueryRow(context.Context, string, ...any) Row
|
||||
@@ -27,7 +34,9 @@ type Tx interface {
|
||||
|
||||
type Database interface {
|
||||
Begin(context.Context) (Tx, error)
|
||||
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||
QueryRow(context.Context, string, ...any) Row
|
||||
Query(context.Context, string, ...any) (Rows, error)
|
||||
Ping(context.Context) error
|
||||
Close()
|
||||
}
|
||||
@@ -70,6 +79,14 @@ func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row {
|
||||
return p.pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (p *Pool) Query(ctx context.Context, sql string, args ...any) (Rows, error) {
|
||||
return p.pool.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (p *Pool) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
return p.pool.Exec(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (p *Pool) Close() {
|
||||
p.pool.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gl/domain/ledger"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (r *JournalRepository) AppendEvent(ctx context.Context, event ledger.TransactionEvent) (ledger.TransactionEvent, bool, error) {
|
||||
if err := event.Validate(); err != nil {
|
||||
return ledger.TransactionEvent{}, false, fmt.Errorf("validate transaction event: %w", err)
|
||||
}
|
||||
metadataValues := event.Metadata
|
||||
if metadataValues == nil {
|
||||
metadataValues = map[string]string{}
|
||||
}
|
||||
metadata, err := json.Marshal(metadataValues)
|
||||
if err != nil {
|
||||
return ledger.TransactionEvent{}, false, fmt.Errorf("encode event metadata: %w", err)
|
||||
}
|
||||
|
||||
err = r.database.QueryRow(ctx, insertEventSQL,
|
||||
event.ID,
|
||||
event.SourceService,
|
||||
event.IdempotencyKey,
|
||||
event.SourceTransactionID,
|
||||
event.TrackingCode,
|
||||
event.EventVersion,
|
||||
event.State,
|
||||
event.ErrorCode,
|
||||
event.ErrorMessage,
|
||||
event.OccurredAt,
|
||||
event.CorrelationID,
|
||||
event.ActorID,
|
||||
event.Blockchain.Network,
|
||||
event.Blockchain.TransactionHash,
|
||||
event.Blockchain.LedgerSequence,
|
||||
metadata,
|
||||
event.PayloadHash,
|
||||
).Scan(&event.RecordedAt)
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
if err != nil {
|
||||
return ledger.TransactionEvent{}, false, fmt.Errorf("insert transaction event: %w", err)
|
||||
}
|
||||
return event, false, nil
|
||||
}
|
||||
|
||||
var stored ledger.TransactionEvent
|
||||
var storedMetadata []byte
|
||||
err = r.database.QueryRow(ctx, selectEventByIdempotencySQL, event.IdempotencyKey).Scan(
|
||||
&stored.ID,
|
||||
&stored.SourceService,
|
||||
&stored.IdempotencyKey,
|
||||
&stored.SourceTransactionID,
|
||||
&stored.TrackingCode,
|
||||
&stored.EventVersion,
|
||||
&stored.State,
|
||||
&stored.ErrorCode,
|
||||
&stored.ErrorMessage,
|
||||
&stored.OccurredAt,
|
||||
&stored.RecordedAt,
|
||||
&stored.CorrelationID,
|
||||
&stored.ActorID,
|
||||
&stored.Blockchain.Network,
|
||||
&stored.Blockchain.TransactionHash,
|
||||
&stored.Blockchain.LedgerSequence,
|
||||
&storedMetadata,
|
||||
&stored.PayloadHash,
|
||||
)
|
||||
if err != nil {
|
||||
return ledger.TransactionEvent{}, false, fmt.Errorf("read idempotent transaction event: %w", err)
|
||||
}
|
||||
if stored.PayloadHash != event.PayloadHash {
|
||||
return ledger.TransactionEvent{}, false, ErrIdempotencyConflict
|
||||
}
|
||||
if err := json.Unmarshal(storedMetadata, &stored.Metadata); err != nil {
|
||||
return ledger.TransactionEvent{}, false, fmt.Errorf("decode event metadata: %w", err)
|
||||
}
|
||||
return stored, true, nil
|
||||
}
|
||||
|
||||
const insertEventSQL = `
|
||||
INSERT INTO transaction_events (
|
||||
id, source_service, idempotency_key, source_transaction_id, tracking_code,
|
||||
event_version, state, error_code, error_message, occurred_at, correlation_id,
|
||||
actor_id, blockchain_network, blockchain_transaction_hash,
|
||||
blockchain_ledger_sequence, metadata, payload_hash
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING
|
||||
RETURNING recorded_at`
|
||||
|
||||
const selectEventByIdempotencySQL = `
|
||||
SELECT id, source_service, idempotency_key, source_transaction_id,
|
||||
tracking_code, event_version, state, error_code, error_message,
|
||||
occurred_at, recorded_at, correlation_id, actor_id, blockchain_network,
|
||||
blockchain_transaction_hash, blockchain_ledger_sequence, metadata,
|
||||
payload_hash
|
||||
FROM transaction_events
|
||||
WHERE idempotency_key = $1`
|
||||
@@ -0,0 +1,73 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestAppendEventStoresAndReturnsRecordingTime(t *testing.T) {
|
||||
event := repositoryEvent()
|
||||
recordedAt := time.Unix(2, 0).UTC()
|
||||
database := &fakeDatabase{directRows: []Row{fakeRow{values: []any{recordedAt}}}}
|
||||
|
||||
stored, existed, err := NewJournalRepository(database).AppendEvent(context.Background(), event)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if existed || !stored.RecordedAt.Equal(recordedAt) {
|
||||
t.Fatalf("unexpected append result: existed=%v event=%+v", existed, stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendEventRejectsConflictingIdempotencyPayload(t *testing.T) {
|
||||
event := repositoryEvent()
|
||||
storedHash := strings.Repeat("b", 64)
|
||||
database := &fakeDatabase{directRows: []Row{
|
||||
fakeRow{err: pgx.ErrNoRows},
|
||||
fakeRow{values: []any{
|
||||
event.ID,
|
||||
event.SourceService,
|
||||
event.IdempotencyKey,
|
||||
event.SourceTransactionID,
|
||||
event.TrackingCode,
|
||||
event.EventVersion,
|
||||
event.State,
|
||||
event.ErrorCode,
|
||||
event.ErrorMessage,
|
||||
event.OccurredAt,
|
||||
time.Unix(2, 0).UTC(),
|
||||
event.CorrelationID,
|
||||
event.ActorID,
|
||||
event.Blockchain.Network,
|
||||
event.Blockchain.TransactionHash,
|
||||
event.Blockchain.LedgerSequence,
|
||||
[]byte(`{}`),
|
||||
storedHash,
|
||||
}},
|
||||
}}
|
||||
|
||||
_, _, err := NewJournalRepository(database).AppendEvent(context.Background(), event)
|
||||
if !errors.Is(err, ErrIdempotencyConflict) {
|
||||
t.Fatalf("expected idempotency conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func repositoryEvent() ledger.TransactionEvent {
|
||||
return ledger.TransactionEvent{
|
||||
ID: "11111111-1111-4111-8111-111111111111",
|
||||
SourceService: "wallet",
|
||||
IdempotencyKey: "wallet:1:status:v1",
|
||||
SourceTransactionID: "1",
|
||||
EventVersion: 1,
|
||||
State: ledger.TransactionStateCreated,
|
||||
OccurredAt: time.Unix(1, 0).UTC(),
|
||||
PayloadHash: strings.Repeat("a", 64),
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,15 @@ import (
|
||||
"gl/domain/ledger"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload")
|
||||
ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal")
|
||||
ErrIdempotencyConflict = ledger.ErrIdempotencyConflict
|
||||
ErrIncompleteJournal = ledger.ErrIncompleteJournal
|
||||
)
|
||||
|
||||
type AppendResult struct {
|
||||
JournalID string
|
||||
AlreadyExists bool
|
||||
}
|
||||
type AppendResult = ledger.AppendResult
|
||||
|
||||
type JournalRepository struct {
|
||||
database Database
|
||||
@@ -76,6 +74,10 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
|
||||
return r.resolveDuplicate(ctx, journal)
|
||||
}
|
||||
if err != nil {
|
||||
var postgresError *pgconn.PgError
|
||||
if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" {
|
||||
return AppendResult{}, ledger.ErrAlreadyReversed
|
||||
}
|
||||
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ func (r fakeRow) Scan(dest ...any) error {
|
||||
*target = value.(int64)
|
||||
case *bool:
|
||||
*target = value.(bool)
|
||||
case *uint32:
|
||||
*target = value.(uint32)
|
||||
case *time.Time:
|
||||
*target = value.(time.Time)
|
||||
case *ledger.TransactionState:
|
||||
*target = value.(ledger.TransactionState)
|
||||
case *[]byte:
|
||||
*target = value.([]byte)
|
||||
default:
|
||||
return errors.New("unsupported scan target")
|
||||
}
|
||||
@@ -75,6 +83,7 @@ func (t *fakeTx) Rollback(context.Context) error {
|
||||
type fakeDatabase struct {
|
||||
tx *fakeTx
|
||||
directRow Row
|
||||
directRows []Row
|
||||
beginCalled bool
|
||||
}
|
||||
|
||||
@@ -83,9 +92,22 @@ func (d *fakeDatabase) Begin(context.Context) (Tx, error) {
|
||||
return d.tx, nil
|
||||
}
|
||||
|
||||
func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { return d.directRow }
|
||||
func (d *fakeDatabase) Ping(context.Context) error { return nil }
|
||||
func (d *fakeDatabase) Close() {}
|
||||
func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row {
|
||||
if len(d.directRows) > 0 {
|
||||
row := d.directRows[0]
|
||||
d.directRows = d.directRows[1:]
|
||||
return row
|
||||
}
|
||||
return d.directRow
|
||||
}
|
||||
func (d *fakeDatabase) Query(context.Context, string, ...any) (Rows, error) {
|
||||
return nil, errors.New("unexpected query")
|
||||
}
|
||||
func (d *fakeDatabase) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
|
||||
return pgconn.CommandTag{}, errors.New("unexpected exec")
|
||||
}
|
||||
func (d *fakeDatabase) Ping(context.Context) error { return nil }
|
||||
func (d *fakeDatabase) Close() {}
|
||||
|
||||
func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) {
|
||||
journal := repositoryJournal(t)
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) {
|
||||
"journal is not balanced per asset",
|
||||
"cannot append to a sealed journal",
|
||||
"reject_ledger_mutation",
|
||||
"journals_one_reversal_idx",
|
||||
} {
|
||||
if !strings.Contains(schema, required) {
|
||||
t.Fatalf("migration is missing %q", required)
|
||||
|
||||
@@ -53,6 +53,10 @@ CREATE INDEX journals_source_transaction_idx
|
||||
|
||||
CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id);
|
||||
|
||||
CREATE UNIQUE INDEX journals_one_reversal_idx
|
||||
ON journals (reversal_of_journal_id)
|
||||
WHERE reversal_of_journal_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE journal_entries (
|
||||
journal_id uuid NOT NULL REFERENCES journals (id),
|
||||
line_number integer NOT NULL CHECK (line_number > 0),
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrNotFound = ledger.ErrNotFound
|
||||
|
||||
type JournalFilter = ledger.JournalFilter
|
||||
|
||||
func (r *JournalRepository) GetByID(ctx context.Context, journalID string) (ledger.Journal, error) {
|
||||
return r.get(ctx, getJournalByIDSQL, journalID)
|
||||
}
|
||||
|
||||
func (r *JournalRepository) GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (ledger.Journal, error) {
|
||||
return r.get(ctx, getJournalByIdempotencySQL, idempotencyKey)
|
||||
}
|
||||
|
||||
func (r *JournalRepository) get(ctx context.Context, query string, value string) (ledger.Journal, error) {
|
||||
journal, err := scanJournal(r.database.QueryRow(ctx, query, value))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ledger.Journal{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ledger.Journal{}, fmt.Errorf("read journal: %w", err)
|
||||
}
|
||||
journal.Entries, err = r.loadEntries(ctx, journal.ID)
|
||||
if err != nil {
|
||||
return ledger.Journal{}, err
|
||||
}
|
||||
return journal, nil
|
||||
}
|
||||
|
||||
func (r *JournalRepository) List(ctx context.Context, filter JournalFilter) ([]ledger.Journal, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 201 {
|
||||
limit = 50
|
||||
}
|
||||
if filter.Offset < 0 {
|
||||
filter.Offset = 0
|
||||
}
|
||||
|
||||
var accountClass, ownerType, ownerID any
|
||||
if filter.Account != nil {
|
||||
accountClass = filter.Account.Class
|
||||
ownerType = filter.Account.OwnerType
|
||||
ownerID = filter.Account.OwnerID
|
||||
}
|
||||
rows, err := r.database.Query(ctx, listJournalsSQL,
|
||||
filter.AssetID,
|
||||
accountClass,
|
||||
ownerType,
|
||||
ownerID,
|
||||
filter.RecordedFrom,
|
||||
filter.RecordedTo,
|
||||
limit,
|
||||
filter.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list journals: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
journals := make([]ledger.Journal, 0, limit)
|
||||
for rows.Next() {
|
||||
journal, scanErr := scanJournal(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan journal: %w", scanErr)
|
||||
}
|
||||
journals = append(journals, journal)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate journals: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for index := range journals {
|
||||
journals[index].Entries, err = r.loadEntries(ctx, journals[index].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return journals, nil
|
||||
}
|
||||
|
||||
func (r *JournalRepository) Balance(ctx context.Context, account ledger.AccountReference, asOf time.Time) (ledger.Amount, error) {
|
||||
if err := account.Validate(); err != nil {
|
||||
return ledger.Amount{}, err
|
||||
}
|
||||
var asOfValue any
|
||||
if !asOf.IsZero() {
|
||||
asOfValue = asOf
|
||||
}
|
||||
var value string
|
||||
if err := r.database.QueryRow(ctx, getBalanceSQL,
|
||||
account.Class,
|
||||
account.OwnerType,
|
||||
account.OwnerID,
|
||||
account.AssetID,
|
||||
asOfValue,
|
||||
).Scan(&value); err != nil {
|
||||
return ledger.Amount{}, fmt.Errorf("read balance: %w", err)
|
||||
}
|
||||
amount, err := ledger.ParseAmount(value)
|
||||
if err != nil {
|
||||
return ledger.Amount{}, fmt.Errorf("decode balance: %w", err)
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanJournal(row scanner) (ledger.Journal, error) {
|
||||
var (
|
||||
journal ledger.Journal
|
||||
reversal *string
|
||||
metadata []byte
|
||||
)
|
||||
err := row.Scan(
|
||||
&journal.ID,
|
||||
&journal.SourceService,
|
||||
&journal.IdempotencyKey,
|
||||
&journal.SourceTransactionID,
|
||||
&journal.TrackingCode,
|
||||
&journal.EffectKind,
|
||||
&journal.EventVersion,
|
||||
&reversal,
|
||||
&journal.OccurredAt,
|
||||
&journal.RecordedAt,
|
||||
&journal.CorrelationID,
|
||||
&journal.ActorID,
|
||||
&journal.Blockchain.Network,
|
||||
&journal.Blockchain.TransactionHash,
|
||||
&journal.Blockchain.LedgerSequence,
|
||||
&metadata,
|
||||
&journal.PayloadHash,
|
||||
)
|
||||
if err != nil {
|
||||
return ledger.Journal{}, err
|
||||
}
|
||||
if reversal != nil {
|
||||
journal.ReversalOfJournalID = *reversal
|
||||
}
|
||||
if err := json.Unmarshal(metadata, &journal.Metadata); err != nil {
|
||||
return ledger.Journal{}, fmt.Errorf("decode metadata: %w", err)
|
||||
}
|
||||
return journal, nil
|
||||
}
|
||||
|
||||
func (r *JournalRepository) loadEntries(ctx context.Context, journalID string) ([]ledger.Entry, error) {
|
||||
rows, err := r.database.Query(ctx, getEntriesSQL, journalID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read journal entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]ledger.Entry, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry ledger.Entry
|
||||
amountValue string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&entry.LineNumber,
|
||||
&entry.Account.Class,
|
||||
&entry.Account.OwnerType,
|
||||
&entry.Account.OwnerID,
|
||||
&entry.Account.AssetID,
|
||||
&amountValue,
|
||||
&entry.Description,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan journal entry: %w", err)
|
||||
}
|
||||
entry.Amount, err = ledger.ParseAmount(amountValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode journal entry amount: %w", err)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate journal entries: %w", err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
const journalColumns = `
|
||||
j.id, j.source_service, j.idempotency_key, j.source_transaction_id,
|
||||
j.tracking_code, j.effect_kind, j.event_version, j.reversal_of_journal_id,
|
||||
j.occurred_at, j.recorded_at, j.correlation_id, j.actor_id,
|
||||
j.blockchain_network, j.blockchain_transaction_hash,
|
||||
j.blockchain_ledger_sequence, j.metadata, j.payload_hash`
|
||||
|
||||
const getJournalByIDSQL = `SELECT ` + journalColumns + `
|
||||
FROM journals j WHERE j.id = $1 AND j.sealed_at IS NOT NULL`
|
||||
|
||||
const getJournalByIdempotencySQL = `SELECT ` + journalColumns + `
|
||||
FROM journals j WHERE j.idempotency_key = $1 AND j.sealed_at IS NOT NULL`
|
||||
|
||||
const listJournalsSQL = `SELECT DISTINCT ` + journalColumns + `
|
||||
FROM journals j
|
||||
JOIN journal_entries e ON e.journal_id = j.id
|
||||
JOIN ledger_accounts a ON a.id = e.account_id
|
||||
WHERE j.sealed_at IS NOT NULL
|
||||
AND ($1::bigint IS NULL OR e.asset_id = $1)
|
||||
AND ($2::text IS NULL OR (
|
||||
a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4
|
||||
))
|
||||
AND ($5::timestamptz IS NULL OR j.recorded_at >= $5)
|
||||
AND ($6::timestamptz IS NULL OR j.recorded_at <= $6)
|
||||
ORDER BY j.recorded_at DESC, j.id DESC
|
||||
LIMIT $7 OFFSET $8`
|
||||
|
||||
const getEntriesSQL = `
|
||||
SELECT e.line_number, a.class, a.owner_type, a.owner_id, e.asset_id,
|
||||
e.amount::text, e.description
|
||||
FROM journal_entries e
|
||||
JOIN ledger_accounts a ON a.id = e.account_id
|
||||
WHERE e.journal_id = $1
|
||||
ORDER BY e.line_number`
|
||||
|
||||
const getBalanceSQL = `
|
||||
SELECT COALESCE(sum(e.amount), 0)::text
|
||||
FROM journal_entries e
|
||||
JOIN ledger_accounts a ON a.id = e.account_id
|
||||
JOIN journals j ON j.id = e.journal_id
|
||||
WHERE j.sealed_at IS NOT NULL
|
||||
AND a.class = $1 AND a.owner_type = $2 AND a.owner_id = $3
|
||||
AND a.asset_id = $4
|
||||
AND ($5::timestamptz IS NULL OR j.recorded_at <= $5)`
|
||||
Reference in New Issue
Block a user