Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,23 @@ curl http://localhost:8081/api/v1/accounts/<account-uuid>/balance \
-H "X-API-Key: sk_test_devkey00000000000000000000"
```

```json
{
"accountId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"currency": "USD",
"amount": 1000.00,
"normalBalance": "DEBIT",
"computedAt": "2025-01-01T00:00:00Z",
"onChainBalances": [
{ "token": "USDC", "amount": 2.50 }
]
}
```

Point-in-time balance: append `?asOf=2025-01-01T00:00:00Z`.

`amount` is the fiat balance in the account's declared currency — it only sums `FiatEntry` lines. On-chain (stablecoin) entries posted to the same account are reported separately in `onChainBalances`, one entry per token net across all chains, and are never combined with the fiat `amount`: a token amount and a fiat amount are not fungible units. An account only receiving fiat entries returns `onChainBalances: []`.

### Kotlin SDK

```kotlin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ data class BalanceResponse(
val normalBalance: EntryType,
@Schema(description = "Timestamp when the balance was computed")
val computedAt: Instant,
@Schema(description = "Net on-chain balance per stablecoin token, summed across all chains — never combined with the fiat amount above")
val onChainBalances: List<OnChainBalanceResponse> = emptyList(),
) {
companion object {
fun from(balance: Balance) =
Expand All @@ -28,6 +30,7 @@ data class BalanceResponse(
amount = balance.amount.value,
normalBalance = balance.normalBalance,
computedAt = balance.computedAt,
onChainBalances = balance.onChainBalances.map(OnChainBalanceResponse::from),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package finance.idem.api.ledger

import finance.idem.core.StablecoinToken
import finance.idem.core.ledger.OnChainBalance
import io.swagger.v3.oas.annotations.media.Schema
import java.math.BigDecimal

data class OnChainBalanceResponse(
@Schema(description = "Stablecoin token")
val token: StablecoinToken,
@Schema(description = "Net on-chain balance for this token, summed across all chains")
val amount: BigDecimal,
) {
companion object {
fun from(balance: OnChainBalance) =
OnChainBalanceResponse(
token = balance.token,
amount = balance.amount.value,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import finance.idem.core.EntryType
import finance.idem.core.FiatCurrency
import finance.idem.core.MonetaryAmount
import finance.idem.core.PaymentRail
import finance.idem.core.StablecoinToken
import finance.idem.core.TenantId
import finance.idem.core.TransactionId
import finance.idem.core.ledger.Account
import finance.idem.core.ledger.AccountType
import finance.idem.core.ledger.JournalLine
import finance.idem.core.ledger.OnChainBalance
import finance.idem.core.monetary.FiatEntry
import org.junit.jupiter.api.Test
import org.mockito.kotlin.any
Expand Down Expand Up @@ -82,14 +84,17 @@ class AccountControllerTest {
createdBy = "test-key",
)

private fun balanceFor(accountId: UUID) =
Balance(
accountId = AccountId(accountId),
currency = FiatCurrency.BRL,
amount = MonetaryAmount.of("350.00"),
normalBalance = EntryType.DEBIT,
computedAt = Instant.parse("2026-05-28T12:00:00Z"),
)
private fun balanceFor(
accountId: UUID,
onChainBalances: List<OnChainBalance> = emptyList(),
) = Balance(
accountId = AccountId(accountId),
currency = FiatCurrency.BRL,
amount = MonetaryAmount.of("350.00"),
normalBalance = EntryType.DEBIT,
computedAt = Instant.parse("2026-05-28T12:00:00Z"),
onChainBalances = onChainBalances,
)

private fun lineFor(
accountId: UUID,
Expand Down Expand Up @@ -140,6 +145,23 @@ class AccountControllerTest {
jsonPath("$.currency") { value("BRL") }
jsonPath("$.amount") { value(350.00) }
jsonPath("$.normalBalance") { value("DEBIT") }
jsonPath("$.onChainBalances") { isEmpty() }
}
}

@Test
fun `balance with on-chain entries returns per-token breakdown`() {
val onChainBalances = listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("2.50")))
whenever(getBalanceUseCase.execute(any())).thenReturn(Result.success(balanceFor(accountId, onChainBalances)))

mockMvc
.get("/api/v1/accounts/$accountId/balance") {
with(SecurityMockMvcRequestPostProcessors.authentication(mockAuth("ACCOUNTS_READ")))
}.andExpect {
status { isOk() }
jsonPath("$.amount") { value(350.00) }
jsonPath("$.onChainBalances[0].token") { value("USDC") }
jsonPath("$.onChainBalances[0].amount") { value(2.50) }
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package finance.idem.api.ledger

import finance.idem.application.ledger.Balance
import finance.idem.core.AccountId
import finance.idem.core.EntryType
import finance.idem.core.FiatCurrency
import finance.idem.core.MonetaryAmount
import finance.idem.core.StablecoinToken
import finance.idem.core.ledger.OnChainBalance
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.Instant
import java.util.UUID
import kotlin.test.assertEquals

class BalanceResponseTest {
@Test
fun `from produces correct dto including on-chain breakdown`() {
val accountId = AccountId(UUID.randomUUID())
val computedAt = Instant.parse("2026-05-28T12:00:00Z")
val balance =
Balance(
accountId = accountId,
currency = FiatCurrency.BRL,
amount = MonetaryAmount.of("350.00"),
normalBalance = EntryType.DEBIT,
computedAt = computedAt,
onChainBalances = listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("2.50"))),
)

val dto = BalanceResponse.from(balance)

assertEquals(accountId.value, dto.accountId)
assertEquals(FiatCurrency.BRL, dto.currency)
assertEquals(BigDecimal("350.00"), dto.amount)
assertEquals(EntryType.DEBIT, dto.normalBalance)
assertEquals(computedAt, dto.computedAt)
assertEquals(listOf(OnChainBalanceResponse(StablecoinToken.USDC, BigDecimal("2.50"))), dto.onChainBalances)

val fullCopy = dto.copy()
val partialCopy = dto.copy(amount = BigDecimal("999.00"))
assertEquals(dto, fullCopy)
assert(dto != partialCopy)
assert(dto != null)
assert(dto.toString().contains("BRL"))
assertEquals(dto.hashCode(), fullCopy.hashCode())

val (respAccountId, currency, amount) = dto
assertEquals(accountId.value, respAccountId)
assertEquals(FiatCurrency.BRL, currency)
assertEquals(BigDecimal("350.00"), amount)
}

@Test
fun `from defaults onChainBalances to empty list when balance has none`() {
val balance =
Balance(
accountId = AccountId(UUID.randomUUID()),
currency = FiatCurrency.USD,
amount = MonetaryAmount.of("0"),
normalBalance = EntryType.DEBIT,
computedAt = Instant.now(),
)

assertEquals(emptyList(), BalanceResponse.from(balance).onChainBalances)
}

@Test
fun `onChainBalances defaults to empty list when omitted from the constructor`() {
val dto = BalanceResponse(UUID.randomUUID(), FiatCurrency.USD, BigDecimal("0"), EntryType.DEBIT, Instant.now())
assertEquals(emptyList(), dto.onChainBalances)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import finance.idem.core.AccountId
import finance.idem.core.EntryType
import finance.idem.core.FiatCurrency
import finance.idem.core.MonetaryAmount
import finance.idem.core.ledger.OnChainBalance
import java.time.Instant

data class Balance(
Expand All @@ -12,4 +13,5 @@ data class Balance(
val amount: MonetaryAmount,
val normalBalance: EntryType,
val computedAt: Instant,
val onChainBalances: List<OnChainBalance> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import finance.idem.core.AccountId
import finance.idem.core.EntryType
import finance.idem.core.FiatCurrency
import finance.idem.core.MonetaryAmount
import finance.idem.core.StablecoinToken
import finance.idem.core.TenantId
import finance.idem.core.ledger.OnChainBalance
import org.junit.jupiter.api.Test
import java.time.Instant
import kotlin.test.assertEquals
Expand Down Expand Up @@ -32,15 +34,23 @@ class GetBalanceModelsTest {

@Test
fun `Balance holds all fields`() {
val balance = Balance(accountId, FiatCurrency.BRL, MonetaryAmount.of("500"), EntryType.DEBIT, now)
val onChainBalances = listOf(OnChainBalance(StablecoinToken.USDC, MonetaryAmount.of("125")))
val balance = Balance(accountId, FiatCurrency.BRL, MonetaryAmount.of("500"), EntryType.DEBIT, now, onChainBalances)
assertEquals(accountId, balance.accountId)
assertEquals(FiatCurrency.BRL, balance.currency)
assertEquals(MonetaryAmount.of("500"), balance.amount)
assertEquals(EntryType.DEBIT, balance.normalBalance)
assertEquals(now, balance.computedAt)
assertEquals(onChainBalances, balance.onChainBalances)
assertEquals(balance, balance.copy())
}

@Test
fun `Balance onChainBalances defaults to empty list`() {
val balance = Balance(accountId, FiatCurrency.BRL, MonetaryAmount.of("500"), EntryType.DEBIT, now)
assertEquals(emptyList(), balance.onChainBalances)
}

@Test
fun `GetBalanceError AccountNotFound carries accountId and message`() {
val error = BalanceAccountNotFound(accountId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package finance.idem.core.ledger

import finance.idem.core.EntryType
import finance.idem.core.MonetaryAmount
import finance.idem.core.StablecoinToken
import finance.idem.core.monetary.FiatEntry
import finance.idem.core.monetary.OnChainEntry

object BalanceCalculator {
fun compute(
Expand All @@ -29,4 +31,38 @@ object BalanceCalculator {
EntryType.CREDIT -> credits - debits
}
}

// Net per StablecoinToken, across all chains — never combined with the fiat balance
// above, since a token amount and a fiat amount are not fungible units.
fun computeOnChain(
account: Account,
transactions: List<Transaction>,
): List<OnChainBalance> {
val debits = mutableMapOf<StablecoinToken, MonetaryAmount>()
val credits = mutableMapOf<StablecoinToken, MonetaryAmount>()

for (tx in transactions) {
for (line in tx.lines) {
if (line.accountId != account.id) continue
val entry = line.monetaryEntry
if (entry !is OnChainEntry) continue
val byToken = if (line.entryType == EntryType.DEBIT) debits else credits
byToken[entry.token] = (byToken[entry.token] ?: MonetaryAmount.ZERO) + entry.amount
}
}

return (debits.keys + credits.keys)
.distinct()
.sortedBy { it.name }
.map { token ->
val d = debits[token] ?: MonetaryAmount.ZERO
val c = credits[token] ?: MonetaryAmount.ZERO
val net =
when (account.normalBalance) {
EntryType.DEBIT -> d - c
EntryType.CREDIT -> c - d
}
OnChainBalance(token, net)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package finance.idem.core.ledger

import finance.idem.core.MonetaryAmount
import finance.idem.core.StablecoinToken

data class OnChainBalance(
val token: StablecoinToken,
val amount: MonetaryAmount,
)
Loading