Functional Scope (In-Scope)
- Pluggable Splitting Strategies: Supports interchangeable Equal, Exact, and Percentage calculations to divide bills dynamically.
- Balance Graph Modeling: Keeps a single aggregated net balance grid (rather than storing redundant copy lines per expense).
- Minimum Cash Flow Settlement: Solves the min-cash-flow transaction graph reduction problem to close debts optimally.
- Precision Rounding Protection: Handles floating point fractions (e.g. splitting $10.00 equally between 3 users leaves a 1-cent remaining mismatch) by assigning offsets to one member dynamically.
Explicit Boundaries (Out-of-Scope)
- No Real Currency Bank Integration: Ignores actual payment gateways or third-party bank webhooks.
- No Dynamic Currency Swaps: Assumes a single unified functional currency for all calculations.
Production reference implementations showing robust split allocations and graph settlement algorithms in Java and Python:
// ─── JAVA BLUEPRINT ──────────────────────────────────────────────────────────
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
class User {
private final String id;
private final String name;
private final String email;
public User(String id, String name, String email) {
this.id = Objects.requireNonNull(id);
this.name = Objects.requireNonNull(name);
this.email = Objects.requireNonNull(email);
}
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}
enum SplitType { EQUAL, EXACT, PERCENT }
abstract class Split {
private final User user;
protected double amount;
public Split(User user) {
this.user = Objects.requireNonNull(user);
}
public User getUser() { return user; }
public double getAmount() { return amount; }
public void setAmount(double amount) { this.amount = amount; }
}
class EqualSplit extends Split {
public EqualSplit(User user) { super(user); }
}
class ExactSplit extends Split {
public ExactSplit(User user, double amount) {
super(user);
this.amount = amount;
}
}
class PercentSplit extends Split {
private final double percent;
public PercentSplit(User user, double percent) {
super(user);
this.percent = percent;
}
public double getPercent() { return percent; }
}
interface ExpenseSplitStrategy {
void calculateAndValidate(List<Split> splits, double totalAmount);
}
class EqualSplitStrategy implements ExpenseSplitStrategy {
@Override
public void calculateAndValidate(List<Split> splits, double totalAmount) {
int n = splits.size();
if (n == 0) throw new IllegalArgumentException("Splits list cannot be empty.");
double share = Math.floor((totalAmount / n) * 100.0) / 100.0;
double totalCalculated = 0.0;
for (Split split : splits) {
split.setAmount(share);
totalCalculated += share;
}
// Precision Rounding Protection
double difference = totalAmount - totalCalculated;
if (Math.abs(difference) > 0.001) {
double adjustment = Math.round(difference * 100.0) / 100.0;
splits.get(0).setAmount(splits.get(0).getAmount() + adjustment);
}
}
}
class ExactSplitStrategy implements ExpenseSplitStrategy {
@Override
public void calculateAndValidate(List<Split> splits, double totalAmount) {
double totalSplitAmount = 0.0;
for (Split split : splits) {
totalSplitAmount += split.getAmount();
}
if (Math.abs(totalSplitAmount - totalAmount) > 0.01) {
throw new IllegalArgumentException("Sum of exact splits (" + totalSplitAmount + ") must equal total amount (" + totalAmount + ").");
}
}
}
class PercentSplitStrategy implements ExpenseSplitStrategy {
@Override
public void calculateAndValidate(List<Split> splits, double totalAmount) {
double totalPercent = 0.0;
for (Split split : splits) {
if (!(split instanceof PercentSplit)) {
throw new IllegalArgumentException("Split must be of type PercentSplit");
}
totalPercent += ((PercentSplit) split).getPercent();
}
if (Math.abs(totalPercent - 100.0) > 0.01) {
throw new IllegalArgumentException("Total percentage must equal 100%. Got: " + totalPercent);
}
double totalCalculated = 0.0;
for (Split split : splits) {
PercentSplit percentSplit = (PercentSplit) split;
double share = Math.floor((totalAmount * percentSplit.getPercent() / 100.0) * 100.0) / 100.0;
split.setAmount(share);
totalCalculated += share;
}
// Precision Rounding Protection
double difference = totalAmount - totalCalculated;
if (Math.abs(difference) > 0.001) {
double adjustment = Math.round(difference * 100.0) / 100.0;
splits.get(0).setAmount(splits.get(0).getAmount() + adjustment);
}
}
}
class ExpenseSplitStrategyFactory {
private static final Map<SplitType, ExpenseSplitStrategy> strategies = new HashMap<>();
static {
strategies.put(SplitType.EQUAL, new EqualSplitStrategy());
strategies.put(SplitType.EXACT, new ExactSplitStrategy());
strategies.put(SplitType.PERCENT, new PercentSplitStrategy());
}
public static ExpenseSplitStrategy getStrategy(SplitType type) {
ExpenseSplitStrategy strategy = strategies.get(type);
if (strategy == null) {
throw new IllegalArgumentException("Invalid split type: " + type);
}
return strategy;
}
}
class Expense {
private final String id;
private final String description;
private final double amount;
private final User paidBy;
private final List<Split> splits;
private final SplitType splitType;
public Expense(String id, String description, double amount, User paidBy, List<Split> splits, SplitType splitType) {
this.id = Objects.requireNonNull(id);
this.description = Objects.requireNonNull(description);
this.amount = amount;
this.paidBy = Objects.requireNonNull(paidBy);
this.splits = Objects.requireNonNull(splits);
this.splitType = Objects.requireNonNull(splitType);
}
public String getId() { return id; }
public String getDescription() { return description; }
public double getAmount() { return amount; }
public User getPaidBy() { return paidBy; }
public List<Split> getSplits() { return splits; }
public SplitType getSplitType() { return splitType; }
}
class SplitwiseService {
private final Map<String, User> users = new ConcurrentHashMap<>();
private final Map<String, Map<String, Double>> balanceSheet = new ConcurrentHashMap<>();
private final List<Expense> expenses = new CopyOnWriteArrayList<>();
private final ReentrantLock lock = new ReentrantLock();
public void registerUser(User user) {
users.put(user.getId(), user);
}
public User getUser(String id) {
return users.get(id);
}
public void addExpense(String description, double amount, String paidById, List<Split> splits, SplitType splitType) {
User paidBy = users.get(paidById);
if (paidBy == null) {
throw new IllegalArgumentException("User not registered: " + paidById);
}
// Resolve split strategy and calculate exact share values
ExpenseSplitStrategy strategy = ExpenseSplitStrategyFactory.getStrategy(splitType);
strategy.calculateAndValidate(splits, amount);
lock.lock();
try {
String expenseId = "EXP-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
Expense expense = new Expense(expenseId, description, amount, paidBy, splits, splitType);
expenses.add(expense);
for (Split split : splits) {
String oweUserId = split.getUser().getId();
if (oweUserId.equals(paidById)) continue;
double shareAmount = split.getAmount();
// paidBy lent shareAmount to oweUserId
updateBalance(paidById, oweUserId, shareAmount);
updateBalance(oweUserId, paidById, -shareAmount);
}
System.out.println("[Splitwise] Added expense: '" + description + "' of $" + amount + " paid by " + paidBy.getName());
} finally {
lock.unlock();
}
}
private void updateBalance(String userA, String userB, double amount) {
balanceSheet.computeIfAbsent(userA, k -> new ConcurrentHashMap<>());
Map<String, Double> balances = balanceSheet.get(userA);
balances.put(userB, balances.getOrDefault(userB, 0.0) + amount);
}
public List<String> settleUp() {
lock.lock();
try {
Map<String, Double> netBalances = new HashMap<>();
// Aggregate global net positions
for (String userA : balanceSheet.keySet()) {
for (Map.Entry<String, Double> entry : balanceSheet.get(userA).entrySet()) {
netBalances.put(userA, netBalances.getOrDefault(userA, 0.0) + entry.getValue());
}
}
// Max Heap for Creditors, Min Heap for Debtors
PriorityQueue<Map.Entry<String, Double>> debtors = new PriorityQueue<>(
Comparator.comparingDouble(Map.Entry::getValue)
);
PriorityQueue<Map.Entry<String, Double>> creditors = new PriorityQueue<>(
(a, b) -> Double.compare(b.getValue(), a.getValue())
);
for (Map.Entry<String, Double> entry : netBalances.entrySet()) {
double val = entry.getValue();
if (val < -0.01) {
debtors.add(new AbstractMap.SimpleEntry<>(entry.getKey(), val));
} else if (val > 0.01) {
creditors.add(new AbstractMap.SimpleEntry<>(entry.getKey(), val));
}
}
List<String> transactions = new ArrayList<>();
while (!debtors.isEmpty() && !creditors.isEmpty()) {
Map.Entry<String, Double> debtor = debtors.poll();
Map.Entry<String, Double> creditor = creditors.poll();
double debtAmount = -debtor.getValue();
double creditAmount = creditor.getValue();
double settledValue = Math.min(debtAmount, creditAmount);
User debtorUser = users.get(debtor.getKey());
User creditorUser = users.get(creditor.getKey());
transactions.add(debtorUser.getName() + " pays " + creditorUser.getName() + " $" + String.format("%.2f", settledValue));
double remainingDebtor = debtor.getValue() + settledValue;
double remainingCreditor = creditor.getValue() - settledValue;
if (remainingDebtor < -0.01) {
debtor.setValue(remainingDebtor);
debtors.add(debtor);
}
if (remainingCreditor > 0.01) {
creditor.setValue(remainingCreditor);
creditors.add(creditor);
}
}
return transactions;
} finally {
lock.unlock();
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
SplitwiseService service = new SplitwiseService();
User alice = new User("U1", "Alice", "alice@example.com");
User bob = new User("U2", "Bob", "bob@example.com");
User charlie = new User("U3", "Charlie", "charlie@example.com");
User david = new User("U4", "David", "david@example.com");
service.registerUser(alice);
service.registerUser(bob);
service.registerUser(charlie);
service.registerUser(david);
System.out.println("--- Registering expenses ---");
// 1. Alice paid $100 for Equal split among Alice, Bob, Charlie, David
List<Split> splits1 = Arrays.asList(
new EqualSplit(alice),
new EqualSplit(bob),
new EqualSplit(charlie),
new EqualSplit(david)
);
service.addExpense("Dinner", 100.00, "U1", splits1, SplitType.EQUAL);
// 2. Bob paid $50 for Exact split: Charlie owes $10, David owes $40
List<Split> splits2 = Arrays.asList(
new ExactSplit(charlie, 10.00),
new ExactSplit(david, 40.00)
);
service.addExpense("Cab ride", 50.00, "U2", splits2, SplitType.EXACT);
// 3. Charlie paid $120.00 for Percentage split: Alice (20%), Bob (30%), Charlie (50%)
List<Split> splits3 = Arrays.asList(
new PercentSplit(alice, 20.0),
new PercentSplit(bob, 30.0),
new PercentSplit(charlie, 50.0)
);
service.addExpense("Groceries", 120.00, "U3", splits3, SplitType.PERCENT);
System.out.println("\n--- Calculating Debt Simplification (Min Cash Flow) ---");
List<String> plans = service.settleUp();
for (String plan : plans) {
System.out.println(" -> " + plan);
}
System.out.println("\n--- Simulating Concurrent Expense Additions ---");
ExecutorService executor = Executors.newFixedThreadPool(3);
Runnable t1 = () -> service.addExpense("Coffee", 15.00, "U1", Arrays.asList(new EqualSplit(alice), new EqualSplit(bob)), SplitType.EQUAL);
Runnable t2 = () -> service.addExpense("Snacks", 25.00, "U2", Arrays.asList(new EqualSplit(bob), new EqualSplit(charlie)), SplitType.EQUAL);
executor.submit(t1);
executor.submit(t2);
executor.shutdown();
executor.awaitTermination(2, TimeUnit.SECONDS);
System.out.println("\n--- New Debt Plan After Concurrency ---");
plans = service.settleUp();
for (String plan : plans) {
System.out.println(" -> " + plan);
}
}
}