Skip to content

Commit

Permalink
Merge pull request #1 from CamilYed/develop
Browse files Browse the repository at this point in the history
Implement Account Service with Currency Exchange and Validation
  • Loading branch information
CamilYed authored Oct 3, 2024
2 parents c1dbc5e + 87bf273 commit 1e87041
Show file tree
Hide file tree
Showing 20 changed files with 876 additions and 0 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ repositories {
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter-api:5.7.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.7.0")
testImplementation("io.strikt:strikt-core:0.34.0")
}

tasks.test {
Expand Down
41 changes: 41 additions & 0 deletions src/main/kotlin/camilyed/github/io/common/Money.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package camilyed.github.io.common

import java.math.BigDecimal
import java.math.RoundingMode

data class Money(val amount: BigDecimal, val currency: String) {

init {
require(amount >= BigDecimal.ZERO) { "Money amount must be greater than or equal to zero" }
require(currency == "PLN" || currency == "USD") {
throw UnsupportedCurrencyException("Unsupported currency: $currency")
}
amount.setScale(2, RoundingMode.HALF_EVEN)
}

operator fun plus(other: Money): Money {
require(currency == other.currency) { "Currencies must match to perform addition" }
val result = amount.add(other.amount).setScale(2, RoundingMode.HALF_EVEN)
return Money(result, currency)
}

operator fun minus(other: Money): Money {
require(currency == other.currency) { "Currencies must match to perform subtraction" }
require(amount >= other.amount) { "Insufficient funds" }
val result = amount.subtract(other.amount).setScale(2, RoundingMode.HALF_EVEN)
return Money(result, currency)
}

operator fun compareTo(other: Money): Int {
require(currency == other.currency) { "Currencies must match to compare" }
return amount.compareTo(other.amount)
}

fun isZero() = amount == BigDecimal.ZERO.setScale(2)

companion object {
fun pln(amount: BigDecimal) = Money(amount.setScale(2, RoundingMode.HALF_EVEN), "PLN")
fun usd(amount: BigDecimal) = Money(amount.setScale(2, RoundingMode.HALF_EVEN), "USD")
}
}
class UnsupportedCurrencyException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package camilyed.github.io.currencyexchangeapi.application

import camilyed.github.io.common.Money
import camilyed.github.io.currencyexchangeapi.domain.Account
import camilyed.github.io.currencyexchangeapi.domain.AccountRepository
import camilyed.github.io.currencyexchangeapi.domain.AccountSnapshot
import camilyed.github.io.currencyexchangeapi.domain.ExchangeRate
import java.math.BigDecimal

class AccountService(
private val repository: AccountRepository
) {

fun create(command: CreateAccountCommand): AccountSnapshot {
val id = repository.nextAccountId()
val account = Account(
id = id,
owner = command.owner,
balancePln = Money.pln(command.initialBalance),
balanceUsd = Money.usd(BigDecimal.ZERO)
)
repository.save(account)
return account.toSnapshot()
}

fun exchangePlnToUsd(command: ExchangePlnToUsdCommand): AccountSnapshot {
val account = repository.find(command.accountId)!!
account.exchangePlnToUsd(Money.pln(command.amount), ExchangeRate(command.exchangeRate))
return account.toSnapshot()
}

fun exchangeUsdToPln(command: ExchangeUsdToPlnCommand): AccountSnapshot {
val account = repository.find(command.accountId)!!
account.exchangeUsdToPln(Money.usd(command.amount), ExchangeRate(command.exchangeRate))
return account.toSnapshot()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package camilyed.github.io.currencyexchangeapi.application

import java.math.BigDecimal

data class CreateAccountCommand(
val owner: String,
val initialBalance: BigDecimal
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package camilyed.github.io.currencyexchangeapi.application

import java.math.BigDecimal
import java.util.UUID

data class ExchangePlnToUsdCommand(
val accountId: UUID,
val amount: BigDecimal,
val exchangeRate: BigDecimal
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package camilyed.github.io.currencyexchangeapi.application

import java.math.BigDecimal
import java.util.UUID

data class ExchangeUsdToPlnCommand(
val accountId: UUID,
val amount: BigDecimal,
val exchangeRate: BigDecimal
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package camilyed.github.io.currencyexchangeapi.domain

import camilyed.github.io.common.Money
import java.math.BigDecimal
import java.util.*

class Account(
private val id: UUID,
private val owner: String,
private var balancePln: Money = Money(BigDecimal.ZERO, "PLN"),
private var balanceUsd: Money = Money(BigDecimal.ZERO, "USD")
) {

init {
require(balancePln.currency == "PLN") { "PLN balance must be in PLN" }
require(balanceUsd.currency == "USD") { "USD balance must be in USD" }
}

fun exchangePlnToUsd(amountPln: Money, exchangeRate: ExchangeRate) {
require(!amountPln.isZero()) { throw InvalidAmountException("Amount must be greater than 0") }
require(amountPln <= balancePln) { throw InsufficientFundsException("Insufficient PLN balance") }

val amountUsd = Money(exchangeRate.convertFromPln(amountPln.amount), "USD")
balancePln -= amountPln
balanceUsd += amountUsd
}

fun exchangeUsdToPln(amountUsd: Money, exchangeRate: ExchangeRate) {
require(!amountUsd.isZero()) { throw InvalidAmountException("Amount must be greater than 0") }
require(amountUsd <= balanceUsd) { throw InsufficientFundsException("Insufficient USD balance") }

val amountPln = Money(exchangeRate.convertToPln(amountUsd.amount), "PLN")
balanceUsd -= amountUsd
balancePln += amountPln
}

fun toSnapshot(): AccountSnapshot {
return AccountSnapshot(
id = id,
owner = owner,
balancePln = balancePln.amount,
balanceUsd = balanceUsd.amount
)
}
}

data class AccountSnapshot(
val id: UUID,
val owner: String,
val balancePln: BigDecimal,
val balanceUsd: BigDecimal
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package camilyed.github.io.currencyexchangeapi.domain

import java.util.UUID

interface AccountRepository {

fun nextAccountId(): UUID

fun save(account: Account)

fun find(id: UUID): Account?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package camilyed.github.io.currencyexchangeapi.domain

class InsufficientFundsException(message: String) : RuntimeException(message)

class InvalidAmountException(message: String) : RuntimeException(message)

class InvalidExchangeRateException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package camilyed.github.io.currencyexchangeapi.domain

import java.math.BigDecimal
import java.math.RoundingMode

data class ExchangeRate(val rate: BigDecimal) {

init {
if (rate <= BigDecimal.ZERO) {
throw InvalidExchangeRateException("Exchange rate must be greater than 0")
}
}

fun convertFromPln(amountPln: BigDecimal): BigDecimal {
return amountPln.divide(rate, 2, RoundingMode.HALF_EVEN)
}

fun convertToPln(amountUsd: BigDecimal): BigDecimal {
return amountUsd.multiply(rate).setScale(2, RoundingMode.HALF_EVEN)
}
}
103 changes: 103 additions & 0 deletions src/test/kotlin/camilyed/github/io/common/MoneyTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package camilyed.github.io.common

import org.junit.jupiter.api.Test
import strikt.api.expectCatching
import strikt.api.expectThat
import strikt.assertions.isA
import strikt.assertions.isEqualTo
import strikt.assertions.isFailure
import strikt.assertions.message
import java.math.BigDecimal

class MoneyTest {

@Test
fun `should create money with valid amount and currency`() {
val money = Money(BigDecimal("100.00"), "USD")

expectThat(money.amount).isEqualTo(BigDecimal("100.00"))
expectThat(money.currency).isEqualTo("USD")
}

@Test
fun `should throw exception when creating money with negative amount`() {
expectCatching {
Money(BigDecimal("-100.00"), "USD")
}.isFailure()
.isA<IllegalArgumentException>()
.message.isEqualTo("Money amount must be greater than or equal to zero")
}

@Test
fun `should throw exception when creating money with empty currency`() {
expectCatching {
Money(BigDecimal("100.00"), "")
}.isFailure()
.isA<UnsupportedCurrencyException>()
.message.isEqualTo("Unsupported currency: ")
}

@Test
fun `should add two money objects with same currency`() {
val money1 = Money(BigDecimal("100.00"), "USD")
val money2 = Money(BigDecimal("50.00"), "USD")

val result = money1 + money2

expectThat(result.amount).isEqualTo(BigDecimal("150.00"))
expectThat(result.currency).isEqualTo("USD")
}

@Test
fun `should subtract two money objects with same currency`() {
val money1 = Money(BigDecimal("100.00"), "USD")
val money2 = Money(BigDecimal("50.00"), "USD")

val result = money1 - money2

expectThat(result.amount).isEqualTo(BigDecimal("50.00"))
expectThat(result.currency).isEqualTo("USD")
}

@Test
fun `should throw exception when subtracting with insufficient funds`() {
val money1 = Money(BigDecimal("50.00"), "USD")
val money2 = Money(BigDecimal("100.00"), "USD")

expectCatching {
money1 - money2
}.isFailure()
.isA<IllegalArgumentException>()
.message.isEqualTo("Insufficient funds")
}

@Test
fun `should round result to 2 decimal places when subtracting`() {
val money1 = Money(BigDecimal("100.555"), "USD")
val money2 = Money(BigDecimal("50.123"), "USD")

val result = money1 - money2

expectThat(result.amount).isEqualTo(BigDecimal("50.43"))
}

@Test
fun `should round using HALF_EVEN rounding mode when adding`() {
val money1 = Money(BigDecimal("100.505"), "USD")
val money2 = Money(BigDecimal("50.505"), "USD")

val result = money1 + money2

expectThat(result.amount).isEqualTo(BigDecimal("151.01"))
}

@Test
fun `should round using HALF_EVEN rounding mode when subtracting`() {
val money1 = Money(BigDecimal("100.505"), "USD")
val money2 = Money(BigDecimal("50.505"), "USD")

val result = money1 - money2

expectThat(result.amount).isEqualTo(BigDecimal("50.00"))
}
}
Loading

0 comments on commit 1e87041

Please sign in to comment.