diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 00000000..cb28b0e3 Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..e70e7bc8 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f1c6f072 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM maven:3.9-eclipse-temurin-21 AS builder + +WORKDIR /app + +COPY pom.xml ./ +RUN mvn dependency:go-offline -q + +COPY src/ src/ +RUN mvn package -DskipTests -q + +FROM eclipse-temurin:21-jre-alpine AS runtime + +WORKDIR /app + +COPY --from=builder /app/target/*.jar app.jar + +EXPOSE 4110 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index 98a12d70..8c08e5fd 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,87 @@ -# Allo Bank Backend Developer Take-Home Test +Youtube: https://www.youtube.com/@learnwithkenedy +email: kenedinovriansyah@gmail.com -Welcome, and thank you for your interest in joining Allo Bank Engineering! +# Split Bill API -This challenge is intentionally open-ended. There is no skeleton, no guided steps, and no single correct answer. We want to see how you think, how you structure a solution, and what you consider important in production-grade code. +A Spring Boot REST API for managing shared expenses and calculating settlements among a group of people. ---- +## Build & Run -## The Challenge: Split Bill API +**Prerequisites:** Java 17+, Docker (optional) -Build a **Spring Boot REST API** that helps a group of people manage shared expenses and calculate who owes whom at the end. +### Local -Think of a real scenario: a group trip, a team lunch, a shared apartment. People take turns paying for things, and at the end someone needs to figure out the fairest way to settle up. +```bash +GITHUB_USERNAME=kydevx ./mvnw spring-boot:run +``` -**Your API should, at minimum, support:** +### Docker -1. Creating a bill group with a name and a list of participants -2. Adding expenses to a group — who paid, how much, and who it was for -3. Retrieving a settlement summary — a clear breakdown of who owes whom and how much +```bash +docker build -t split-bill-api . +docker run -p 4110:4110 -e GITHUB_USERNAME=kydevx split-bill-api +``` -Everything else is up to you. +## Example Curl Commands ---- +### 1. Create a group -## Technical Requirements +```bash +curl -X POST http://localhost:4110/api/groups \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Weekend Trip", + "participants": ["Alice", "Bob", "Charlie"] + }' +``` -These are non-negotiable: +Response includes the `id` needed for subsequent requests. -- **Java 17+**, **Spring Boot**, **Maven** -- **`BigDecimal`** for all monetary values — no `float` or `double` -- **A `Dockerfile`** using a multi-stage build (see `Dockerfile.template` in this repo) -- At least **one unit test** covering your settlement calculation logic -- A **`README.md`** in your submission with: - - How to build and run your project - - Example `curl` commands for each endpoint - - Your **GitHub username** and your calculated **service charge** value (see Personalization section below) - - Answer to the submission question (see below) +### 2. Add an expense ---- +```bash +curl -X POST http://localhost:4110/api/groups/{groupId}/expenses \ + -H "Content-Type: application/json" \ + -d '{ + "paidBy": "Alice", + "amount": 60.00, + "description": "Pizza dinner", + "splitAmong": ["Alice", "Bob", "Charlie"] + }' +``` -## Personalization - -Every settlement response must include two additional fields: `service_charge_pct` and `service_charge_amount`. - -The `service_charge_pct` is unique to you and is calculated as follows: - -1. Take your GitHub username in **lowercase** -2. Sum the Unicode (ASCII) values of all characters -3. `service_charge_pct = (sum % 10)` — this gives a value between 0 and 9 (representing a percentage) - -**Example:** GitHub username `johndoe47` -- Unicode sum: `106+111+104+110+100+111+101+52+55` = `850` -- `service_charge_pct = 850 % 10` = **0** (0%) - -The `service_charge_amount` is this percentage applied to the total group expenses. +### 3. Get settlement summary -Include both fields in your settlement response. This value must be computed in code — do not hardcode it. +```bash +curl http://localhost:4110/api/groups/{groupId}/settlement +``` ---- +Sample response: -## Show Your Skills +```json +{ + "groupId": "...", + "groupName": "Weekend Trip", + "totalExpenses": 60.0, + "debts": [ + { "from": "Bob", "to": "Alice", "amount": 20.0 }, + { "from": "Charlie", "to": "Alice", "amount": 20.0 } + ], + "serviceChargePct": 7, + "serviceChargeAmount": 4.2 +} +``` -The minimum requirements get you through the door. What you build beyond that is how you stand out. - -Some directions to explore — pick what interests you, or invent your own: - -- **Multiple split strategies** — equal split, split by percentage, split by exact amount per person -- **Settlement optimization** — minimize the total number of transactions needed to settle all debts -- **Payment recording** — mark a debt as paid and update outstanding balances -- **Expense categories** — tag expenses (food, transport, accommodation) and show per-category summaries -- **Audit trail** — track when expenses and payments were added - -There is no bonus point checklist. We are looking at the quality of what you choose to build, not the quantity. +## Personalization ---- +- **GitHub Username:** kydevx +- **Service Charge Calculation:** + - Unicode sum: 107+121+100+101+118+120 = 667 + - `service_charge_pct = 667 % 10 = 7` (7%) +- The `service_charge_amount` is 7% of the total group expenses, computed in code at runtime. ## Submission Question -In your `README.md`, answer the following in a short paragraph (3–5 sentences): - -> **"What was the hardest design decision you made while building this, and what trade-off did you accept?"** - -There is no wrong answer. We ask this because it tells us more about how you think than the code itself. - ---- - -## Submission Process - -1. **Create a private GitHub repository** for your solution -2. **Add `allobankdev` as a collaborator** (Settings → Collaborators → Add people) -3. **Include a `Dockerfile`** in the root of your project (see `Dockerfile.template`) -4. **Submit via the form:** [Click Here](https://forms.gle/nZKQ2EjTCPfAKHog7) - - The form will ask for: - - Your full name and contact details - - Your private GitHub repository URL - - Your GitHub username (for personalization verification) - -> Do not open a Pull Request to this repository. Submissions are private. - ---- - -## What We Look For - -| Area | What it signals | -|---|---| -| Data modeling | How you think about domain entities and relationships | -| API design | Clarity, consistency, and REST conventions | -| Monetary handling | Awareness of precision issues in financial systems | -| Code structure | Separation of concerns, readability, maintainability | -| Testing | What you consider worth testing and why | -| Submission answer | Genuine engagement with the problem | - -We review every submission before the interview. The interview will include questions directly about your code — be ready to walk through it and extend it live. +**"What was the hardest design decision you made while building this, and what trade-off did you accept?"** -Good luck! +The hardest decision was choosing between an in-memory store and a database. I chose in-memory (ConcurrentHashMap) to keep the setup zero-config and focus the evaluation on the API design and settlement algorithm rather than infrastructure wiring. The trade-off is that all data is lost on restart, but the problem's requirements didn't specify persistence, and the settlement logic is the core deliverable. If this were production, I'd swap in a database repository — the service layer is already fully decoupled from storage through the repository interface, so the migration would be straightforward. diff --git a/mvnw b/mvnw new file mode 100755 index 00000000..bd8896bf --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..92450f93 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..7b808d83 --- /dev/null +++ b/pom.xml @@ -0,0 +1,46 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.allo + split-bill + 1.0.0 + Split Bill API + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/allo/splitbill/SplitBillApplication.java b/src/main/java/com/allo/splitbill/SplitBillApplication.java new file mode 100644 index 00000000..e5b8bf3c --- /dev/null +++ b/src/main/java/com/allo/splitbill/SplitBillApplication.java @@ -0,0 +1,12 @@ +package com.allo.splitbill; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SplitBillApplication { + + public static void main(String[] args) { + SpringApplication.run(SplitBillApplication.class, args); + } +} diff --git a/src/main/java/com/allo/splitbill/controller/BillGroupController.java b/src/main/java/com/allo/splitbill/controller/BillGroupController.java new file mode 100644 index 00000000..400fe28d --- /dev/null +++ b/src/main/java/com/allo/splitbill/controller/BillGroupController.java @@ -0,0 +1,54 @@ +package com.allo.splitbill.controller; + +import com.allo.splitbill.dto.AddExpenseRequest; +import com.allo.splitbill.dto.CreateGroupRequest; +import com.allo.splitbill.model.BillGroup; +import com.allo.splitbill.model.Expense; +import com.allo.splitbill.model.SettlementResponse; +import com.allo.splitbill.service.BillGroupService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/groups") +public class BillGroupController { + + private final BillGroupService billGroupService; + + public BillGroupController(BillGroupService billGroupService) { + this.billGroupService = billGroupService; + } + + @PostMapping + public ResponseEntity createGroup(@Valid @RequestBody CreateGroupRequest request) { + BillGroup group = billGroupService.createGroup(request.getName(), request.getParticipants()); + return ResponseEntity.status(HttpStatus.CREATED).body(group); + } + + @PostMapping("/{groupId}/expenses") + public ResponseEntity addExpense( + @PathVariable String groupId, + @Valid @RequestBody AddExpenseRequest request) { + BillGroup group = billGroupService.addExpense( + groupId, request.getPaidBy(), request.getAmount(), + request.getDescription(), request.getSplitAmong()); + Expense last = group.getExpenses().get(group.getExpenses().size() - 1); + return ResponseEntity.status(HttpStatus.CREATED).body(last); + } + + @GetMapping("/{groupId}") + public ResponseEntity getGroup(@PathVariable String groupId) { + BillGroup group = billGroupService.getGroup(groupId); + return ResponseEntity.ok(group); + } + + @GetMapping("/{groupId}/settlement") + public ResponseEntity getSettlement(@PathVariable String groupId) { + SettlementResponse settlement = billGroupService.getSettlement(groupId); + return ResponseEntity.ok(settlement); + } +} diff --git a/src/main/java/com/allo/splitbill/controller/GlobalExceptionHandler.java b/src/main/java/com/allo/splitbill/controller/GlobalExceptionHandler.java new file mode 100644 index 00000000..fadac4b5 --- /dev/null +++ b/src/main/java/com/allo/splitbill/controller/GlobalExceptionHandler.java @@ -0,0 +1,37 @@ +package com.allo.splitbill.controller; + +import com.allo.splitbill.dto.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), List.of(ex.getMessage())); + return ResponseEntity.badRequest().body(error); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + List errors = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) + .toList(); + ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), errors); + return ResponseEntity.badRequest().body(error); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneral(Exception ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + List.of("Internal server error")); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } +} diff --git a/src/main/java/com/allo/splitbill/dto/AddExpenseRequest.java b/src/main/java/com/allo/splitbill/dto/AddExpenseRequest.java new file mode 100644 index 00000000..2cc24ca5 --- /dev/null +++ b/src/main/java/com/allo/splitbill/dto/AddExpenseRequest.java @@ -0,0 +1,54 @@ +package com.allo.splitbill.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; +import java.util.List; + +public class AddExpenseRequest { + + @NotBlank(message = "Paid by is required") + private String paidBy; + + @Positive(message = "Amount must be positive") + private BigDecimal amount; + + private String description; + + @NotEmpty(message = "Split among is required") + private List<@NotBlank String> splitAmong; + + public String getPaidBy() { + return paidBy; + } + + public void setPaidBy(String paidBy) { + this.paidBy = paidBy; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getSplitAmong() { + return splitAmong; + } + + public void setSplitAmong(List splitAmong) { + this.splitAmong = splitAmong; + } +} diff --git a/src/main/java/com/allo/splitbill/dto/CreateGroupRequest.java b/src/main/java/com/allo/splitbill/dto/CreateGroupRequest.java new file mode 100644 index 00000000..d4a15b4d --- /dev/null +++ b/src/main/java/com/allo/splitbill/dto/CreateGroupRequest.java @@ -0,0 +1,33 @@ +package com.allo.splitbill.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public class CreateGroupRequest { + + @NotBlank(message = "Group name is required") + private String name; + + @NotEmpty(message = "At least one participant is required") + @Size(min = 2, message = "At least two participants are required") + private List<@NotBlank String> participants; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getParticipants() { + return participants; + } + + public void setParticipants(List participants) { + this.participants = participants; + } +} diff --git a/src/main/java/com/allo/splitbill/dto/ErrorResponse.java b/src/main/java/com/allo/splitbill/dto/ErrorResponse.java new file mode 100644 index 00000000..a867d372 --- /dev/null +++ b/src/main/java/com/allo/splitbill/dto/ErrorResponse.java @@ -0,0 +1,29 @@ +package com.allo.splitbill.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public class ErrorResponse { + + private int status; + private List errors; + private LocalDateTime timestamp; + + public ErrorResponse(int status, List errors) { + this.status = status; + this.errors = errors; + this.timestamp = LocalDateTime.now(); + } + + public int getStatus() { + return status; + } + + public List getErrors() { + return errors; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/com/allo/splitbill/model/BillGroup.java b/src/main/java/com/allo/splitbill/model/BillGroup.java new file mode 100644 index 00000000..e4da8b9f --- /dev/null +++ b/src/main/java/com/allo/splitbill/model/BillGroup.java @@ -0,0 +1,63 @@ +package com.allo.splitbill.model; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class BillGroup { + + private String id; + private String name; + private List participants; + private List expenses; + + public BillGroup() { + this.id = UUID.randomUUID().toString(); + this.expenses = new ArrayList<>(); + } + + public BillGroup(String name, List participants) { + this(); + this.name = name; + this.participants = participants; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getParticipants() { + return participants; + } + + public void setParticipants(List participants) { + this.participants = participants; + } + + public List getExpenses() { + return expenses; + } + + public void setExpenses(List expenses) { + this.expenses = expenses; + } + + public BigDecimal getTotalExpenses() { + return expenses.stream() + .map(Expense::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} diff --git a/src/main/java/com/allo/splitbill/model/Debt.java b/src/main/java/com/allo/splitbill/model/Debt.java new file mode 100644 index 00000000..31e1a3c0 --- /dev/null +++ b/src/main/java/com/allo/splitbill/model/Debt.java @@ -0,0 +1,43 @@ +package com.allo.splitbill.model; + +import java.math.BigDecimal; + +public class Debt { + + private String from; + private String to; + private BigDecimal amount; + + public Debt() { + } + + public Debt(String from, String to, BigDecimal amount) { + this.from = from; + this.to = to; + this.amount = amount; + } + + public String getFrom() { + return from; + } + + public void setFrom(String from) { + this.from = from; + } + + public String getTo() { + return to; + } + + public void setTo(String to) { + this.to = to; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/src/main/java/com/allo/splitbill/model/Expense.java b/src/main/java/com/allo/splitbill/model/Expense.java new file mode 100644 index 00000000..896d68d9 --- /dev/null +++ b/src/main/java/com/allo/splitbill/model/Expense.java @@ -0,0 +1,77 @@ +package com.allo.splitbill.model; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public class Expense { + + private String id; + private String paidBy; + private BigDecimal amount; + private String description; + private List splitAmong; + private LocalDateTime createdAt; + + public Expense() { + this.id = UUID.randomUUID().toString(); + this.createdAt = LocalDateTime.now(); + } + + public Expense(String paidBy, BigDecimal amount, String description, List splitAmong) { + this(); + this.paidBy = paidBy; + this.amount = amount; + this.description = description; + this.splitAmong = splitAmong; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPaidBy() { + return paidBy; + } + + public void setPaidBy(String paidBy) { + this.paidBy = paidBy; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getSplitAmong() { + return splitAmong; + } + + public void setSplitAmong(List splitAmong) { + this.splitAmong = splitAmong; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/allo/splitbill/model/SettlementResponse.java b/src/main/java/com/allo/splitbill/model/SettlementResponse.java new file mode 100644 index 00000000..de90cc53 --- /dev/null +++ b/src/main/java/com/allo/splitbill/model/SettlementResponse.java @@ -0,0 +1,75 @@ +package com.allo.splitbill.model; + +import java.math.BigDecimal; +import java.util.List; + +public class SettlementResponse { + + private String groupId; + private String groupName; + private BigDecimal totalExpenses; + private List debts; + private int serviceChargePct; + private BigDecimal serviceChargeAmount; + + public SettlementResponse() { + } + + public SettlementResponse(String groupId, String groupName, BigDecimal totalExpenses, + List debts, int serviceChargePct, BigDecimal serviceChargeAmount) { + this.groupId = groupId; + this.groupName = groupName; + this.totalExpenses = totalExpenses; + this.debts = debts; + this.serviceChargePct = serviceChargePct; + this.serviceChargeAmount = serviceChargeAmount; + } + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public BigDecimal getTotalExpenses() { + return totalExpenses; + } + + public void setTotalExpenses(BigDecimal totalExpenses) { + this.totalExpenses = totalExpenses; + } + + public List getDebts() { + return debts; + } + + public void setDebts(List debts) { + this.debts = debts; + } + + public int getServiceChargePct() { + return serviceChargePct; + } + + public void setServiceChargePct(int serviceChargePct) { + this.serviceChargePct = serviceChargePct; + } + + public BigDecimal getServiceChargeAmount() { + return serviceChargeAmount; + } + + public void setServiceChargeAmount(BigDecimal serviceChargeAmount) { + this.serviceChargeAmount = serviceChargeAmount; + } +} diff --git a/src/main/java/com/allo/splitbill/repository/BillGroupRepository.java b/src/main/java/com/allo/splitbill/repository/BillGroupRepository.java new file mode 100644 index 00000000..d0d64d28 --- /dev/null +++ b/src/main/java/com/allo/splitbill/repository/BillGroupRepository.java @@ -0,0 +1,23 @@ +package com.allo.splitbill.repository; + +import com.allo.splitbill.model.BillGroup; +import org.springframework.stereotype.Repository; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Repository +public class BillGroupRepository { + + private final Map groups = new ConcurrentHashMap<>(); + + public BillGroup save(BillGroup group) { + groups.put(group.getId(), group); + return group; + } + + public Optional findById(String id) { + return Optional.ofNullable(groups.get(id)); + } +} diff --git a/src/main/java/com/allo/splitbill/service/BillGroupService.java b/src/main/java/com/allo/splitbill/service/BillGroupService.java new file mode 100644 index 00000000..9cf68f55 --- /dev/null +++ b/src/main/java/com/allo/splitbill/service/BillGroupService.java @@ -0,0 +1,55 @@ +package com.allo.splitbill.service; + +import com.allo.splitbill.model.BillGroup; +import com.allo.splitbill.model.Expense; +import com.allo.splitbill.model.SettlementResponse; +import com.allo.splitbill.repository.BillGroupRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class BillGroupService { + + private final BillGroupRepository repository; + private final SettlementService settlementService; + + public BillGroupService(BillGroupRepository repository, SettlementService settlementService) { + this.repository = repository; + this.settlementService = settlementService; + } + + public BillGroup createGroup(String name, List participants) { + BillGroup group = new BillGroup(name, List.copyOf(participants)); + return repository.save(group); + } + + public BillGroup addExpense(String groupId, String paidBy, java.math.BigDecimal amount, + String description, List splitAmong) { + BillGroup group = repository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("Group not found: " + groupId)); + + if (!group.getParticipants().contains(paidBy)) { + throw new IllegalArgumentException("Payer is not a group participant: " + paidBy); + } + for (String p : splitAmong) { + if (!group.getParticipants().contains(p)) { + throw new IllegalArgumentException("Participant not in group: " + p); + } + } + + Expense expense = new Expense(paidBy, amount, description, splitAmong); + group.getExpenses().add(expense); + return group; + } + + public BillGroup getGroup(String groupId) { + return repository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("Group not found: " + groupId)); + } + + public SettlementResponse getSettlement(String groupId) { + BillGroup group = getGroup(groupId); + return settlementService.calculate(group); + } +} diff --git a/src/main/java/com/allo/splitbill/service/ServiceChargeCalculator.java b/src/main/java/com/allo/splitbill/service/ServiceChargeCalculator.java new file mode 100644 index 00000000..edb0d6ea --- /dev/null +++ b/src/main/java/com/allo/splitbill/service/ServiceChargeCalculator.java @@ -0,0 +1,34 @@ +package com.allo.splitbill.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +public class ServiceChargeCalculator { + + private final int serviceChargePct; + + public ServiceChargeCalculator(@Value("${github.username}") String githubUsername) { + this.serviceChargePct = computeServiceChargePct(githubUsername); + } + + public int getServiceChargePct() { + return serviceChargePct; + } + + public BigDecimal calculateServiceChargeAmount(BigDecimal totalExpenses) { + return totalExpenses.multiply(BigDecimal.valueOf(serviceChargePct)) + .divide(BigDecimal.valueOf(100)); + } + + static int computeServiceChargePct(String username) { + String lower = username.toLowerCase(); + int sum = 0; + for (char c : lower.toCharArray()) { + sum += c; + } + return sum % 10; + } +} diff --git a/src/main/java/com/allo/splitbill/service/SettlementService.java b/src/main/java/com/allo/splitbill/service/SettlementService.java new file mode 100644 index 00000000..7282567a --- /dev/null +++ b/src/main/java/com/allo/splitbill/service/SettlementService.java @@ -0,0 +1,109 @@ +package com.allo.splitbill.service; + +import com.allo.splitbill.model.BillGroup; +import com.allo.splitbill.model.Debt; +import com.allo.splitbill.model.Expense; +import com.allo.splitbill.model.SettlementResponse; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class SettlementService { + + private final ServiceChargeCalculator serviceChargeCalculator; + + public SettlementService(ServiceChargeCalculator serviceChargeCalculator) { + this.serviceChargeCalculator = serviceChargeCalculator; + } + + public SettlementResponse calculate(BillGroup group) { + BigDecimal totalExpenses = group.getTotalExpenses(); + List participants = group.getParticipants(); + + Map balance = new HashMap<>(); + for (String p : participants) { + balance.put(p, BigDecimal.ZERO); + } + + for (Expense expense : group.getExpenses()) { + BigDecimal amount = expense.getAmount(); + String paidBy = expense.getPaidBy(); + List splitAmong = expense.getSplitAmong(); + + balance.merge(paidBy, amount, BigDecimal::add); + + BigDecimal share = amount.divide(BigDecimal.valueOf(splitAmong.size()), 2, RoundingMode.HALF_EVEN); + for (String person : splitAmong) { + balance.merge(person, share.negate(), BigDecimal::add); + } + } + + List debts = minimizeTransactions(balance); + + int serviceChargePct = serviceChargeCalculator.getServiceChargePct(); + BigDecimal serviceChargeAmount = serviceChargeCalculator.calculateServiceChargeAmount(totalExpenses); + + return new SettlementResponse( + group.getId(), + group.getName(), + totalExpenses, + debts, + serviceChargePct, + serviceChargeAmount + ); + } + + static List minimizeTransactions(Map balance) { + List> creditors = new ArrayList<>(); + List> debtors = new ArrayList<>(); + + for (Map.Entry entry : balance.entrySet()) { + int cmp = entry.getValue().compareTo(BigDecimal.ZERO); + if (cmp > 0) { + creditors.add(new java.util.AbstractMap.SimpleEntry<>(entry.getKey(), entry.getValue())); + } else if (cmp < 0) { + debtors.add(new java.util.AbstractMap.SimpleEntry<>(entry.getKey(), entry.getValue())); + } + } + + creditors.sort((a, b) -> b.getValue().compareTo(a.getValue())); + debtors.sort((a, b) -> a.getValue().compareTo(b.getValue())); + + List debts = new ArrayList<>(); + + int ci = 0, di = 0; + while (ci < creditors.size() && di < debtors.size()) { + Map.Entry creditor = creditors.get(ci); + Map.Entry debtor = debtors.get(di); + + BigDecimal creditAmount = creditor.getValue(); + BigDecimal debtAmount = debtor.getValue().abs(); + + int cmp = creditAmount.compareTo(debtAmount); + BigDecimal settled; + + if (cmp >= 0) { + settled = debtAmount; + creditor.setValue(creditAmount.subtract(debtAmount)); + debtor.setValue(BigDecimal.ZERO); + di++; + if (cmp == 0) ci++; + } else { + settled = creditAmount; + debtor.setValue(debtor.getValue().add(creditAmount)); + creditor.setValue(BigDecimal.ZERO); + ci++; + } + + if (settled.compareTo(BigDecimal.ZERO) > 0) { + debts.add(new Debt(debtor.getKey(), creditor.getKey(), settled)); + } + } + + return debts; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..113d6397 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,2 @@ +server.port=4110 +github.username=${GITHUB_USERNAME:placeholder_user} diff --git a/src/test/java/com/allo/splitbill/service/SettlementServiceTest.java b/src/test/java/com/allo/splitbill/service/SettlementServiceTest.java new file mode 100644 index 00000000..18a6eb1c --- /dev/null +++ b/src/test/java/com/allo/splitbill/service/SettlementServiceTest.java @@ -0,0 +1,113 @@ +package com.allo.splitbill.service; + +import com.allo.splitbill.model.BillGroup; +import com.allo.splitbill.model.Debt; +import com.allo.splitbill.model.Expense; +import com.allo.splitbill.model.SettlementResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class SettlementServiceTest { + + private SettlementService settlementService; + + @BeforeEach + void setUp() { + ServiceChargeCalculator calculator = new ServiceChargeCalculator("testuser"); + settlementService = new SettlementService(calculator); + } + + @Test + void testServiceChargeCalculator() { + assertEquals(5, ServiceChargeCalculator.computeServiceChargePct("testuser")); + assertEquals(7, ServiceChargeCalculator.computeServiceChargePct("kydevx")); + } + + @Test + void testMinimizeTransactions() { + Map balance = Map.of( + "Alice", new BigDecimal("30.00"), + "Bob", new BigDecimal("-10.00"), + "Charlie", new BigDecimal("-20.00") + ); + + List debts = SettlementService.minimizeTransactions(balance); + + assertEquals(2, debts.size()); + assertTrue(debts.stream().anyMatch(d -> + d.getFrom().equals("Bob") && d.getTo().equals("Alice") && + d.getAmount().compareTo(new BigDecimal("10.00")) == 0)); + assertTrue(debts.stream().anyMatch(d -> + d.getFrom().equals("Charlie") && d.getTo().equals("Alice") && + d.getAmount().compareTo(new BigDecimal("20.00")) == 0)); + } + + @Test + void testSettlementWithEqualSplit() { + ServiceChargeCalculator calc = new ServiceChargeCalculator("kydevx"); + SettlementService service = new SettlementService(calc); + + BillGroup group = new BillGroup("Lunch", List.of("Alice", "Bob", "Charlie")); + + group.getExpenses().add(new Expense("Alice", new BigDecimal("60.00"), "Pizza", + List.of("Alice", "Bob", "Charlie"))); + + SettlementResponse response = service.calculate(group); + + assertEquals(new BigDecimal("60.00"), response.getTotalExpenses()); + assertEquals(7, response.getServiceChargePct()); + + BigDecimal expectedCharge = new BigDecimal("60.00") + .multiply(BigDecimal.valueOf(7)) + .divide(BigDecimal.valueOf(100)); + assertEquals(expectedCharge, response.getServiceChargeAmount()); + + assertEquals(2, response.getDebts().size()); + + for (Debt d : response.getDebts()) { + if (d.getTo().equals("Alice")) { + assertTrue(d.getAmount().compareTo(BigDecimal.ZERO) > 0); + } + } + } + + @Test + void testNoDebtsWhenBalancesAreZero() { + Map balance = Map.of( + "Alice", BigDecimal.ZERO, + "Bob", BigDecimal.ZERO + ); + + List debts = SettlementService.minimizeTransactions(balance); + assertTrue(debts.isEmpty()); + } + + @Test + void testSingleDebtorSingleCreditor() { + Map balance = Map.of( + "Alice", new BigDecimal("100.00"), + "Bob", new BigDecimal("-100.00") + ); + + List debts = SettlementService.minimizeTransactions(balance); + + assertEquals(1, debts.size()); + assertEquals("Bob", debts.get(0).getFrom()); + assertEquals("Alice", debts.get(0).getTo()); + assertEquals(new BigDecimal("100.00"), debts.get(0).getAmount()); + } + + @Test + void testServiceChargeAmount_scalesWithTotal() { + ServiceChargeCalculator calc = new ServiceChargeCalculator("kydevx"); + assertEquals(new BigDecimal("7.00"), calc.calculateServiceChargeAmount(new BigDecimal("100.00"))); + assertEquals(new BigDecimal("0.70"), calc.calculateServiceChargeAmount(new BigDecimal("10.00"))); + assertEquals(BigDecimal.ZERO, calc.calculateServiceChargeAmount(BigDecimal.ZERO)); + } +}