From 5fe2833d49306e56c6f5934ad663e768afe3da44 Mon Sep 17 00:00:00 2001 From: Raghav Ranjan Date: Sat, 21 Sep 2019 23:07:43 +0530 Subject: [PATCH] Design for Splitwise --- .idea/libraries/junit_junit_4_12.xml | 11 ++ src/Main.java | 94 +++++++++++ .../ShareTypeValidationException.java | 7 + src/exception/UserAlreadyExistsException.java | 7 + src/model/Identifier.java | 17 ++ src/model/InstructionType.java | 6 + src/model/SplitType.java | 84 ++++++++++ src/model/TransactionInput.java | 34 ++++ src/model/User.java | 21 +++ src/model/ValuePair.java | 14 ++ src/service/SplitwiseMainService.java | 16 ++ .../impl/SplitwiseMainServiceImpl.java | 86 ++++++++++ tst/model/TransactionInputTest.java | 28 ++++ tst/service/SplitwiseMainServiceTest.java | 149 ++++++++++++++++++ 14 files changed, 574 insertions(+) create mode 100644 .idea/libraries/junit_junit_4_12.xml create mode 100644 src/Main.java create mode 100644 src/exception/ShareTypeValidationException.java create mode 100644 src/exception/UserAlreadyExistsException.java create mode 100644 src/model/Identifier.java create mode 100644 src/model/InstructionType.java create mode 100644 src/model/SplitType.java create mode 100644 src/model/TransactionInput.java create mode 100644 src/model/User.java create mode 100644 src/model/ValuePair.java create mode 100644 src/service/SplitwiseMainService.java create mode 100644 src/service/impl/SplitwiseMainServiceImpl.java create mode 100644 tst/model/TransactionInputTest.java create mode 100644 tst/service/SplitwiseMainServiceTest.java diff --git a/.idea/libraries/junit_junit_4_12.xml b/.idea/libraries/junit_junit_4_12.xml new file mode 100644 index 0000000..d134f71 --- /dev/null +++ b/.idea/libraries/junit_junit_4_12.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/Main.java b/src/Main.java new file mode 100644 index 0000000..867a4ed --- /dev/null +++ b/src/Main.java @@ -0,0 +1,94 @@ +import model.Identifier; +import model.InstructionType; +import model.SplitType; +import model.TransactionInput; +import model.ValuePair; +import service.SplitwiseMainService; +import service.impl.SplitwiseMainServiceImpl; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class Main { + public static void main(String args[]) throws IOException { + final SplitwiseMainService splitwiseMainService = new SplitwiseMainServiceImpl(); + final BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + splitwiseMainService.createUser(new Identifier("u3")); + splitwiseMainService.createUser(new Identifier("u2")); + splitwiseMainService.createUser(new Identifier("u1")); + splitwiseMainService.createUser(new Identifier("u4")); + + while(true) { + try { + final String inputLine = reader.readLine(); + final String[] input = inputLine.split(" "); + if (input[0].equals(InstructionType.EXPENSE.toString())) { + final Identifier payer = new Identifier(input[1]); + final Double amount = Double.parseDouble(input[2]); + final Integer number = Integer.parseInt(input[3]); + final List valuePairs = new ArrayList<>(); + for(int i = 4; i < 4 + number; i++) { + valuePairs.add(new ValuePair(new Identifier(input[i]), 0.0)); + } + + if (!input[4 + number].equals(SplitType.EQUAL.toString())) { + for (int i = 5 + number; i < 5 + 2 * number; i++) { + valuePairs.get(i - number - 5).setValue(Double.parseDouble(input[i])); + } + } + final TransactionInput transactionInput = + TransactionInput.builder() + .amount(amount) + .payer(payer) + .shareParameters(valuePairs) + .splitType(SplitType.valueOf(input[4 + number])) + .build(); + splitwiseMainService.addExpense(transactionInput); + } else if (input[0].equals(InstructionType.SHOW.toString())) { + if (input.length == 1) { + formatAndDisplayAll(splitwiseMainService.getBalances()); + } else { + final Identifier user = new Identifier(input[1]); + formatAndDisplay(user, splitwiseMainService.getBalanceForUser(user), false); + } + } else { + throw new RuntimeException(String.format("Did not match any command: %s", inputLine)); + } + } catch (final Exception e) { + System.out.println("Error: " + e.getMessage()); + } + } + } + + private static void formatAndDisplay(final Identifier user, final Map expenseMap, final Boolean allMode) { + if(expenseMap.size() == 0) { + System.out.println("No balances"); + } + expenseMap.forEach( + ((identifier, aDouble) -> { + if(aDouble > 0) { + System.out.println(identifier.getValue() + " owes " + user.getValue() + " " + Math.abs(aDouble)); + } else if (!allMode) { + System.out.println(user.getValue() + " owes " + identifier.getValue() + " " + Math.abs(aDouble)); + } + }) + ); + } + + private static void formatAndDisplayAll(final Map> allExpenseMap) { + if (allExpenseMap.keySet().stream() + .allMatch(key -> allExpenseMap.get(key).size() == 0)) { + System.out.println("No balances"); + return; + } + + allExpenseMap.forEach( + (userId, expenseMap) -> { + formatAndDisplay(userId, expenseMap, true); + }); + } +} diff --git a/src/exception/ShareTypeValidationException.java b/src/exception/ShareTypeValidationException.java new file mode 100644 index 0000000..56464ae --- /dev/null +++ b/src/exception/ShareTypeValidationException.java @@ -0,0 +1,7 @@ +package exception; + +public class ShareTypeValidationException extends IllegalArgumentException{ + public ShareTypeValidationException(final String msg) { + super(msg); + } +} diff --git a/src/exception/UserAlreadyExistsException.java b/src/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000..7f5781f --- /dev/null +++ b/src/exception/UserAlreadyExistsException.java @@ -0,0 +1,7 @@ +package exception; + +public class UserAlreadyExistsException extends IllegalArgumentException { + public UserAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/model/Identifier.java b/src/model/Identifier.java new file mode 100644 index 0000000..f90c0cd --- /dev/null +++ b/src/model/Identifier.java @@ -0,0 +1,17 @@ +package model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@EqualsAndHashCode +public class Identifier { + private final String value; + + public Identifier(@NonNull final String value) { + this.value = value; + } +} diff --git a/src/model/InstructionType.java b/src/model/InstructionType.java new file mode 100644 index 0000000..62f840f --- /dev/null +++ b/src/model/InstructionType.java @@ -0,0 +1,6 @@ +package model; + +public enum InstructionType { + SHOW, + EXPENSE; +} diff --git a/src/model/SplitType.java b/src/model/SplitType.java new file mode 100644 index 0000000..4e4696a --- /dev/null +++ b/src/model/SplitType.java @@ -0,0 +1,84 @@ +package model; + +import exception.ShareTypeValidationException; +import lombok.NonNull; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public enum SplitType { + EQUAL { + public Map splitAmount(@NonNull final Double amount, @NonNull final List shareParameters) { + final Integer numberOfUsers = shareParameters.size(); + final Map shareAmounts = new HashMap<>(); + final Double amountToBeShared = amount / numberOfUsers; + shareParameters + .forEach( + shareParameter -> shareAmounts.put(shareParameter.getKey(), truncateToTwoDecimalPlaces(amountToBeShared))); + adjustToTwoDecimalPlaces(shareParameters, amount, shareAmounts); + return shareAmounts; + } + }, + EXACT { + public Map splitAmount(@NonNull final Double amount, @NonNull final List shareParameters) { + SplitType.validateTotal(shareParameters, amount); + final Map shareAmounts = shareParameters.stream() + .collect(Collectors.toMap(ValuePair::getKey, valuePair -> truncateToTwoDecimalPlaces(valuePair.getValue()))); + adjustToTwoDecimalPlaces(shareParameters, amount, shareAmounts); + return shareAmounts; + } + }, + PERCENTAGE { + public Map splitAmount(@NonNull final Double amount, @NonNull final List shareParameters) { + final Map shareAmounts = new HashMap<>(); + SplitType.validateTotal(shareParameters, 100.0); + shareParameters.forEach( + valuePair -> shareAmounts.put(valuePair.getKey(), truncateToTwoDecimalPlaces((valuePair.getValue() * amount)/100))); + adjustToTwoDecimalPlaces(shareParameters, amount, shareAmounts); + return shareAmounts; + } + }, + SHARES { + public Map splitAmount(@NonNull final Double amount, @NonNull final List shareParameters) { + final Map shareAmounts = new HashMap<>(); + final Double shareSum = shareParameters.stream() + .map(ValuePair::getValue) + .reduce((a, b) -> a + b) + .orElseThrow(() -> new ShareTypeValidationException("Error occurred in processing SHARES split")); + shareParameters.forEach( + valuePair -> shareAmounts.put(valuePair.getKey(), truncateToTwoDecimalPlaces((valuePair.getValue() * amount)/shareSum))); + adjustToTwoDecimalPlaces(shareParameters, amount, shareAmounts); + return shareAmounts; + } + }; + + private static void validateTotal(final List valuePairs, final Double amount) { + valuePairs.stream() + .map(ValuePair::getValue) + .reduce((a, b) -> a + b) + .filter(sumAmount -> sumAmount.equals(amount)) + .orElseThrow(() -> new ShareTypeValidationException("Values do not add for split")); + } + + private static Double truncateToTwoDecimalPlaces(final Double amount) { + return Math.floor(amount * 100)/100; + } + + private static void adjustToTwoDecimalPlaces(final List shareParameters, + final Double amount, + final Map expenses) { + final Double totalAmount = expenses.keySet().stream() + .map(expenses::get) + .reduce((a, b) -> a + b).get(); + + if (!totalAmount.equals(amount)) { + final Double diff = amount - totalAmount; + expenses.put(shareParameters.get(0).getKey(), expenses.get(shareParameters.get(0).getKey()) + diff); + } + } + + public abstract Map splitAmount(@NonNull final Double amount, @NonNull final List shareParameters); + +} diff --git a/src/model/TransactionInput.java b/src/model/TransactionInput.java new file mode 100644 index 0000000..2abe670 --- /dev/null +++ b/src/model/TransactionInput.java @@ -0,0 +1,34 @@ +package model; + +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +import java.util.List; + +@Builder(toBuilder = true) +@Getter +public class TransactionInput { + @NonNull + private final Identifier payer; + + @NonNull + private final SplitType splitType; + + @NonNull + private final List shareParameters; + + @NonNull + private final Double amount; + + public static class TransactionInputBuilder { + public TransactionInputBuilder amount(Double amount) { + final Double truncatedAmount = Math.floor(amount * 100)/100; + if(!truncatedAmount.equals(amount)) { + throw new IllegalArgumentException("More than two digits specified in input amount"); + } + this.amount = amount; + return this; + } + } +} diff --git a/src/model/User.java b/src/model/User.java new file mode 100644 index 0000000..42adf73 --- /dev/null +++ b/src/model/User.java @@ -0,0 +1,21 @@ +package model; + +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +import java.util.Map; + +@Builder +@Getter +public class User { + @NonNull + private Identifier identifier; + + private String name; + + private String email; + + @NonNull + private Map balances; +} diff --git a/src/model/ValuePair.java b/src/model/ValuePair.java new file mode 100644 index 0000000..7f331d7 --- /dev/null +++ b/src/model/ValuePair.java @@ -0,0 +1,14 @@ +package model; + +import lombok.Data; +import lombok.NonNull; + +@Data +public class ValuePair { + @NonNull + private final Identifier key; + + @NonNull + private Double value; + +} diff --git a/src/service/SplitwiseMainService.java b/src/service/SplitwiseMainService.java new file mode 100644 index 0000000..cb74c62 --- /dev/null +++ b/src/service/SplitwiseMainService.java @@ -0,0 +1,16 @@ +package service; + +import model.Identifier; +import model.TransactionInput; + +import java.util.Map; + +public interface SplitwiseMainService { + public void createUser(Identifier userId); + + public void addExpense(TransactionInput transaction); + + public Map> getBalances(); + + public Map getBalanceForUser(Identifier userId); +} diff --git a/src/service/impl/SplitwiseMainServiceImpl.java b/src/service/impl/SplitwiseMainServiceImpl.java new file mode 100644 index 0000000..1532959 --- /dev/null +++ b/src/service/impl/SplitwiseMainServiceImpl.java @@ -0,0 +1,86 @@ +package service.impl; + +import exception.UserAlreadyExistsException; +import lombok.NonNull; +import model.TransactionInput; +import model.Identifier; +import model.User; +import service.SplitwiseMainService; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +public class SplitwiseMainServiceImpl implements SplitwiseMainService { + private final Map userMap = new HashMap<>(); + + @Override + public void addExpense(@NonNull final TransactionInput transaction) { + final Identifier payer = transaction.getPayer(); + final Map payerExpenses = getBalanceForUser(payer); + + checkUserExists(payer); + + final Map expenseAmounts = transaction.getSplitType().splitAmount(transaction.getAmount(), + transaction.getShareParameters()); + expenseAmounts.keySet().forEach(this::checkUserExists); + + expenseAmounts.keySet().stream() + .filter(userId -> !userId.equals(payer)) + .forEach(userId -> { + addExpenseToMap(payerExpenses, userId, expenseAmounts.get(userId)); + addExpenseToMap(getBalanceForUser(userId), payer, -expenseAmounts.get(userId)); + }); + } + + @Override + public Map getBalanceForUser(@NonNull final Identifier userId) { + if (!userMap.keySet().contains(userId)) { + throw new RuntimeException(String.format("User %s does not exist", userId.getValue())); + } + return userMap.get(userId).getBalances(); + } + + private void checkUserExists(final Identifier userId) { + if (!userMap.keySet().contains(userId)) { + throw new RuntimeException(String.format("User %s does not exist", userId.getValue())); + } + } + + private void addExpenseToMap(final Map expenseMap, + final Identifier userId, + final Double amount) { + if(expenseMap.keySet().contains(userId)) { + final Double updatedAmount = expenseMap.get(userId) + amount; + if(updatedAmount == 0.0) { + expenseMap.remove(userId); + } else { + expenseMap.put(userId, expenseMap.get(userId) + amount); + } + } else { + expenseMap.put(userId, amount); + } + } + + @Override + public void createUser(@NonNull final Identifier userId) { + if(userMap.keySet().contains(userId)) { + throw new UserAlreadyExistsException(String.format("User: %s already exists", userId.getValue())); + } + final User user = + User.builder() + .identifier(userId) + .balances(new HashMap<>()) + .build(); + userMap.put(userId, user); + } + + @Override + public Map> getBalances() { + return userMap.keySet().parallelStream() + .map(userMap::get) + .collect(Collectors.toMap(User::getIdentifier, User::getBalances)); + } + +} + diff --git a/tst/model/TransactionInputTest.java b/tst/model/TransactionInputTest.java new file mode 100644 index 0000000..be685af --- /dev/null +++ b/tst/model/TransactionInputTest.java @@ -0,0 +1,28 @@ +package model; + +import org.junit.Test; + +import java.util.ArrayList; + +public class TransactionInputTest { + + @Test + public void testTransactionInput() { + TransactionInput.builder() + .payer(new Identifier("Dummy")) + .shareParameters(new ArrayList<>()) + .splitType(SplitType.EQUAL) + .amount(120.1) + .build(); + } + + @Test(expected = IllegalArgumentException.class) + public void testTransactionInput_moreThatTwoDigits() { + TransactionInput.builder() + .payer(new Identifier("Dummy")) + .shareParameters(new ArrayList<>()) + .splitType(SplitType.EQUAL) + .amount(120.001) + .build(); + } +} diff --git a/tst/service/SplitwiseMainServiceTest.java b/tst/service/SplitwiseMainServiceTest.java new file mode 100644 index 0000000..8d611ac --- /dev/null +++ b/tst/service/SplitwiseMainServiceTest.java @@ -0,0 +1,149 @@ +package service; + +import model.Identifier; +import model.SplitType; +import model.TransactionInput; +import model.ValuePair; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import service.impl.SplitwiseMainServiceImpl; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class SplitwiseMainServiceTest { + + private SplitwiseMainService splitwiseMainService; + private Identifier u1; + private Identifier u2; + private Identifier u3; + + @Before + public void setUp() { + splitwiseMainService = new SplitwiseMainServiceImpl(); + u1 = new Identifier("UserId1"); + u2 = new Identifier("UserId2"); + u3 = new Identifier("UserId3"); + splitwiseMainService.createUser(u1); + splitwiseMainService.createUser(u2); + splitwiseMainService.createUser(u3); + } + + @Test + public void testMainService_EQUAL() { + final List valuePairList = new ArrayList<>(); + valuePairList.add(new ValuePair(u1, 0.0)); + valuePairList.add(new ValuePair(u2, 0.0)); + valuePairList.add(new ValuePair(u3, 0.0)); + + final List valuePairList2 = new ArrayList<>(); + valuePairList2.add(new ValuePair(u1, 0.0)); + valuePairList2.add(new ValuePair(u2, 0.0)); + valuePairList2.add(new ValuePair(u3, 0.0)); + + final List valuePairList3 = new ArrayList<>(); + valuePairList3.add(new ValuePair(u1, 0.0)); + valuePairList3.add(new ValuePair(u2, 0.0)); + valuePairList3.add(new ValuePair(u3, 0.0)); + + + final TransactionInput transactionInput = + TransactionInput.builder() + .amount(120.0) + .payer(u1) + .splitType(SplitType.EQUAL) + .shareParameters(valuePairList) + .build(); + splitwiseMainService.addExpense(transactionInput); + final Map> allBalances = splitwiseMainService.getBalances(); + Assert.assertEquals(allBalances.get(u1).get(u3), new Double(40.0)); + Assert.assertEquals(allBalances.get(u1).get(u2), new Double(40.0)); + Assert.assertEquals(allBalances.get(u3).get(u1), new Double(-40.0)); + Assert.assertEquals(allBalances.get(u2).get(u1), new Double(-40.0)); + + splitwiseMainService.addExpense( + transactionInput + .toBuilder() + .payer(u2) + .shareParameters(valuePairList2) + .build()); + splitwiseMainService.addExpense( + transactionInput + .toBuilder() + .payer(u3) + .shareParameters(valuePairList3) + .build()); + final Map> allBalances2 = splitwiseMainService.getBalances(); + Assert.assertTrue(allBalances2.keySet().stream() + .allMatch( + key -> allBalances2.get(key).size() == 0)); + } + + @Test + public void testMainService_SHARES() { + final List valuePairList = new ArrayList<>(); + valuePairList.add(new ValuePair(u1, 1.0)); + valuePairList.add(new ValuePair(u2, 1.0)); + valuePairList.add(new ValuePair(u3, 2.0)); + + final TransactionInput transactionInput = + TransactionInput.builder() + .amount(100.0) + .payer(u1) + .splitType(SplitType.SHARES) + .shareParameters(valuePairList) + .build(); + splitwiseMainService.addExpense(transactionInput); + final Map> allBalances = splitwiseMainService.getBalances(); + Assert.assertEquals(allBalances.get(u1).get(u3), new Double(50.0)); + Assert.assertEquals(allBalances.get(u1).get(u2), new Double(25.0)); + Assert.assertEquals(allBalances.get(u3).get(u1), new Double(-50.0)); + Assert.assertEquals(allBalances.get(u2).get(u1), new Double(-25.0)); + } + + @Test + public void testMainService_PERCENTAGE() { + final List valuePairList = new ArrayList<>(); + valuePairList.add(new ValuePair(u1, 25.0)); + valuePairList.add(new ValuePair(u2, 25.0)); + valuePairList.add(new ValuePair(u3, 50.0)); + + final TransactionInput transactionInput = + TransactionInput.builder() + .amount(100.0) + .payer(u1) + .splitType(SplitType.PERCENTAGE) + .shareParameters(valuePairList) + .build(); + splitwiseMainService.addExpense(transactionInput); + final Map> allBalances = splitwiseMainService.getBalances(); + Assert.assertEquals(allBalances.get(u1).get(u3), new Double(50.0)); + Assert.assertEquals(allBalances.get(u1).get(u2), new Double(25.0)); + Assert.assertEquals(allBalances.get(u3).get(u1), new Double(-50.0)); + Assert.assertEquals(allBalances.get(u2).get(u1), new Double(-25.0)); + } + + @Test + public void testMainService_EXACT() { + final List valuePairList = new ArrayList<>(); + valuePairList.add(new ValuePair(u1, 25.0)); + valuePairList.add(new ValuePair(u2, 25.0)); + valuePairList.add(new ValuePair(u3, 50.0)); + + final TransactionInput transactionInput = + TransactionInput.builder() + .amount(100.0) + .payer(u1) + .splitType(SplitType.EXACT) + .shareParameters(valuePairList) + .build(); + splitwiseMainService.addExpense(transactionInput); + final Map> allBalances = splitwiseMainService.getBalances(); + Assert.assertEquals(allBalances.get(u1).get(u3), new Double(50.0)); + Assert.assertEquals(allBalances.get(u1).get(u2), new Double(25.0)); + Assert.assertEquals(allBalances.get(u3).get(u1), new Double(-50.0)); + Assert.assertEquals(allBalances.get(u2).get(u1), new Double(-25.0)); + } +}