refactor/OBPv510 add BankAccountBalance class and related API endpoints for balance management

This commit is contained in:
Hongwei 2025-04-24 14:47:42 +02:00
parent 00bd381310
commit 470632595e
12 changed files with 536 additions and 37 deletions

View File

@ -1129,7 +1129,7 @@ object ToSchemify {
CustomerAccountLink,
TransactionIdMapping,
RegulatedEntityAttribute,
BankAccountBalance
code.bankaccountbalance.BankAccountBalance
)
// start grpc server

View File

@ -5669,7 +5669,22 @@ object SwaggerDefinitionsJSON {
lazy val regulatedEntityAttributesJsonV510 = RegulatedEntityAttributesJsonV510(
List(regulatedEntityAttributeResponseJsonV510)
)
lazy val bankAccountBalanceRequestJsonV510 = BankAccountBalanceRequestJsonV510(
balance_type = balanceTypeExample.value,
balance_amount = balanceAmountExample.value
)
lazy val bankAccountBalanceResponseJsonV510 = BankAccountBalanceResponseJsonV510(
balance_id = balanceIdExample.value,
account_id = accountIdExample.value,
balance_type = balanceTypeExample.value,
balance_amount = balanceAmountExample.value
)
lazy val bankAccountBalancesJsonV510 = BankAccountBalancesJsonV510(
balances = List(bankAccountBalanceResponseJsonV510)
)
//The common error or success format.
//Just some helper format to use in Json
case class NotSupportedYet()

View File

@ -1009,7 +1009,23 @@ object ApiRole extends MdcLoggable{
case class CanGetBankLevelEndpointTag(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetBankLevelEndpointTag = CanGetBankLevelEndpointTag()
// BankAccountBalance roles
case class CanCreateBankAccountBalance(requiresBankId: Boolean = false) extends ApiRole
lazy val canCreateBankAccountBalance = CanCreateBankAccountBalance()
case class CanGetBankAccountBalance(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetBankAccountBalance = CanGetBankAccountBalance()
case class CanGetBankAccountBalances(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetBankAccountBalances = CanGetBankAccountBalances()
case class CanUpdateBankAccountBalance(requiresBankId: Boolean = false) extends ApiRole
lazy val canUpdateBankAccountBalance = CanUpdateBankAccountBalance()
case class CanDeleteBankAccountBalance(requiresBankId: Boolean = false) extends ApiRole
lazy val canDeleteBankAccountBalance = CanDeleteBankAccountBalance()
case class CanCreateHistoricalTransactionAtBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateHistoricalTransactionAtBank = CanCreateHistoricalTransactionAtBank()

View File

@ -67,6 +67,7 @@ object ApiTag {
val apiTagMXOpenFinance = ResourceDocTag("MXOpenFinance")
val apiTagAggregateMetrics = ResourceDocTag("Aggregate-Metrics")
val apiTagSystemIntegrity = ResourceDocTag("System-Integrity")
val apiTagBalance = ResourceDocTag("Balance")
val apiTagWebhook = ResourceDocTag("Webhook")
val apiTagMockedData = ResourceDocTag("Mocked-Data")
val apiTagConsent = ResourceDocTag("Consent")

View File

@ -298,6 +298,12 @@ object ExampleValue {
lazy val accountTypeExample = ConnectorField("AC","A short code that represents the type of the account as provided by the bank.")
lazy val balanceAmountExample = ConnectorField("50.89", "The balance on the account.")
lazy val balanceTypeExample = ConnectorField("openingBooked", "The balance type.")
glossaryItems += makeGlossaryItem("balance_type", balanceTypeExample)
lazy val balanceIdExample = ConnectorField("7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", "A string that MUST uniquely identify the Account Balance on this OBP instance, can be used in all cache.")
glossaryItems += makeGlossaryItem("balance_id", balanceIdExample)
lazy val amountExample = ConnectorField("10.12", "The balance on the account.")

View File

@ -0,0 +1,91 @@
package code.api.util.newstyle
import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail}
import code.api.util.ErrorMessages.{InvalidConnectorResponse}
import code.api.util.CallContext
import code.bankaccountbalance.{BankAccountBalanceX}
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model.{AccountId, BankAccountBalanceTrait}
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.BalanceId
object BankAccountBalanceNewStyle {
def getBankAccountBalances(
accountId: AccountId,
callContext: Option[CallContext]
): OBPReturnType[List[BankAccountBalanceTrait]] = {
BankAccountBalanceX.bankAccountBalanceProvider.vend.getBankAccountBalances(accountId).map {
result =>
(
unboxFullOrFail(
result,
callContext,
s"$InvalidConnectorResponse ${nameOf(getBankAccountBalances _)}",
404),
callContext
)
}
}
def getBankAccountBalanceById(
balanceId: BalanceId,
callContext: Option[CallContext]
): OBPReturnType[BankAccountBalanceTrait] = {
BankAccountBalanceX.bankAccountBalanceProvider.vend.getBankAccountBalanceById(balanceId).map {
result =>
(
unboxFullOrFail(
result,
callContext,
s"$InvalidConnectorResponse ${nameOf(getBankAccountBalanceById _)}",
404),
callContext
)
}
}
def createOrUpdateBankAccountBalance(
balanceId: Option[BalanceId],
accountId: AccountId,
balanceType: String,
balanceAmount: BigDecimal,
callContext: Option[CallContext]
): OBPReturnType[BankAccountBalanceTrait] = {
BankAccountBalanceX.bankAccountBalanceProvider.vend.createOrUpdateBankAccountBalance(
balanceId,
accountId,
balanceType,
balanceAmount
).map {
result =>
(
unboxFullOrFail(
result,
callContext,
s"$InvalidConnectorResponse ${nameOf(createOrUpdateBankAccountBalance _)}",
400),
callContext
)
}
}
def deleteBankAccountBalance(
balanceId: BalanceId,
callContext: Option[CallContext]
): OBPReturnType[Boolean] = {
BankAccountBalanceX.bankAccountBalanceProvider.vend.deleteBankAccountBalance(balanceId).map {
result =>
(
unboxFullOrFail(
result,
callContext,
s"$InvalidConnectorResponse ${nameOf(deleteBankAccountBalance _)}",
400),
callContext
)
}
}
}

View File

@ -188,6 +188,221 @@ trait APIMethods510 {
}
}
staticResourceDocs += ResourceDoc(
createBankAccountBalance,
implementedInApiVersion,
nameOf(createBankAccountBalance),
"POST",
"/accounts/ACCOUNT_ID/balances",
"Create Bank Account Balance",
s"""Create a new Balance for a Bank Account.
|
|${userAuthenticationMessage(true)}
|
|""",
bankAccountBalanceRequestJsonV510,
bankAccountBalanceResponseJsonV510,
List(
$UserNotLoggedIn,
UserHasMissingRoles,
InvalidJsonFormat,
UnknownError
),
List(apiTagAccount, apiTagBalance),
Some(List(canCreateBankAccountBalance))
)
lazy val createBankAccountBalance: OBPEndpoint = {
case "accounts" :: AccountId(accountId) :: "balances" :: Nil JsonPost json -> _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- SS.user
postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $BankAccountBalanceRequestJsonV510 ", 400, callContext) {
json.extract[BankAccountBalanceRequestJsonV510]
}
balanceAmount <- NewStyle.function.tryons(s"$InvalidNumber Current balance_amount is ${postedData.balance_amount}" , 400, cc.callContext) {
BigDecimal(postedData.balance_amount)
}
(balance, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.createOrUpdateBankAccountBalance(
balanceId = None,
accountId = accountId,
balanceType = postedData.balance_type,
balanceAmount = balanceAmount,
callContext = cc.callContext
)
} yield {
(JSONFactory510.createBankAccountBalanceJson(balance), HttpCode.`201`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
getBankAccountBalanceById,
implementedInApiVersion,
nameOf(getBankAccountBalanceById),
"GET",
"/accounts/ACCOUNT_ID/balances/BALANCE_ID",
"Get Bank Account Balance By ID",
s"""Get a specific Bank Account Balance by its BALANCE_ID.
|
|${userAuthenticationMessage(true)}
|
|""",
EmptyBody,
bankAccountBalanceResponseJsonV510,
List(
$UserNotLoggedIn,
UserHasMissingRoles,
UnknownError
),
List(apiTagAccount, apiTagBalance),
Some(List(canGetBankAccountBalance))
)
lazy val getBankAccountBalanceById: OBPEndpoint = {
case "accounts" :: AccountId(accountId) :: "balances" :: BalanceId(balanceId) :: Nil JsonGet _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- SS.user
(balance, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalanceById(
balanceId,
callContext
)
} yield {
(JSONFactory510.createBankAccountBalanceJson(balance), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
getAllBankAccountBalances,
implementedInApiVersion,
nameOf(getAllBankAccountBalances),
"GET",
"/accounts/ACCOUNT_ID/balances",
"Get All Bank Account Balances",
s"""Get all Balances for a Bank Account.
|
|${userAuthenticationMessage(true)}
|
|""",
EmptyBody,
bankAccountBalancesJsonV510,
List(
$UserNotLoggedIn,
UserHasMissingRoles,
UnknownError
),
List(apiTagAccount, apiTagBalance),
Some(List(canGetBankAccountBalances))
)
lazy val getAllBankAccountBalances: OBPEndpoint = {
case "accounts" :: AccountId(accountId) :: "balances" :: Nil JsonGet _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- SS.user
(balances, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.getBankAccountBalances(
accountId,
callContext
)
} yield {
(JSONFactory510.createBankAccountBalancesJson(balances), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
updateBankAccountBalance,
implementedInApiVersion,
nameOf(updateBankAccountBalance),
"PUT",
"/accounts/ACCOUNT_ID/balances/BALANCE_ID",
"Update Bank Account Balance",
s"""Update an existing Bank Account Balance specified by BALANCE_ID.
|
|${userAuthenticationMessage(true)}
|
|""",
bankAccountBalanceRequestJsonV510,
bankAccountBalanceResponseJsonV510,
List(
$UserNotLoggedIn,
UserHasMissingRoles,
InvalidJsonFormat,
UnknownError
),
List(apiTagAccount, apiTagBalance),
Some(List(canUpdateBankAccountBalance))
)
lazy val updateBankAccountBalance: OBPEndpoint = {
case "accounts" :: AccountId(accountId) :: "balances" :: BalanceId(balanceId) :: Nil JsonPut json -> _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- SS.user
postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the BankAccountBalanceRequestJsonV510 ", 400, callContext) {
json.extract[BankAccountBalanceRequestJsonV510]
}
balanceAmount <- NewStyle.function.tryons(s"$InvalidNumber Current balance_amount is ${postedData.balance_amount}" , 400, cc.callContext) {
BigDecimal(postedData.balance_amount)
}
(balance, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.createOrUpdateBankAccountBalance(
balanceId = Some(balanceId),
accountId = accountId,
balanceType = postedData.balance_type,
balanceAmount = balanceAmount,
callContext = callContext
)
} yield {
(JSONFactory510.createBankAccountBalanceJson(balance), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
deleteBankAccountBalance,
implementedInApiVersion,
nameOf(deleteBankAccountBalance),
"DELETE",
"/accounts/ACCOUNT_ID/balances/BALANCE_ID",
"Delete Bank Account Balance",
s"""Delete a Bank Account Balance specified by BALANCE_ID.
|
|${userAuthenticationMessage(true)}
|
|""",
EmptyBody,
EmptyBody,
List(
$UserNotLoggedIn,
UserHasMissingRoles,
UnknownError
),
List(apiTagAccount, apiTagBalance),
Some(List(canDeleteBankAccountBalance))
)
lazy val deleteBankAccountBalance: OBPEndpoint = {
case "accounts" :: AccountId(accountId) :: "balances" :: BalanceId(balanceId) :: Nil JsonDelete _ => {
cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- SS.user
(deleted, callContext) <- code.api.util.newstyle.BankAccountBalanceNewStyle.deleteBankAccountBalance(
balanceId,
callContext
)
} yield {
(Full(deleted), HttpCode.`204`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
createRegulatedEntity,

View File

@ -611,6 +611,23 @@ case class SyncExternalUserJson(user_id: String)
case class UserValidatedJson(is_validated: Boolean)
case class BankAccountBalanceRequestJsonV510(
balance_type: String,
balance_amount: String
)
case class BankAccountBalanceResponseJsonV510(
account_id: String,
balance_id: String,
balance_type: String,
balance_amount: String
)
case class BankAccountBalancesJsonV510(
balances: List[BankAccountBalanceResponseJsonV510]
)
object JSONFactory510 extends CustomJsonFormats {
def createTransactionRequestJson(tr : TransactionRequest, transactionRequestAttributes: List[TransactionRequestAttributeTrait] ) : TransactionRequestJsonV510 = {
@ -651,7 +668,7 @@ object JSONFactory510 extends CustomJsonFormats {
createTransactionRequestJson(transactionRequest, transactionRequestAttributes)
))
}
def createViewJson(view: View): CustomViewJsonV510 = {
val alias =
if (view.usePublicAliasIfOneExists)
@ -1073,7 +1090,7 @@ object JSONFactory510 extends CustomJsonFormats {
logo_url = if (c.logoUrl.get == null || c.logoUrl.get.isEmpty ) null else Some(c.logoUrl.get)
)
}
def createConsumersJson(consumers:List[Consumer]) = {
ConsumersJsonV510(consumers.map(createConsumerJSON(_,None)))
}
@ -1100,7 +1117,7 @@ object JSONFactory510 extends CustomJsonFormats {
agent_number = agent.number
)))
}
def createRegulatedEntityAttributeJson(attribute: RegulatedEntityAttributeTrait): RegulatedEntityAttributeResponseJsonV510 = {
RegulatedEntityAttributeResponseJsonV510(
regulated_entity_id = attribute.regulatedEntityId.value,
@ -1119,7 +1136,20 @@ object JSONFactory510 extends CustomJsonFormats {
attributes.map(createRegulatedEntityAttributeJson)
)
}
def createBankAccountBalanceJson(balance: BankAccountBalanceTrait): BankAccountBalanceResponseJsonV510 = {
BankAccountBalanceResponseJsonV510(
balance_id = balance.balanceId.value,
account_id = balance.accountId.value,
balance_type = balance.balanceType,
balance_amount = balance.balanceAmount.toString
)
}
def createBankAccountBalancesJson(balances: List[BankAccountBalanceTrait]): BankAccountBalancesJsonV510 = {
BankAccountBalancesJsonV510(
balances.map(createBankAccountBalanceJson)
)
}
}

View File

@ -0,0 +1,33 @@
package code.bankaccountbalance
import code.model.dataAccess.MappedBankAccount
import code.util.{Helper, MappedUUID}
import com.openbankproject.commons.model.{AccountId, BalanceId, BankAccountBalanceTrait}
import net.liftweb.common.{Box, Empty, Full, Logger}
import net.liftweb.mapper._
class BankAccountBalance extends BankAccountBalanceTrait with LongKeyedMapper[BankAccountBalance] with CreatedUpdated with IdPK {
override def getSingleton = BankAccountBalance
object BalanceId_ extends MappedUUID(this)
// object AccountId_ extends MappedLongForeignKey(this, MappedBankAccount)
object AccountId_ extends MappedUUID(this)
object BalanceType extends MappedString(this, 255)
//this is the smallest unit of currency! eg. cents, yen, pence, øre, etc.
object BalanceAmount extends MappedLong(this)
val foreignMappedBankAccount: Box[MappedBankAccount] = code.model.dataAccess.MappedBankAccount.find(
By(MappedBankAccount.theAccountId, AccountId_.get)
)
val foreignMappedBankAccountCurrency = foreignMappedBankAccount.map(_.currency).getOrElse("EUR")
override def balanceId: BalanceId = BalanceId(BalanceId_.get)
override def accountId: AccountId = AccountId(AccountId_.get)
override def balanceType: String = BalanceType.get
override def balanceAmount: BigDecimal = Helper.smallestCurrencyUnitToBigDecimal(BalanceAmount.get, foreignMappedBankAccountCurrency)
}
object BankAccountBalance extends BankAccountBalance with LongKeyedMetaMapper[BankAccountBalance] {}

View File

@ -0,0 +1,112 @@
package code.bankaccountbalance
import code.model.dataAccess.MappedBankAccount
import code.util.{Helper, MappedUUID}
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model.{AccountId, BankAccountBalanceTrait}
import net.liftweb.common.{Box, Empty, Full}
import net.liftweb.mapper._
import net.liftweb.util.Helpers.tryo
import net.liftweb.util.SimpleInjector
import com.openbankproject.commons.model.BalanceId
import scala.concurrent.Future
object BankAccountBalanceX extends SimpleInjector {
val bankAccountBalanceProvider = new Inject(buildOne _) {}
def buildOne: BankAccountBalanceProviderTrait = MappedBankAccountBalanceProvider
// Helper to get the count out of an option
def countOfBankAccountBalance(listOpt: Option[List[BankAccountBalance]]): Int = {
val count = listOpt match {
case Some(list) => list.size
case None => 0
}
count
}
}
trait BankAccountBalanceProviderTrait {
def getBankAccountBalances(accountId: AccountId): Future[Box[List[BankAccountBalance]]]
def getBankAccountBalanceById(balanceId: BalanceId): Future[Box[BankAccountBalance]]
def createOrUpdateBankAccountBalance(
balanceId: Option[BalanceId],
accountId: AccountId,
balanceType: String,
balanceAmount: BigDecimal): Future[Box[BankAccountBalance]]
def deleteBankAccountBalance(balanceId: BalanceId): Future[Box[Boolean]]
}
object MappedBankAccountBalanceProvider extends BankAccountBalanceProviderTrait {
override def getBankAccountBalances(accountId: AccountId): Future[Box[List[BankAccountBalance]]] = Future {
tryo{
BankAccountBalance.findAll(
By(BankAccountBalance.AccountId_,accountId.value)
)}
}
override def getBankAccountBalanceById(balanceId: BalanceId): Future[Box[BankAccountBalance]] = Future {
// Find a balance by its ID
BankAccountBalance.find(
By(BankAccountBalance.BalanceId_, balanceId.value)
)
}
override def createOrUpdateBankAccountBalance(
balanceId: Option[BalanceId],
accountId: AccountId,
balanceType: String,
balanceAmount: BigDecimal
): Future[Box[BankAccountBalance]] = Future {
// Get the MappedBankAccount for the given account ID
val mappedBankAccount = code.model.dataAccess.MappedBankAccount
.find(
By(MappedBankAccount.theAccountId, accountId.value)
)
mappedBankAccount match {
case Full(account) =>
balanceId match {
case Some(id) =>
BankAccountBalance.find(
By(BankAccountBalance.BalanceId_, id.value)
) match {
case Full(balance) =>
tryo {
balance
.AccountId_(accountId.value)
.BalanceType(balanceType)
.BalanceAmount(Helper.convertToSmallestCurrencyUnits(balanceAmount, account.currency))
.saveMe()
}
case _ => Empty
}
case _ =>
tryo {
BankAccountBalance.create
.AccountId_(accountId.value)
.BalanceType(balanceType)
.BalanceAmount(Helper.convertToSmallestCurrencyUnits(balanceAmount, account.currency))
.saveMe()
}
}
case _ => Empty
}
}
override def deleteBankAccountBalance(balanceId: BalanceId): Future[Box[Boolean]] = Future {
// Delete a balance by its ID
BankAccountBalance.find(
By(BankAccountBalance.BalanceId_, balanceId.value)
).map(_.delete_!)
}
}

View File

@ -1,29 +0,0 @@
package code.model.dataAccess
import com.openbankproject.commons.model._
import net.liftweb.common.Box
import net.liftweb.mapper._
import code.util.Helper
class BankAccountBalance extends BankAccountBalanceTrait with LongKeyedMapper[BankAccountBalance] with CreatedUpdated with IdPK{
override def getSingleton = BankAccountBalance
object AccountId_ extends MappedLongForeignKey(this, MappedBankAccount)
object BalanceType extends MappedString(this, 255)
//this is the smallest unit of currency! eg. cents, yen, pence, øre, etc.
object BalanceAmount extends MappedLong(this)
val foreignMappedBankAccount: Box[MappedBankAccount] = AccountId_.foreign
val foreignMappedBankAccountCurrency = foreignMappedBankAccount.map(_.currency).getOrElse("EUR")
override def accountId : AccountId = {
foreignMappedBankAccount.map(_.accountId).getOrElse(AccountId(""))
}
override def balanceType: String = BalanceType.get
override def balanceAmount: BigDecimal = Helper.smallestCurrencyUnitToBigDecimal(BalanceAmount.get, foreignMappedBankAccountCurrency)
}
object BankAccountBalance extends BankAccountBalance with LongKeyedMetaMapper[BankAccountBalance] {}

View File

@ -136,6 +136,14 @@ object RegulatedEntityId {
def unapply(id : String) = Some(RegulatedEntityId(id))
}
case class BalanceId(val value : String) {
override def toString = value
}
object BalanceId {
def unapply(id : String) = Some(BalanceId(id))
}
case class AccountId(val value : String) {
override def toString = value
}
@ -229,7 +237,8 @@ trait BankAccount{
}
trait BankAccountBalanceTrait {
def accountId : AccountId
def balanceId: BalanceId
def accountId: AccountId
def balanceType: String
def balanceAmount: BigDecimal
}