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;
}