/* * 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; /** * <p> * The purpose of this class is to encapsulate all the banking actions * like checking the balances, * transferring the money etc. * </p> * * <p> * We inject the TransactionHandler, which manages the interaction with * QLDB driver. * </p> */ @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<Balance> getBalancesForAccount(@NonNull final String accountId) { return transactionsHandler.executeTransaction(txn -> { final List<Balance> balances = this.getBalancesForAccount(txn, accountId); return balances; }, (retry) -> log.info("There was an error while checking for balance. Retrying ")); } /** * <p> * 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: * </p> * <ol> * <li>Read the Balances of the Sender Account </li> * <li>Read the Balances of the Receiver Account </li> * <li> Check if Sender Account and Receiver Accounts support the currency * and Sender Account has balance more than the requested transfer amount * </li> * <li>Once the above checks pass, we create an entry * in the Transactions table </li> * <li>Calculate and update the balance of Sender Account</li> * <li>Calculate and update the balance of Receiver Account</li> * </ol> * * <p> * 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. * </p> */ 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<Balance> senderAccountBalances = getBalancesForAccount(txn, senderAccountId); log.debug("The Balance for AccountId {} is {}", senderAccountId, senderAccountBalances); final List<Balance> 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<Balance> getBalancesForAccount( @NonNull final TransactionExecutor txn, @NonNull final String accountId) { List<Balance> balances = new ArrayList<>(); final String queryString = "SELECT Balances FROM Accounts WHERE AccountId = ?"; final List<IonValue> 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<IonStruct> 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<String> 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<IonValue> parameters = Collections.singletonList(transactionDocument); final Result result = txn.execute(query, parameters); final List<String> 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<String> updateBalance(@NonNull final TransactionExecutor txn, @NonNull final List<Balance> balances, @NonNull final String accountId, @NonNull final String currency, final double amount, final TransactionType transactionType) { final List<Balance> updatedCurrencyBalances = updateBalanceForCurrency(balances, currency, amount, transactionType); final String query = "UPDATE Accounts SET Balances = ? WHERE AccountId = ?"; final List<IonValue> parameters = new ArrayList<>(); parameters.add(ionHelper.toIonValue(updatedCurrencyBalances)); parameters.add(ionHelper.toIonValue(accountId)); final Result result = txn.execute(query, parameters); final List<String> 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<Balance> senderAccountBalances, @NonNull final String currency, final double amount) { final Optional<Balance> 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<Balance> receiverAccountBalances, final String currency) { final Optional<Balance> 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<Balance> getBalanceForCurrency(@NonNull final List<Balance> 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<Balance> updateBalanceForCurrency(@NonNull final List<Balance> 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; } }