-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from CamilYed/develop
Implement Account Service with Currency Exchange and Validation
- Loading branch information
Showing
20 changed files
with
876 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
37 changes: 37 additions & 0 deletions
37
src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/AccountService.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
8 changes: 8 additions & 0 deletions
8
src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/CreateAccountCommand.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
10 changes: 10 additions & 0 deletions
10
...main/kotlin/camilyed/github/io/currencyexchangeapi/application/ExchangePlnToUsdCommand.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
10 changes: 10 additions & 0 deletions
10
...main/kotlin/camilyed/github/io/currencyexchangeapi/application/ExchangeUsdToPlnCommand.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
52 changes: 52 additions & 0 deletions
52
src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/Account.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
12 changes: 12 additions & 0 deletions
12
src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/AccountRepository.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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? | ||
} |
7 changes: 7 additions & 0 deletions
7
src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/Exceptions.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
21 changes: 21 additions & 0 deletions
21
src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/ExchangeRate.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")) | ||
} | ||
} |
Oops, something went wrong.