Building a Transaction System with MongoDB

Transaction systems are at the heart of modern commerce and financial activities, spanning a wide range of scenarios from e-commerce order processing to real-time settlements in financial institutions. Such systems demand high concurrency handling, real-time data storage, and efficient retrieval capabilities, all while balancing data consistency and performance. As transaction data continues to grow in scale and complexity, traditional transaction system architectures often struggle to meet these challenges due to rigid table structures and limited horizontal scalability.

MongoDB, as a distributed document-oriented database, provides a modern solution for building complex and efficient transaction systems. With its flexible schema design, high throughput capabilities, and built-in transaction support, MongoDB addresses diverse business needs effectively.

Problem Statement and Challenges

The core functionalities of a transaction system include account management, balance inquiry, recharge, purchase, and managing and querying transaction records. For instance, the following illustrates how a user's account balance is displayed:

Displays the user's transaction history:

Building an efficient and reliable modern transaction system involves tackling several key challenges:

  1. High Concurrency Transaction systems must handle large-scale concurrent requests, particularly in domains like finance and e-commerce, where users frequently perform transactions, balance inquiries, and other operations simultaneously. Failure to support high concurrency can result in delayed responses or even system crashes.

  2. Data Consistency and Transaction Management Ensuring data consistency is critical, especially for sensitive operations like account balance updates, transaction records, and purchase. Any inconsistency could lead to financial risks and a loss of user trust. Transaction management in distributed environments adds another layer of complexity.

  3. Flexible Data Modeling Traditional relational databases often rely on rigid table structures that struggle to adapt to the complex and evolving business logic of transaction systems. Adding new features often requires significant changes to the data model, leading to high modification costs and potential service disruptions.

  4. Real-Time Data Processing and Query Performance With massive transaction volumes, the system must efficiently store, retrieve, and process real-time user queries, such as balance checks and transaction history retrievals. This demands a database with high throughput and lightning-fast query performance.

  5. System Scalability As the business grows, the volume of transaction data will continue to expand. The system must support seamless horizontal scaling, ensuring that performance improves linearly as additional servers or storage nodes are added.

Data Modeling

To achieve large-scale data storage and high-concurrency read/write capabilities, data modeling is the key challenge. MongoDB's document model is renowned for its flexibility and efficiency, allowing it to easily accommodate the complex and evolving needs of transaction systems.

The following six APIs define the essential functionalities of the transaction system:

  1. api/CreateAccount Creates a user account, initializing basic user information and the initial balance.

  2. api/GetBalance Retrieves the account balance, ensuring real-time accuracy and consistency.

  3. api/GetTransactionById Fetches transaction details based on a transaction ID, supporting quick and precise lookups.

  4. api/GetTransactions Retrieves historical transaction records for a specific account, with support for pagination and time-range filtering to handle large-scale data queries.

  5. api/Recharge Processes account recharges, involving transaction handling to ensure synchronized updates to the account balance and transaction records.

  6. api/Purchase Handles purchase operations, ensuring transaction integrity and detailed operation records.

To address the above requirements, two collections are designed to store user account states and detailed transaction information. The two collections are linked through Uuid, enabling efficient queries and updates.

  1. AccountEntry represents the basic account information for each user in the system, with the following key fields:
  • Uuid (string) A unique identifier for each account, used to uniquely locate an account within the system. Globally unique.

  • Balance (decimal) The current balance of the account, stored as a precise decimal value to record the total amount of funds available to the user.

The AccountEntry definition:

public class AccountEntry
{
    public string Uuid { get; set; }
    public decimal Balance { get; set; }
    public DateTime CreateTime { get; set; }
    public DateTime UpdateTime { get; set; }
}
  1. TransEntry records each transaction in the system and supports various types of operations. The field descriptions are as follows:
  • Tid (string) A unique identifier for each transaction, used to uniquely locate a specific transaction.

  • Uuid (string) The unique identifier of the corresponding account, serving as a foreign key linked to AccountEntry, indicating which account this transaction belongs to.

  • Amount (decimal) The transaction amount, recording the funds involved in this transaction.

  • NewBalance (decimal) A snapshot of the account balance after the transaction is completed, enabling the tracking of fund movements and supporting auditing functionality.

  • Type (TransType) An enumeration of transaction types, indicating the nature of the transaction.

The TransEntry definition:

public class TransEntry
{
    public string Tid { get; set; }
    public string Uuid { get; set; }
    public decimal Amount { get; set; }
    public decimal NewBalance { get; set; }
    public TransType Type { get; set; }
    public DateTime CreateTime { get; set; }
    public DateTime UpdateTime { get; set; }
}

public enum TransType
{
    None = 0,
    Recharge = 1,
    Purchase = 2,
}

System Implementation

Here we implement each API and determine the required indexes and shard keys based on actual read and write patterns. The APIs are introduced step-by-step, starting with simpler ones and progressing to more complex implementations.

CreateAccount API

The CreateAccount API is straightforward. It does not use transaction processing because the user's Uuid serves as the primary key. If an account with the same Uuid already exists, the insert operation will fail due to the unique constraint.

public async Task<AccountEntry> CreateAccountAsync(string uuid, CancellationToken cancellationToken = default)
{
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
    {
        var newAccount = new AccountEntry
        {
            Uuid = uuid,
            Balance = 0,
            CreateTime = DateTime.UtcNow,
            UpdateTime = DateTime.UtcNow
        };

        await _accountCollection.InsertOneAsync(newAccount, cancellationToken: cts.Token);

        return newAccount;
    }
}

GetBalance API

The implementation of retrieving the balance is straightforward. Note that the IClientSessionHandle parameter is optional. When this parameter is provided, it serves as a basic building block of complex transactions:

public async Task<decimal> GetBalanceAsync(string uuid, CancellationToken ct, IClientSessionHandle s = null)
{
    AccountEntry account;
    if (s == null)
    {
        account = await _accountCollection.Find(x => x.Uuid == uuid).FirstOrDefaultAsync(ct);
    }
    else
    {
        account = await _accountCollection.Find(s, x => x.Uuid == uuid).FirstOrDefaultAsync(ct);
    }
    if (account == null)
    {
        throw new KeyNotFoundException($"Account '{uuid}' does not exist.");
    }

    return account.Balance;
}

This API requires Account index:(Uuid).

AddBalance & DecreaseBalance API

The operations on the balance are also the foundation for constructing subsequent transaction APIs. The implementation of these two APIs uses MongoDB's Inc operator for atomic operations, and they also support transactions. They will not be used independently, as both increasing or decreasing the balance require corresponding transaction records. Therefore, the IClientSessionHandle parameter is mandatory:

private async Task<AccountEntry> AddBalanceAsync(string accountId, decimal amount, CancellationToken ct, IClientSessionHandle s)
{
    if (amount <= 0)
    {
        throw new ArgumentException("Amount should > 0");
    }
    var filter = Builders<AccountEntry>.Filter.Eq(x => x.Uuid, accountId);
    var update = Builders<AccountEntry>.Update
        .Inc(x => x.Balance, amount)
        .Set(x => x.UpdateTime, DateTime.UtcNow);
    var options = new FindOneAndUpdateOptions<AccountEntry> { ReturnDocument = ReturnDocument.After, IsUpsert = true };
    return await _accountCollection.FindOneAndUpdateAsync(s, filter, update, options, cancellationToken: ct);
}

private async Task<AccountEntry> DecreaseBalanceAsync(string accountId, decimal amount, CancellationToken ct, IClientSessionHandle s)
{
    if (amount <= 0)
    {
        throw new ArgumentException("Amount should > 0");
    }
    var filter = Builders<AccountEntry>.Filter.And(
        Builders<AccountEntry>.Filter.Eq(x => x.Uuid, accountId),
        Builders<AccountEntry>.Filter.Gte(x => x.Balance, amount)
    );
    var update = Builders<AccountEntry>.Update
        .Inc(x => x.Balance, -amount)
        .Set(x => x.UpdateTime, DateTime.UtcNow);
    var options = new FindOneAndUpdateOptions<AccountEntry> { ReturnDocument = ReturnDocument.After };
    return await _accountCollection.FindOneAndUpdateAsync(s, filter, update, options, cancellationToken: ct);
}

It is worth mentioning that in our design, the amount in both of these APIs is always a positive number. The advantage of this is that when decreasing the balance, we can include Filter.Gte(x => x.Balance, amount), which further ensures the correctness of the balance deduction.

These two APIs require an index on the Account table: (Uuid).

GetTransactionByTid & GetTransactions API

Both APIs are simple:

public async Task<TransEntry> GetTransactionByTidAsync(string tid, CancellationToken ct, IClientSessionHandle s = null)
{
    if (s == null)
    {
        return await _transCollection.Find(x => x.Tid == tid).FirstOrDefaultAsync(ct);
    }
    return await _transCollection.Find(s, x => x.Tid == tid).FirstOrDefaultAsync(ct);
}

public async Task<List<TransEntry>> GetTransactionsAsync(string accountId, CancellationToken ct, int n = 10)
{
    var result = await _transCollection.Find(x => x.Uuid == accountId).SortBy(x => x.CreateTime).Limit(n).ToListAsync(ct);
    return result;
}

These two APIs require an index on the Trans table: (Uuid, CreateTime), (Tid).

Recharge API

Here come the two most complex transaction APIs: Recharge and Purchase. Note that these two APIs not only ensure consistency, but also need to guarantee idempotency, meaning they must support repeated calls without affecting the final result.

For Recharge, three operations are involved, which require a transaction:

  • GetTransactionByTid: The caller needs to pass in the Tid to prevent duplicate transactions. Idempotency is ensured by this step. In practical applications, the Tid can be the unique identifier returned by a third-party financial system.
  • AddBalance: This API was implemented earlier.
  • InsertTransaction: Adds a recharge transaction record.

If any of the above three operations fail, MongoDB's transaction can be used to roll back the changes, ensuring strong consistency of the transaction:

public async Task<TransResult> RechargeAsync(string accountId, string tid, decimal amount)
{
    using (var session = await _mongoClient.StartSessionAsync())
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
    {
        return await session.WithTransactionAsync(
            async (s, ct) =>
            {
                var existingTransEntry = await GetTransactionByTidAsync(tid, ct, s);
                if (existingTransEntry != null)
                {
                    return new TransResult { Code = TransCode.DuplicateTrans };
                }

                var newBalanceEntry = await AddBalanceAsync(accountId, amount, ct, s);

                var newTransEntry = new TransEntry
                {
                    Tid = GenerateTid(),
                    Uuid = accountId,
                    Amount = amount,
                    NewBalance = newBalanceEntry.Balance,
                    Type = TransType.Recharge,
                    CreateTime = DateTime.UtcNow,
                    UpdateTime = DateTime.UtcNow,
                };

                await _transCollection.InsertOneAsync(s, newTransEntry, cancellationToken: ct);

                return new TransResult
                {
                    NewBalance = newTransEntry.NewBalance,
                    Tid = newTransEntry.Tid
                };
            }, cancellationToken: cts.Token);
    }
}

Purchase API

The implementation of the Purchase API is essentially the same as Recharge, with AddBalance replaced by DecreaseBalance.

public async Task<TransResult> PurchaseAsync(string accountId, string tid, decimal amount)
{
    using (var session = await _mongoClient.StartSessionAsync())
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
    {
        return await session.WithTransactionAsync(
            async (s, ct) =>
            {
                var existingTransEntry = await GetTransactionByTidAsync(tid, ct, s);
                if (existingTransEntry != null)
                {
                    return new TransResult { Code = TransCode.DuplicateTrans };
                }

                var newBalanceEntry = await DecreaseBalanceAsync(accountId, amount, ct, s);
                if (newBalanceEntry == null)
                {
                    return new TransResult { Code = TransCode.InsufficientBalance };
                }

                var newTransEntry = new TransEntry
                {
                    Tid = GenerateTid(),
                    Uuid = accountId,
                    Amount = amount,
                    NewBalance = newBalanceEntry.Balance,
                    Type = TransType.Purchase,
                    CreateTime = DateTime.UtcNow,
                    UpdateTime = DateTime.UtcNow,
                };

                await _transCollection.InsertOneAsync(s, newTransEntry, cancellationToken: ct);

                return new TransResult
                {
                    NewBalance = newTransEntry.NewBalance,
                    Tid = newTransEntry.Tid
                };
            },
            cancellationToken: cts.Token);
    }
}

In practice, the Purchase API can be modified to incorperate business logic, ensuring consistency between the business logic and the transaction system.

Indexes and Shard Keys

Based on the above implementations and considering the leftmost prefix matching principle, we need one indexe on Account:

  • (Uuid)

Two indexes on Trans

  • (Uuid, CreateTime)
  • (Tid)

Both Account and Trans could use Uuid as the sharding key.

Test Code

Create 10 accounts and perform an initial recharge of 1000 for each account. Generate 5 transactions for each account, randomly deciding the transaction type (recharge or purchase), with transaction amounts between 0 and 300. Print the current balance of each account, as well as the details of the last 10 transactions.

public async Task RunAsync()
{
    var random = new Random(42);

    // Create 10 accounts
    var accountIds = new List<string>();
    for (int i = 0; i < 10; i++)
    {
        var accountId = GenerateAccountUuid();
        var account = await CreateAccountAsync(accountId);
        accountIds.Add(account.Uuid);

        await RechargeAsync(account.Uuid, GenerateTid(), 1000);
        Console.WriteLine($"Created account {account.Uuid} with initial balance of 1000.");
    }

    Console.WriteLine("------------------------------------------------------------------------------");

    foreach (var accountId in accountIds)
    {
        for (int i = 0; i < 5; i++)
        {
            var isRecharge = random.Next(0, 2) == 0;
            var amount = (decimal)(random.NextDouble() * 300);
            var tid = GenerateTid();

            if (isRecharge)
            {
                var result = await RechargeAsync(accountId, tid, amount);
                if (result.Code == TransCode.Success)
                {
                    Console.WriteLine($"Recharge: Account {accountId}, Amount: {Math.Round(amount, 2)}, New Balance: {Math.Round(result.NewBalance, 2)}, Tid: {tid}");
                }
                else
                {
                    Console.WriteLine($"Recharge failed: {result.Code}");
                }
            }
            else
            {
                var result = await PurchaseAsync(accountId, tid, amount);
                if (result.Code == TransCode.Success)
                {
                    Console.WriteLine($"Purchase: Account {accountId}, Amount: {Math.Round(amount, 2)}, New Balance: {Math.Round(result.NewBalance, 2)}, Tid: {tid}");
                }
                else
                {
                    Console.WriteLine($"Purchase failed: {result.Code}");
                }
            }
        }
    }

    Console.WriteLine("------------------------------------------------------------------------------");
    foreach (var accountId in accountIds)
    {
        var balance = await GetBalanceAsync(accountId, CancellationToken.None);
        var transactions = await GetTransactionsAsync(accountId, CancellationToken.None);

        Console.WriteLine($"Account {accountId} Balance: {Math.Round(balance, 2)}");
        Console.WriteLine("Recent Transactions:");
        foreach (var transaction in transactions)
        {
            Console.WriteLine($"Tid: {transaction.Tid}, Type: {transaction.Type}, Amount: {Math.Round(transaction.Amount, 2)}, New Balance: {Math.Round(transaction.NewBalance, 2)}, Time: {transaction.CreateTime}");
        }
        Console.WriteLine("****************************************************************************");
    }
}

Some results:

Account Acc_BE2E2B2FD3A2490CBEEA9625C3BD7C18 Balance: 1267.83 Recent Transactions: Tid: T_6DA267BECAC945E2973903C7EE36C599, Type: Recharge, Amount: 1000, New Balance: 1000, Time: 11/24/2024 4:00:42 AM Tid: T_6BB3C3B2E9CD4197B609957C2937D218, Type: Purchase, Amount: 42.27, New Balance: 957.73, Time: 11/24/2024 4:00:43 AM Tid: T_0D63FB52BCBF4FF382D7C611252329F4, Type: Recharge, Amount: 156.83, New Balance: 1114.56, Time: 11/24/2024 4:00:43 AM Tid: T_30201F58B3AA4BD6B14C4B9DC0A20272, Type: Recharge, Amount: 78.78, New Balance: 1193.33, Time: 11/24/2024 4:00:43 AM Tid: T_42837124561748BFBECA1677FD951338, Type: Purchase, Amount: 153.88, New Balance: 1039.46, Time: 11/24/2024 4:00:43 AM Tid: T_16DC47F772814D4A8A3E1DBCF26B661D, Type: Recharge, Amount: 228.38, New Balance: 1267.83, Time: 11/24/2024 4:00:43 AM

Connect to the database deployed on MongoDB Atlas using MongoDB Compass:

In this post, we provide complete account management and transaction support functions based on MongoDB, making it suitable for small to medium-sized transaction systems. It is also easily extendable to more complex application scenarios, such as multi-currency support and international payments. Each functional module works together through transactions, filters, and idempotency handling to build a secure, efficient, and scalable transaction system framework.

This design meets the practical business needs of high concurrency, data consistency, and complex transactions, while also providing clear error handling pathways for easier maintenance and expansion.

Full Demo

Fill the ConnectionString:

using MongoDB.Bson.Serialization.Conventions;
using MongoDB.Driver;

namespace MongoTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var model = new TransModel();

            model.RunAsync().Wait();

            Console.WriteLine("Done");
        }


    }

    public class AccountEntry
    {
        public string Uuid { get; set; }
        public decimal Balance { get; set; }
        public DateTime CreateTime { get; set; }
        public DateTime UpdateTime { get; set; }
    }

    public class TransEntry
    {
        public string Tid { get; set; }
        public string Uuid { get; set; }
        public decimal Amount { get; set; }
        public decimal NewBalance { get; set; }
        public TransType Type { get; set; }
        public DateTime CreateTime { get; set; }
        public DateTime UpdateTime { get; set; }
    }

    public enum TransType
    {
        None = 0,
        Recharge = 1,
        Purchase = 2,
    }

    public class TransResult
    {
        public string Tid { get; set; }
        public decimal NewBalance { get; set; }
        public TransCode Code { get; set; }
    }

    public enum TransCode
    {
        Success = 0,
        DuplicateTrans = 1,
        Purchase = 2,
        InsufficientBalance = 3
    }

    public class TransModel
    {
        private readonly IMongoClient _mongoClient;
        private readonly IMongoCollection<AccountEntry> _accountCollection;
        private readonly IMongoCollection<TransEntry> _transCollection;

        private const string DatabaseName = "Transaction";
        private const string AccountCollectionName = "Account";
        private const string TransCollectionName = "Trans";
        public const string ConnectionString = "";

        public TransModel()
        {
            _mongoClient = new MongoClient(ConnectionString);
            var database = _mongoClient.GetDatabase(DatabaseName);

            var ignoreExtraElementsConvention = new ConventionPack { new IgnoreExtraElementsConvention(true) };
            ConventionRegistry.Register("IgnoreExtraElements", ignoreExtraElementsConvention, type => true);
            _accountCollection = database.GetCollection<AccountEntry>(AccountCollectionName);
            _transCollection = database.GetCollection<TransEntry>(TransCollectionName);
        }

        public static string GenerateTid() => $"T_{Guid.NewGuid().ToString("N").ToUpper()}";
        public static string GenerateAccountUuid() => $"Acc_{Guid.NewGuid().ToString("N").ToUpper()}";

        public async Task<AccountEntry> CreateAccountAsync(string uuid, CancellationToken cancellationToken = default)
        {
            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
            {
                var newAccount = new AccountEntry
                {
                    Uuid = uuid,
                    Balance = 0,
                    CreateTime = DateTime.UtcNow,
                    UpdateTime = DateTime.UtcNow
                };

                await _accountCollection.InsertOneAsync(newAccount, cancellationToken: cts.Token);

                return newAccount;
            }
        }

        public async Task<decimal> GetBalanceAsync(string uuid, CancellationToken ct, IClientSessionHandle s = null)
        {
            AccountEntry account;
            if (s == null)
            {
                account = await _accountCollection.Find(x => x.Uuid == uuid).FirstOrDefaultAsync(ct);
            }
            else
            {
                account = await _accountCollection.Find(s, x => x.Uuid == uuid).FirstOrDefaultAsync(ct);
            }
            if (account == null)
            {
                throw new KeyNotFoundException($"Account '{uuid}' does not exist.");
            }

            return account.Balance;
        }

        private async Task<AccountEntry> AddBalanceAsync(string accountId, decimal amount, CancellationToken ct, IClientSessionHandle s)
        {
            if (amount <= 0)
            {
                throw new ArgumentException("Amount should > 0");
            }
            var filter = Builders<AccountEntry>.Filter.Eq(x => x.Uuid, accountId);
            var update = Builders<AccountEntry>.Update
                .Inc(x => x.Balance, amount)
                .Set(x => x.UpdateTime, DateTime.UtcNow);
            var options = new FindOneAndUpdateOptions<AccountEntry> { ReturnDocument = ReturnDocument.After, IsUpsert = true };
            return await _accountCollection.FindOneAndUpdateAsync(s, filter, update, options, cancellationToken: ct);
        }

        private async Task<AccountEntry> DecreaseBalanceAsync(string accountId, decimal amount, CancellationToken ct, IClientSessionHandle s)
        {
            if (amount <= 0)
            {
                throw new ArgumentException("Amount should > 0");
            }
            var filter = Builders<AccountEntry>.Filter.And(
                Builders<AccountEntry>.Filter.Eq(x => x.Uuid, accountId),
                Builders<AccountEntry>.Filter.Gte(x => x.Balance, amount)
            );
            var update = Builders<AccountEntry>.Update
                .Inc(x => x.Balance, -amount)
                .Set(x => x.UpdateTime, DateTime.UtcNow);
            var options = new FindOneAndUpdateOptions<AccountEntry> { ReturnDocument = ReturnDocument.After };
            return await _accountCollection.FindOneAndUpdateAsync(s, filter, update, options, cancellationToken: ct);
        }

        public async Task<TransEntry> GetTransactionByTidAsync(string tid, CancellationToken ct, IClientSessionHandle s = null)
        {
            if (s == null)
            {
                return await _transCollection.Find(x => x.Tid == tid).FirstOrDefaultAsync(ct);
            }
            return await _transCollection.Find(s, x => x.Tid == tid).FirstOrDefaultAsync(ct);
        }

        public async Task<List<TransEntry>> GetTransactionsAsync(string accountId, CancellationToken ct, int n = 10)
        {
            var result = await _transCollection.Find(x => x.Uuid == accountId).SortBy(x => x.CreateTime).Limit(n).ToListAsync(ct);
            return result;
        }

        public async Task<TransResult> RechargeAsync(string accountId, string tid, decimal amount)
        {
            using (var session = await _mongoClient.StartSessionAsync())
            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
            {
                return await session.WithTransactionAsync(
                    async (s, ct) =>
                    {
                        var existingTransEntry = await GetTransactionByTidAsync(tid, ct, s);
                        if (existingTransEntry != null)
                        {
                            return new TransResult { Code = TransCode.DuplicateTrans };
                        }

                        var newBalanceEntry = await AddBalanceAsync(accountId, amount, ct, s);

                        var newTransEntry = new TransEntry
                        {
                            Tid = GenerateTid(),
                            Uuid = accountId,
                            Amount = amount,
                            NewBalance = newBalanceEntry.Balance,
                            Type = TransType.Recharge,
                            CreateTime = DateTime.UtcNow,
                            UpdateTime = DateTime.UtcNow,
                        };

                        await _transCollection.InsertOneAsync(s, newTransEntry, cancellationToken: ct);

                        return new TransResult
                        {
                            NewBalance = newTransEntry.NewBalance,
                            Tid = newTransEntry.Tid
                        };
                    }, cancellationToken: cts.Token);
            }
        }

        public async Task<TransResult> PurchaseAsync(string accountId, string tid, decimal amount)
        {
            using (var session = await _mongoClient.StartSessionAsync())
            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
            {
                return await session.WithTransactionAsync(
                    async (s, ct) =>
                    {
                        var existingTransEntry = await GetTransactionByTidAsync(tid, ct, s);
                        if (existingTransEntry != null)
                        {
                            return new TransResult { Code = TransCode.DuplicateTrans };
                        }

                        var newBalanceEntry = await DecreaseBalanceAsync(accountId, amount, ct, s);
                        if (newBalanceEntry == null)
                        {
                            return new TransResult { Code = TransCode.InsufficientBalance };
                        }

                        var newTransEntry = new TransEntry
                        {
                            Tid = GenerateTid(),
                            Uuid = accountId,
                            Amount = amount,
                            NewBalance = newBalanceEntry.Balance,
                            Type = TransType.Purchase,
                            CreateTime = DateTime.UtcNow,
                            UpdateTime = DateTime.UtcNow,
                        };

                        await _transCollection.InsertOneAsync(s, newTransEntry, cancellationToken: ct);

                        return new TransResult
                        {
                            NewBalance = newTransEntry.NewBalance,
                            Tid = newTransEntry.Tid
                        };
                    },
                    cancellationToken: cts.Token);
            }
        }

        public async Task RunAsync()
        {
            var random = new Random(42);

            // Create 10 accounts
            var accountIds = new List<string>();
            for (int i = 0; i < 10; i++)
            {
                var accountId = GenerateAccountUuid();
                var account = await CreateAccountAsync(accountId);
                accountIds.Add(account.Uuid);

                await RechargeAsync(account.Uuid, GenerateTid(), 1000);
                Console.WriteLine($"Created account {account.Uuid} with initial balance of 1000.");
            }

            Console.WriteLine("------------------------------------------------------------------------------");

            foreach (var accountId in accountIds)
            {
                for (int i = 0; i < 5; i++)
                {
                    var isRecharge = random.Next(0, 2) == 0;
                    var amount = (decimal)(random.NextDouble() * 300);
                    var tid = GenerateTid();

                    if (isRecharge)
                    {
                        var result = await RechargeAsync(accountId, tid, amount);
                        if (result.Code == TransCode.Success)
                        {
                            Console.WriteLine($"Recharge: Account {accountId}, Amount: {Math.Round(amount, 2)}, New Balance: {Math.Round(result.NewBalance, 2)}, Tid: {tid}");
                        }
                        else
                        {
                            Console.WriteLine($"Recharge failed: {result.Code}");
                        }
                    }
                    else
                    {
                        var result = await PurchaseAsync(accountId, tid, amount);
                        if (result.Code == TransCode.Success)
                        {
                            Console.WriteLine($"Purchase: Account {accountId}, Amount: {Math.Round(amount, 2)}, New Balance: {Math.Round(result.NewBalance, 2)}, Tid: {tid}");
                        }
                        else
                        {
                            Console.WriteLine($"Purchase failed: {result.Code}");
                        }
                    }
                }
            }

            Console.WriteLine("------------------------------------------------------------------------------");
            foreach (var accountId in accountIds)
            {
                var balance = await GetBalanceAsync(accountId, CancellationToken.None);
                var transactions = await GetTransactionsAsync(accountId, CancellationToken.None);

                Console.WriteLine($"Account {accountId} Balance: {Math.Round(balance, 2)}");
                Console.WriteLine("Recent Transactions:");
                foreach (var transaction in transactions)
                {
                    Console.WriteLine($"Tid: {transaction.Tid}, Type: {transaction.Type}, Amount: {Math.Round(transaction.Amount, 2)}, New Balance: {Math.Round(transaction.NewBalance, 2)}, Time: {transaction.CreateTime}");
                }
                Console.WriteLine("****************************************************************************");
            }
        }
    }
}