/*
* Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: MIT-0
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this
* software and associated documentation files (the "Software"), to deal in the Software
* without restriction, including without limitation the rights to use, copy, modify,
* merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package software.amazon.qldb.doubleentry.actions;
import com.amazon.ion.Decimal;
import com.amazon.ion.IonStruct;
import com.amazon.ion.IonValue;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Validate;
import software.amazon.qldb.Result;
import software.amazon.qldb.TransactionExecutor;
import software.amazon.qldb.doubleentry.Constants;
import software.amazon.qldb.doubleentry.helpers.IonHelper;
import software.amazon.qldb.doubleentry.helpers.TransactionsHandler;
import software.amazon.qldb.doubleentry.models.Balance;
import software.amazon.qldb.doubleentry.models.Transaction;
import software.amazon.qldb.doubleentry.models.TransactionEntry;
import software.amazon.qldb.doubleentry.models.TransactionType;
import software.amazon.qldb.doubleentry.models.TransferRequest;
import software.amazon.qldb.doubleentry.models.TransferResponse;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
*
* The purpose of this class is to encapsulate all the banking actions
* like checking the balances, * transferring the money etc.
*
*
*
* We inject the TransactionHandler, which manages the interaction with
* QLDB driver.
*
*/
@Slf4j
public class Banking {
private TransactionsHandler transactionsHandler;
private IonHelper ionHelper;
public Banking(@NonNull final TransactionsHandler transactionsHandler,
@NonNull final IonHelper ionHelper) {
this.transactionsHandler = transactionsHandler;
this.ionHelper = ionHelper;
}
/**
* Get the balances for the given AccountId. This method is intended to
* be used from the example code and just logs the balance.
*
* @param accountId The AccountId to get the balances for
*/
public List getBalancesForAccount(@NonNull final String accountId) {
return transactionsHandler.executeTransaction(txn -> {
final List balances = this.getBalancesForAccount(txn, accountId);
return balances;
}, (retry) -> log.info("There was an error while checking for balance. Retrying "));
}
/**
*
* Initiate the transfer of money between two accounts. The method first
* validates all the parameters, and then starts a QLDB Transaction(via the
* TransactionHandler). In the QLDB Transaction we do the following steps:
*
*
* - Read the Balances of the Sender Account
* - Read the Balances of the Receiver Account
* - Check if Sender Account and Receiver Accounts support the currency
* and Sender Account has balance more than the requested transfer amount
*
* - Once the above checks pass, we create an entry
* in the Transactions table
* - Calculate and update the balance of Sender Account
* - Calculate and update the balance of Receiver Account
*
*
*
* All the above mentioned steps are part of a single QLDB Transaction.
* If there is an OCC while committing this transaction, then the
* QLDB Driver(or specifically, QLDB session) takes care of retrying the
* entire transaction, meaning, the failed transaction will start again from
* reading the balances, doing the business validations again with the new
* values, and then updating the balance to the correct values.
*
*/
public TransferResponse transfer(@NonNull final TransferRequest transferRequest) {
//Validate that the input parameters are correct
validateParameters(transferRequest);
/*
* The executeTransaction Method of TransactionsHandler will take care
* of getting the QLDB session and executing the given transaction
* body(via the anonymous function)
*
* If there is an OCC while doing the transaction, this entire anonymous
* function will be tried again, meaning, the balances will be read
* again, the balance checks will be done again and the new balances
* will be computed and the transaction commit will be tried again.
*
*/
final String senderAccountId = transferRequest.getSenderAccountId();
final String receiverAccountId = transferRequest.getReceiverAccountId();
final String currency = transferRequest.getCurrency();
final double amount = transferRequest.getAmount();
/*
* transferSuccessful flag should default to false unless we actually
* mark it to true when Transfer is done successfully
*/
final TransferResponse response = TransferResponse.builder()
.transferSuccessful(false)
.build();
return transactionsHandler.executeTransaction(txn -> {
final List senderAccountBalances =
getBalancesForAccount(txn, senderAccountId);
log.debug("The Balance for AccountId {} is {}",
senderAccountId, senderAccountBalances);
final List receiverAccountBalances =
getBalancesForAccount(txn, receiverAccountId);
log.debug("The Balance for AccountId {} is {}",
receiverAccountId, receiverAccountBalances);
if (senderHasSufficientBalance(senderAccountBalances, currency, amount) &&
receiverAcceptsCurrency(receiverAccountBalances, currency)) {
addEntryInTransactions(txn, transferRequest);
updateBalance(txn, senderAccountBalances, senderAccountId,
currency, amount, TransactionType.DEBIT);
updateBalance(txn, receiverAccountBalances, receiverAccountId,
currency, amount, TransactionType.CREDIT);
response.setTransferSuccessful(true);
response.setUpdatedReceiverBalances(receiverAccountBalances);
response.setUpdatedSenderBalances(senderAccountBalances);
return response;
}
return response;
}, (retry) -> log.info("There was an error "));
}
private void validateParameters(
@NonNull final TransferRequest transferRequest) {
Validate.isTrue(transferRequest.getAmount() > 0);
Validate.notBlank(transferRequest.getSenderAccountId());
Validate.notBlank(transferRequest.getReceiverAccountId());
Validate.isTrue(Constants.SUPPORTED_CURRENCIES.contains(transferRequest.getCurrency()));
Validate.isTrue(!transferRequest.getSenderAccountId().equals(transferRequest.getReceiverAccountId()));
}
/**
* Given an AccountId, get all the balances of the account
* This method is called as a part of the QLDB Transaction and takes in the
* TransactionExecutor instance as an input argument.
*
* @param txn The TransactionExecutor object which is instantiated during
* the QLDB Transaction
* @param accountId
* @return List of Balances for the given AccountId
*/
private List getBalancesForAccount(
@NonNull final TransactionExecutor txn,
@NonNull final String accountId) {
List balances = new ArrayList<>();
final String queryString = "SELECT Balances FROM Accounts WHERE AccountId = ?";
final List parameters = Collections.singletonList(
ionHelper.toIonValue(accountId));
log.debug("Reading the balance for AccountID {}", accountId);
final Result result = txn.execute(queryString, parameters);
if (result.isEmpty()) {
log.error("Could not find any balances for the account {}", result);
return balances;
}
final List documents = ionHelper.toIonStructs(result);
if (1 != documents.size()) {
log.error("More than one accounts exist for the same Account Id {}. Cannot decide which account to "
+ "pick", accountId);
return balances;
}
balances = Arrays.asList(ionHelper.readIonValue(documents.get(0).get("Balances"), Balance[].class));
return balances;
}
/**
* Create an entry in the Transaction Table. This creates a single document
* in the transactions table
*
* @param txn The TransactionExecutor object which is instantiated during
* the QLDB Transaction
* @param transferRequest
* @return List of documentIds created in the transactions table
*/
private List addEntryInTransactions(
@NonNull final TransactionExecutor txn,
@NonNull final TransferRequest transferRequest) {
final TransactionEntry senderTransactionEntry =
TransactionEntry.builder()
.accountId(transferRequest.getSenderAccountId())
.transactionType(TransactionType.DEBIT.name())
.notes(transferRequest.getNotes())
.amount(Decimal.valueOf(transferRequest.getAmount()))
.currency(transferRequest.getCurrency())
.build();
final TransactionEntry receiverTransactionEntry =
TransactionEntry.builder()
.accountId(transferRequest.getReceiverAccountId())
.transactionType(TransactionType.CREDIT.name())
.notes(transferRequest.getNotes())
.amount(Decimal.valueOf(transferRequest.getAmount()))
.currency(transferRequest.getCurrency())
.build();
final Transaction transaction = Transaction.builder()
.transactionTime(LocalDate.now())
.senderAccountEntry(senderTransactionEntry)
.receiverAccountEntry(receiverTransactionEntry)
.build();
final String query = "INSERT INTO Transactions VALUE ? ";
final IonValue transactionDocument =
ionHelper.toIonValue(transaction);
final List parameters =
Collections.singletonList(transactionDocument);
final Result result = txn.execute(query, parameters);
final List insertedDocumentIds = ionHelper.getDocumentIdsFromDmlResult(result);
log.info("Created entries in Transactions table. " +
"Inserted document ids {}", insertedDocumentIds);
return insertedDocumentIds;
}
/**
* Update the balance, of a particular currency, for the given AccountId.
* This method computes the balance to be updated and
* then executes the "UPDATE" Query
*
* @param txn The TransactionExecutor object which is instantiated during
* the QLDB Transaction
* @param balances
* @param accountId
* @param currency
* @param amount
* @param transactionType
* @return List of modified documents in the Accounts table
*/
private List updateBalance(@NonNull final TransactionExecutor txn,
@NonNull final List balances,
@NonNull final String accountId,
@NonNull final String currency,
final double amount,
final TransactionType transactionType) {
final List updatedCurrencyBalances =
updateBalanceForCurrency(balances, currency, amount,
transactionType);
final String query = "UPDATE Accounts SET Balances = ? WHERE AccountId = ?";
final List parameters = new ArrayList<>();
parameters.add(ionHelper.toIonValue(updatedCurrencyBalances));
parameters.add(ionHelper.toIonValue(accountId));
final Result result = txn.execute(query, parameters);
final List insertedDocumentIds = ionHelper.getDocumentIdsFromDmlResult(result);
log.info("Updated entries in Accounts table for Account Id {}. Affected document ids are {}",
accountId, insertedDocumentIds);
return insertedDocumentIds;
}
/**
* Check if the sender account has enough balance for the given currency
*/
private Boolean senderHasSufficientBalance(@NonNull final List senderAccountBalances,
@NonNull final String currency,
final double amount) {
final Optional senderAccountCurrencyBalance = getBalanceForCurrency(senderAccountBalances, currency);
final Boolean senderSatisfiesCondition = senderAccountCurrencyBalance
.map(Balance::getCurrencyBalance)
.filter(b -> (b.doubleValue() > amount))
.isPresent();
return senderSatisfiesCondition;
}
/**
* Check if receiver account accepts the currency
*/
private Boolean receiverAcceptsCurrency(@NonNull final List receiverAccountBalances,
final String currency) {
final Optional receiverAccountCurrencyBalance = getBalanceForCurrency(receiverAccountBalances, currency);
return receiverAccountCurrencyBalance.isPresent();
}
/**
* Given a currency and list of Balance objects, where each object has two
* attributes: currency & balance, get the Balance of input currency.
*/
private Optional getBalanceForCurrency(@NonNull final List balances,
@NonNull final String currency) {
return balances.stream()
.filter(b -> currency.equals(b.getCurrency()))
.findFirst();
}
/**
* Update the Balance of the given currency by the given input amount.
* The update can be either an addition or subtraction depending on the
* TransactionType.
*
* Note: This method just modifies the Balance Object in the list but does
* not write anything to the DB
*/
private List updateBalanceForCurrency(@NonNull final List balances,
@NonNull final String currency,
final double amount,
final TransactionType transactionType) {
balances.forEach(balance -> {
if(currency.equals(balance.getCurrency())) {
if (TransactionType.DEBIT.equals(transactionType)) {
balance.setCurrencyBalance(
Decimal.valueOf(balance.getCurrencyBalance().subtract(Decimal.valueOf(amount))));
} else if (TransactionType.CREDIT.equals(transactionType)) {
balance.setCurrencyBalance(
Decimal.valueOf(balance.getCurrencyBalance().add(Decimal.valueOf(amount))));
}
}
});
return balances;
}
}