Low Level Design for a Payment Tracking App like Splitwise

Photo by Alex Haney on Unsplash

Low Level Design for a Payment Tracking App like Splitwise

Functional Requirements

  • Add an expense

  • Edit this expense

  • Settle the expense

  • Creation of groups, Adding friends

  • Registering certain expenses as group expenses

  • The split can be equal, unequal or percentage-wise

  • Comment on expenses by everyone

  • Activity Log ie created expense, updated expense

  • Simplify expenses to minimise the total number of transactions

  • Handing concurrency during updates

  • Cache relevant APIs

Non Functional Requirements

Debt Simplification

  • Simplify debts (a.k.a. “debt simplification”) is a feature of Splitwise that restructures debt within groups of people. It does not change the total amount that anyone owes, but it makes it easier to pay people back by minimizing the total number of payments.

  • Suppose six people go to a restaurant and two end up paying, assuming everybody owes the same amount, in the end, the transactions can be summarised as

  • or if B has ordered more expensive stuff, the owed amounts are different

  • Now the app needs to tell all the users where to get their money from such that total transactions are minimised

  • We will be more interested in caching things which are being accessed by many people ie data relevant to groups ex getGroupExpenses,getGroupPaymentGraph

Concurrency

  • In our system, if somebody is updating expenses as we simplify them, we will face concurrency issues

  • One approach would be to take a write lock in our system, now even if a user wants to read they would not be able to read until the write operation finishes- Strong Consistency

  • Another approach can be,to use immutable objects, instead of changing them one by one, we change them to a create operation and write changes to this new object, meanwhile, if any read is to happen it can still happen via the old reference- Eventual Consistency

API Design

  • getUser()

  • getGroups()

  • getExpense()

  • getGroupExpenses(groupId, userId): userId for authentication

  • getGroupPaymentGraph(groupId, userId) : userId for authentication

Object/Classes and Relationships Identification

  • An Amount object will encapsulate the value and currency of the money

    • The user will have an ID, image URL and bio

  • The Expense object has an ID, a group ID if it belongs to any group, a map<User, Balance>, a title, a boolean variable signalling it settled or not, a timestamp, an image and comments (metadata), we can encapsulate this map<User, Balance> as another model class BalanceMap

  • A Group will have an ID, a vector of Users, and a vector of Expenses(When a group expense is to be added, the expense is added as normal but associated with a group by putting the group ID**in the expense*)*

  • We aim to generate a graph-like structure for every user to encapsulate how much amount every user needs to pay to every other user,we can use a greedy approach which takes the most positive and most negative net balance and attempts to satisfy them first, in the group we need a MAP(UserIds -> MAP(UserId, Amount)), we will use -a PaymentGraph class needs to encapsulate that

We are now done with all the models in our system

Let us take a look at all the services that our system would need

Expense Service:

  • getGroupExpenses(group Id): returns a vector<Expenses> for this particular group

    • getPaymentGraph(groupBalances: BalanceMap): operates on the net. BalaceMap(representation of result_balace in the above screenshot) and figures out minimum transactions needed to satisfy all

Group Service:

  • getGroupPaymentGraph(groupId,userId): Group ID will allow us to fetch all expenses of that group and userId is the user who is requesting this simplified transactions graph, this userId must belong to the group

    • we will then 'add' these expenses for every user to create a resultBalaces , BalaceMap

    • this BalaceMap is used to create PaymentGraph

UML

Working

  • Let's consider an example of a group trip, to find all net balances of this trip we will first fetch all expenses with this same group ID, for each of these expenses we will get a map<User, Balances> we can sum them up and calculate the total balances as shown in below screenshot

  • Now another Group API getPaymentGraph (group id) will simplify our transactions, a positive value signifies the amount is to be received and a negative value means that the amount is given

  • To simplify transactions one greedy approach would be to get the most negative Amount(min-heap) and most positive Amount(max-heap) and cancel them, each time creating a resulting transaction

  • The above is a greedy approach that would work but it would not always be optimal

CODE

#include <iostream>
#include <unordered_map>
#include <string>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;

//--------------------------------------MODELS
class User {
private:
    string id;
    string firstName;
    string lastName;
    string bio;
    string imageUrl;

    // Making constructor private to enforce the use of the builder
    User(const string& id, const string& firstName, const string& lastName, const string& bio, const string& imageUrl)
        : id(id), firstName(firstName), lastName(lastName), bio(bio), imageUrl(imageUrl) {}

public:
    // Builder class for User
    class Builder {
    private:
        string id;
        string firstName;
        string lastName;
        string bio;
        string imageUrl;

    public:
        Builder& setId(const string& id) {
            this->id = id;
            return *this;
        }

        Builder& setFirstName(const string& firstName) {
            this->firstName = firstName;
            return *this;
        }

        Builder& setLastName(const string& lastName) {
            this->lastName = lastName;
            return *this;
        }

        Builder& setBio(const string& bio) {
            this->bio = bio;
            return *this;
        }

        Builder& setImageUrl(const string& imageUrl) {
            this->imageUrl = imageUrl;
            return *this;
        }

        User build() {
            return User(id, firstName, lastName, bio, imageUrl);
        }
    };

    // Make the builder accessible
    static Builder createUserBuilder() {
        return Builder();
    }

    // Delete copy constructor and copy assignment operator
    User(const User&) = delete;
    User& operator=(const User&) = delete;

    // Public getters
    string getId() const {
        return id;
    }

    string getFirstName() const {
        return firstName;
    }

    string getLastName() const {
        return lastName;
    }

    string getBio() const {
        return bio;
    }

    string getImageUrl() const {
        return imageUrl;
    }
};

class Group {
private:
    string name;
    string desc;
    string imageUrl;
    vector<string> userList;// List of userIds

public:
    Group() = default; // Add default constructor
    Group(const string& name, const string& desc, const string& imageUrl, const vector<string>& userList)
        : name(name), desc(desc), imageUrl(imageUrl), userList(userList) {
    }

    const vector<string>& getUsers() const {
        return userList;
    }

};

enum class Currency {
    USD,
    INR
};

class Amount {
private:
    Currency currency;
    double amount;

public:
    Amount() = default; // Add default constructor

  Amount(const Currency& currency, double amount)
      : currency(currency), amount(amount) {
  }

    Amount add(const Amount& other) const {
        return Amount(currency, amount + other.amount);
    }

    const Currency& getCurrency() const {
        return currency;
    }

    double getAmount() const {
        return amount;
    }
};

class BalanceMap {
private:
    unordered_map<string, Amount> balances;
    // USER_ID -> Owed Amount to this user by another particular user for a particular expense
public:
    BalanceMap() {
        // Initialize the balances map in the constructor.
    }

    BalanceMap(const unordered_map<string, Amount>& resultBalances)
        : balances(resultBalances) {
    }

    const unordered_map<string, Amount>& getBalances() const {
        return balances;
    }
};

class Expense {
private:
    BalanceMap userBalances;
    string title;
    string imageUrl;
    string description;
    string groupId;

public:
    Expense(const BalanceMap& userBalances, const string& title, const string& imageUrl, const string& description, const string& groupId)
        : userBalances(userBalances), title(title), imageUrl(imageUrl), description(description), groupId(groupId) {
    }

    const BalanceMap& getUserBalances() const {
        return userBalances;
    }

    const string& getGroupId() const {
        return groupId;
    }
};



class PaymentGraph {
private:
    unordered_map<string, BalanceMap> graph;// MAP(UserIds -> MAP(UserId,Amount))
    // how much amount every user needs to pay to every other user in the group

public:
    PaymentGraph(const unordered_map<string, BalanceMap>& graph)
        : graph(graph) {
    }

    const unordered_map<string, BalanceMap>& getGraph() const {
        return graph;
    }
};
//--------------------------------------SERVICES

class ExpenseService {
private:
    unordered_map<string, vector<Expense>> groupExpenses; // Simulating a database
    // groupdID=> Expenses
public:
    ExpenseService() {}// Initialize the groupExpenses map in the constructor.

    void addGroupExpense(Expense expense) {
        const string& groupId = expense.getGroupId();
        if (!groupId.empty()) {
            groupExpenses[groupId].push_back(expense);
        }
    }

    vector<Expense> getGroupExpenses(const string& groupId) {
        auto it = groupExpenses.find(groupId);
        if (it != groupExpenses.end()) {
            return it->second;
        }
        return vector<Expense>();
    }

    PaymentGraph getGroupPaymentSimplifiedGraph(const BalanceMap& groupBalances) {
        auto compareBalances = [](const pair<string, Amount>& balance1, const pair<string, Amount>& balance2) {
            return balance1.second.getAmount() < balance2.second.getAmount();
        };

        priority_queue<pair<string, Amount>, vector<pair<string, Amount>>, decltype(compareBalances)> positiveAmounts(compareBalances);
        priority_queue<pair<string, Amount>, vector<pair<string, Amount>>, decltype(compareBalances)> negativeAmounts(compareBalances);

        for (const auto& balance : groupBalances.getBalances()) {
            if (balance.second.getAmount() > 0) {
                positiveAmounts.push(balance);
            } else {
                negativeAmounts.push(balance);
            }
        }

        unordered_map<string, BalanceMap> graph;

        while (!positiveAmounts.empty()) {
            // Eliminate the largest from both groups.
            auto largestPositive = positiveAmounts.top();
            auto largestNegative = negativeAmounts.top();
            negativeAmounts.pop();
            positiveAmounts.pop();

            double negativeAmount = -largestNegative.second.getAmount();
            double positiveAmount = largestPositive.second.getAmount();

            // Retrieve the BalanceMap associated with the largestPositive key (make a copy).
            BalanceMap positiveBalanceMapCopy = graph[largestPositive.first].getBalances();


            double remaining = positiveAmount - negativeAmount;
            Amount remainingAmount(Currency::USD, remaining);

            if (remaining > 0) {
                positiveAmounts.push(make_pair(largestPositive.first, remainingAmount));
            } else if (remaining < 0) {
                negativeAmounts.push(make_pair(largestNegative.first, remainingAmount));
            }
        }

        return PaymentGraph(graph);
    }
};

class GroupService {
    ExpenseService expenseService;
    unordered_map<string, Group> groups;// simulating the database

    BalanceMap sumExpenses(const vector<Expense>& groupExpenses) {
        unordered_map<string, Amount> resultBalances;
        for (const Expense& expense : groupExpenses) {
            for (const auto& balance : expense.getUserBalances().getBalances()) {
                const string& user = balance.first;
                const Amount& amount = balance.second;
                resultBalances[user] = resultBalances[user].add(amount);
            }
        }
        return BalanceMap(resultBalances);
    }

    BalanceMap getNetBalances(const string& groupId, const string& userId) {
        bool userFound = false;
        for (const string& user : groups[groupId].getUsers()) {
            if (user == userId) {
                userFound = true;
                break;
            }
        }

        if (!userFound) {
            throw logic_error("Access denied");
        }
        return sumExpenses(expenseService.getGroupExpenses(groupId));
    }
public:
    static const Amount NIL;

    GroupService(ExpenseService expenseService, unordered_map<string, Group> groups)
        : expenseService(expenseService), groups(groups) {
    }

    PaymentGraph getGroupPaymentGraph(const string& groupId, const string& userId) {
        return expenseService.getGroupPaymentSimplifiedGraph(getNetBalances(groupId, userId));
    }
};



//--------------------------------------APP
class Splitwise {
private:
    GroupService groupService;
    ExpenseService expenseService;

public:
    Splitwise(GroupService groupService, ExpenseService expenseService)
        : groupService(groupService), expenseService(expenseService) {
    }
};

int main() {
    // Design a Expense Splitting application like Splitwise and emulate the below scenario
    // INPUT :
    // Lets say we have a group of 4 users(A,B,C,D) in a group named "PICNIC"
    // Petrol : D paid 400, but A and D agree split among themselves only
    // Icecream : A paid 400 , all agree to distribute equally
    // Lunch : B(Non veg + drink) paid 600, A owes 50(Vegan),C owes 150(drink), D owes 100(Veg)

    //OUTPUT: Tell the group the Minimum transactions that should do to settle their net. Expenses
    // Create users
    User userA = User::createUserBuilder()
                    .setId("A")
                    .setFirstName("John")
                    .setLastName("Doe")
                    .setBio("Software Engineer")
                    .setImageUrl("profile.jpg")
                    .build();
    User userB = User::createUserBuilder()
                    .setId("B")
                    .setFirstName("Jane")
                    .setBio("Tester")
                    .build();
    User userC = User::createUserBuilder()
                    .setId("C")
                    .setFirstName("Jane")
                    .build();
    User userD = User::createUserBuilder()
                    .setId("D")
                    .build();

    // Create a group named "PICNIC"
    vector<string> userList = {"A", "B", "C", "D"};
    Group picnicGroup("PICNIC", "Group for picnic expenses", "group.jpg", userList);

    // Define expenses

        // Petrol : D paid, but A and D agree split among themselves only
    BalanceMap petrolBalances({{"A", Amount(Currency::USD, -200)}, {"B", Amount(Currency::USD, 0)}, {"C", Amount(Currency::USD, 0)}, {"D", Amount(Currency::USD, 200)}});
    Expense petrolExpense(petrolBalances, "Petrol", "petrol.jpg", "Petrol expenses", "PICNIC");

    // Icecream : A paid 400 , all agree to distribute equally
    BalanceMap iceCreamBalances({{"A", Amount(Currency::USD, 300)}, {"B", Amount(Currency::USD, -100)}, {"C", Amount(Currency::USD, -100)}, {"D", Amount(Currency::USD, -100)}});
    Expense iceCreamExpense(iceCreamBalances, "Icecream", "icecream.jpg", "Icecream expenses", "PICNIC");

    // Lunch : B(Non veg + drink) paid 600, A owes 50(Vegan),C owes 150(drink), D owes 100(Veg)
    BalanceMap lunchBalances({{"A", Amount(Currency::USD, -50)}, {"B", Amount(Currency::USD, 300)}, {"C", Amount(Currency::USD, -150)}, {"D", Amount(Currency::USD, -100)}});
    Expense lunchExpense(lunchBalances, "Lunch", "lunch.jpg", "Lunch expenses", "PICNIC");


    // Add group expenses
    ExpenseService expenseService;
    expenseService.addGroupExpense(petrolExpense);
    expenseService.addGroupExpense(iceCreamExpense);
    expenseService.addGroupExpense(lunchExpense);

    // Create a GroupService instance
    GroupService groupService(expenseService, {{"PICNIC", picnicGroup}});

    // Calculate minimum transactions needed to settle expenses
    PaymentGraph paymentGraph = groupService.getGroupPaymentGraph("PICNIC", "A");

    // Output the results (minimum transactions needed)
    const unordered_map<string, BalanceMap>& graph = paymentGraph.getGraph();
    for (const auto& pair : graph) {
        const string& userId = pair.first;
        const BalanceMap& balanceMap = pair.second;
        const unordered_map<string, Amount>& balances = balanceMap.getBalances();
        for (const auto& balance : balances) {
            const string& debtorId = balance.first;
            const Amount& amount = balance.second;
            if (amount.getAmount() != 0) {
                cout << userId << " owes " << debtorId << ": " << amount.getAmount() << endl;
            }
        }
    }

    return 0;
}