| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| package org.apache.fineract.cn.deposit.service.internal.service; |
| |
| import org.apache.commons.lang.StringUtils; |
| import org.apache.fineract.cn.accounting.api.v1.client.LedgerManager; |
| import org.apache.fineract.cn.accounting.api.v1.domain.Account; |
| import org.apache.fineract.cn.accounting.api.v1.domain.Creditor; |
| import org.apache.fineract.cn.accounting.api.v1.domain.Debtor; |
| import org.apache.fineract.cn.accounting.api.v1.domain.JournalEntry; |
| import org.apache.fineract.cn.api.util.UserContextHolder; |
| import org.apache.fineract.cn.deposit.api.v1.definition.domain.Action; |
| import org.apache.fineract.cn.deposit.api.v1.definition.domain.Charge; |
| import org.apache.fineract.cn.deposit.api.v1.definition.domain.Currency; |
| import org.apache.fineract.cn.deposit.api.v1.definition.domain.ProductDefinition; |
| import org.apache.fineract.cn.deposit.api.v1.instance.domain.ProductInstance; |
| import org.apache.fineract.cn.deposit.api.v1.instance.domain.SubTransactionType; |
| import org.apache.fineract.cn.deposit.api.v1.transaction.domain.data.*; |
| import org.apache.fineract.cn.deposit.api.v1.transaction.utils.MathUtil; |
| import org.apache.fineract.cn.deposit.service.ServiceConstants; |
| import org.apache.fineract.cn.deposit.service.internal.command.TransactionCommand; |
| import org.apache.fineract.cn.deposit.service.internal.repository.ProductInstanceEntity; |
| import org.apache.fineract.cn.deposit.service.internal.repository.ProductInstanceRepository; |
| import org.apache.fineract.cn.deposit.service.internal.repository.TransactionEntity; |
| import org.apache.fineract.cn.deposit.service.internal.repository.TransactionRepository; |
| import org.apache.fineract.cn.lang.DateConverter; |
| import org.apache.fineract.cn.lang.ServiceException; |
| import org.slf4j.Logger; |
| import org.springframework.beans.factory.annotation.Autowired; |
| import org.springframework.beans.factory.annotation.Qualifier; |
| import org.springframework.data.domain.Page; |
| import org.springframework.data.domain.Pageable; |
| import org.springframework.stereotype.Service; |
| import org.springframework.transaction.annotation.Transactional; |
| |
| import javax.validation.constraints.NotNull; |
| import java.math.BigDecimal; |
| import java.math.MathContext; |
| import java.time.Clock; |
| import java.time.LocalDateTime; |
| import java.util.*; |
| import java.util.stream.Collectors; |
| |
| @Service |
| public class TransactionService { |
| private final Logger logger; |
| private final LedgerManager ledgerManager; |
| private final ProductInstanceService productInstanceService; |
| private final ProductDefinitionService productDefinitionService; |
| private final ActionService actionService; |
| private final SubTxnTypesService subTxnTypesService; |
| private final TransactionRepository transactionRepository; |
| private final ProductInstanceRepository productInstanceRepository; |
| |
| public static final String DEBIT = "DEBIT"; |
| public static final String CREDIT = "CREDIT"; |
| |
| @Autowired |
| public TransactionService(@Qualifier(ServiceConstants.LOGGER_NAME) Logger logger, LedgerManager ledgerManager, |
| ProductInstanceService productInstanceService, |
| ProductDefinitionService productDefinitionService, ActionService actionService, |
| SubTxnTypesService subTxnTypesService, TransactionRepository transactionRepository, ProductInstanceRepository productInstanceRepository) { |
| this.logger = logger; |
| this.ledgerManager = ledgerManager; |
| this.productInstanceService = productInstanceService; |
| this.productDefinitionService = productDefinitionService; |
| this.actionService = actionService; |
| this.subTxnTypesService = subTxnTypesService; |
| this.transactionRepository = transactionRepository; |
| this.productInstanceRepository = productInstanceRepository; |
| } |
| |
| @Transactional |
| public TransactionResponseData withdraw(TransactionCommand command) { |
| TransactionRequestData request = command.getTransactionRequest(); |
| AccountWrapper accountWrapper = validateAndGetAccount(request, request.getAccountId(), TransactionTypeEnum.WITHDRAWAL); |
| LocalDateTime transactionDate = getNow(); |
| //get txntype charges |
| List<Charge> charges = getCharges(accountWrapper.account.getIdentifier(), TransactionTypeEnum.WITHDRAWAL); |
| //todo: get subTxnType charges |
| |
| TransactionEntity txn = doWithdraw(request, accountWrapper, charges, getNow(), request.getAccountId()); |
| |
| |
| return TransactionResponseData.build(request.getRoutingCode(), request.getExternalId(), |
| request.getRequestCode(), ActionState.ACCEPTED, |
| null, txn.getIdentifier(), transactionDate); |
| } |
| |
| @Transactional |
| public TransactionResponseData deposit(TransactionCommand command) { |
| TransactionRequestData request = command.getTransactionRequest(); |
| AccountWrapper accountWrapper = validateAndGetAccount(request, request.getAccountId(), TransactionTypeEnum.DEPOSIT); |
| LocalDateTime transactionDate = getNow(); |
| //get txntype charges |
| List<Charge> charges = getCharges(accountWrapper.account.getIdentifier(), TransactionTypeEnum.DEPOSIT); |
| //todo: get subTxnType charges |
| TransactionEntity txn = doDeposit(request, accountWrapper, charges, getNow(), request.getAccountId()); |
| |
| return TransactionResponseData.build(request.getRoutingCode(), request.getExternalId(), |
| request.getRequestCode(), ActionState.ACCEPTED, |
| null, txn.getIdentifier(), transactionDate); |
| } |
| |
| public TransactionResponseData transfer(TransactionCommand command) { |
| TransactionRequestData request = command.getTransactionRequest(); |
| AccountWrapper fromAccountWrapper = validateAndGetAccount(request, request.getFromAccountId(), TransactionTypeEnum.WITHDRAWAL); |
| AccountWrapper toAccountWrapper = validateAndGetAccount(request, request.getToAccountId(), TransactionTypeEnum.DEPOSIT); |
| LocalDateTime transactionDate = getNow(); |
| //get txntype charges |
| List<Charge> charges = getCharges(fromAccountWrapper.account.getIdentifier(), TransactionTypeEnum.ACCOUNT_TRANSFER); |
| //todo: get subTxnType charges |
| |
| TransactionEntity txn = doWithdraw(request, fromAccountWrapper, charges, getNow(), request.getFromAccountId()); |
| TransactionEntity txn2 = doDeposit(request, toAccountWrapper, new ArrayList<>(), getNow(), request.getToAccountId()); |
| |
| return TransactionResponseData.build(request.getRoutingCode(), request.getExternalId(), |
| request.getRequestCode(), ActionState.ACCEPTED, |
| null, txn.getIdentifier() +"_to_"+ txn2.getIdentifier(), transactionDate); |
| } |
| |
| private TransactionEntity doDeposit(TransactionRequestData request, AccountWrapper accountWrapper, |
| List<Charge> charges, LocalDateTime transactionDate, String accountId) { |
| BigDecimal amount = request.getAmount().getAmount(); |
| |
| TransactionEntity txn = createTransaction(request,TransactionTypeEnum.DEPOSIT, transactionDate, CREDIT, null, accountId); |
| String debitAccountIdentifier = accountWrapper.productDefinition.getCashAccountIdentifier(); |
| /* if subtxn is provided and it has an account configured the do debit that account*/ |
| if(StringUtils.isNotBlank(request.getSubTxnId())){ |
| Optional<SubTransactionType> subTxnTypeOpt = this.subTxnTypesService.findByIdentifier(request.getSubTxnId()); |
| if(subTxnTypeOpt.isPresent()) { |
| txn.setSubTxnType(subTxnTypeOpt.get().getIdentifier()); |
| if (subTxnTypeOpt.get().getLedgerAccount() != null) { |
| debitAccountIdentifier = subTxnTypeOpt.get().getLedgerAccount(); |
| } |
| } |
| } |
| final JournalEntry journalEntry = createJournalEntry(txn.getIdentifier(), TransactionTypeEnum.DEPOSIT.getCode(), |
| DateConverter.toIsoString(transactionDate), request.getNote(), getLoginUser()); |
| |
| HashSet<Debtor> debtors = new HashSet<>(1); |
| HashSet<Creditor> creditors = new HashSet<>(1); |
| |
| addCreditor(accountWrapper.account.getIdentifier(), amount.doubleValue(), creditors); |
| addDebtor(debitAccountIdentifier, amount.doubleValue(), debtors); |
| |
| |
| prepareCharges(request, accountWrapper, charges, debtors, creditors, txn, accountId); |
| |
| if (debtors.isEmpty()) // must be same size as creditors |
| throw ServiceException.badRequest("Debit and Credit doesn't match"); |
| |
| journalEntry.setDebtors(debtors); |
| journalEntry.setCreditors(creditors); |
| |
| transactionRepository.save(txn); |
| |
| ledgerManager.createJournalEntry(journalEntry); |
| return txn; |
| } |
| |
| |
| private TransactionEntity doWithdraw(@NotNull TransactionRequestData request, @NotNull AccountWrapper accountWrapper, |
| List<Charge> charges, LocalDateTime transactionDate, String accountId) { |
| BigDecimal amount = request.getAmount().getAmount(); |
| |
| TransactionEntity txn = createTransaction(request, TransactionTypeEnum.WITHDRAWAL, transactionDate, DEBIT, null, accountId); |
| |
| String creditAccountIdentifier = accountWrapper.productDefinition.getCashAccountIdentifier(); |
| /* if subtxn is provided and it has an account configured the do credit that account*/ |
| if(StringUtils.isNotBlank(request.getSubTxnId())){ |
| Optional<SubTransactionType> subTxnTypeOpt = this.subTxnTypesService.findByIdentifier(request.getSubTxnId()); |
| if(subTxnTypeOpt.isPresent()) { |
| txn.setSubTxnType(subTxnTypeOpt.get().getIdentifier()); |
| if (subTxnTypeOpt.get().getLedgerAccount() != null) { |
| creditAccountIdentifier = subTxnTypeOpt.get().getLedgerAccount(); |
| } |
| } |
| } |
| final JournalEntry journalEntry = createJournalEntry(txn.getIdentifier(), TransactionTypeEnum.WITHDRAWAL.getCode(), |
| DateConverter.toIsoString(transactionDate), request.getNote(), getLoginUser()); |
| |
| HashSet<Debtor> debtors = new HashSet<>(1); |
| HashSet<Creditor> creditors = new HashSet<>(1); |
| |
| addCreditor(creditAccountIdentifier, amount.doubleValue(), creditors); |
| addDebtor(accountWrapper.account.getIdentifier(), amount.doubleValue(), debtors); |
| |
| prepareCharges(request, accountWrapper, charges, debtors, creditors, txn, accountId); |
| |
| if (debtors.isEmpty()) // must be same size as creditors |
| throw ServiceException.badRequest("Debit and Credit doesn't match"); |
| |
| journalEntry.setDebtors(debtors); |
| journalEntry.setCreditors(creditors); |
| transactionRepository.save(txn); |
| ledgerManager.createJournalEntry(journalEntry); |
| return txn; |
| } |
| |
| |
| |
| private void prepareCharges(@NotNull TransactionRequestData request, @NotNull AccountWrapper accountWrapper, |
| @NotNull List<Charge> charges, HashSet<Debtor> debtors, HashSet<Creditor> creditors, |
| TransactionEntity txn, String accountId) { |
| |
| |
| BigDecimal amount = request.getAmount().getAmount(); |
| Currency currency = accountWrapper.productDefinition.getCurrency(); |
| |
| BigDecimal total = MathUtil.normalize(calcTotalCharges(charges, amount), currency); |
| if (MathUtil.isEmpty(total)) { |
| return; |
| } |
| txn.setFeeAmount(total); |
| |
| if (creditors == null) { |
| creditors = new HashSet<>(1); |
| } |
| if (debtors == null) { |
| debtors = new HashSet<>(charges.size()); |
| } |
| for(Charge charge : charges){ |
| addCreditor(charge.getIncomeAccountIdentifier(), calcChargeAmount(amount, charge).doubleValue(), creditors); |
| addDebtor(accountWrapper.account.getIdentifier(), calcChargeAmount(amount, charge).doubleValue(), debtors); |
| createTransaction(request,TransactionTypeEnum.CHARGES_PAYMENT, getNow(), DEBIT, txn, accountId); |
| } |
| |
| } |
| |
| |
| private AccountWrapper validateAndGetAccount(@NotNull TransactionRequestData request, String accountId, TransactionTypeEnum txnType) { |
| //TODO: error handling |
| Account account = ledgerManager.findAccount(accountId); |
| validateAccount(request.getAmount(), account); |
| |
| ProductInstance product = productInstanceService.findByAccountIdentifier(accountId).get(); |
| ProductDefinition productDefinition = productDefinitionService.findProductDefinition(product.getProductIdentifier()).get(); |
| |
| Currency currency = productDefinition.getCurrency(); |
| if (!currency.getCode().equals(request.getAmount().getCurrency())) |
| throw new UnsupportedOperationException(); |
| |
| request.normalizeAmounts(currency); |
| |
| Double withdrawableBalance = getWithdrawableBalance(account, productDefinition); |
| if (txnType == TransactionTypeEnum.WITHDRAWAL && withdrawableBalance < request.getAmount().getAmount().doubleValue()) |
| throw new UnsupportedOperationException(); |
| |
| return new AccountWrapper(account, product, productDefinition, withdrawableBalance); |
| } |
| |
| Double getWithdrawableBalance(Account account, ProductDefinition productDefinition) { |
| // on-hold amount, if any, is subtracted to payable account |
| return MathUtil.subtractToZero(account.getBalance(), productDefinition.getMinimumBalance()); |
| } |
| |
| |
| private void validateAccount(MoneyData requestAmount, Account account) { |
| validateAccount(account); |
| |
| String accountId = account.getIdentifier(); |
| |
| if (account.getHolders() != null) { // customer account |
| ProductInstance product = productInstanceService.findByAccountIdentifier(accountId).get(); |
| ProductDefinition productDefinition = productDefinitionService.findProductDefinition(product.getProductIdentifier()).get(); |
| if (!Boolean.TRUE.equals(productDefinition.getActive())) |
| throw new UnsupportedOperationException("Product Definition is inactive"); |
| |
| Currency currency = productDefinition.getCurrency(); |
| if (!currency.getCode().equals(requestAmount.getCurrency())) |
| throw new UnsupportedOperationException(); |
| } |
| } |
| |
| private void validateAccount(Account account) { |
| if (account == null) |
| throw new UnsupportedOperationException("Account not found"); |
| if (!account.getState().equals(Account.State.OPEN.name())) |
| throw new UnsupportedOperationException("Account is in state " + account.getState()); |
| } |
| |
| public List<Charge> getCharges(String accountIdentifier, TransactionTypeEnum transactionType) { |
| List<Action> actions = actionService.fetchActions(); |
| |
| List<String> actionIds = actions |
| .stream() |
| .filter(action -> action.getTransactionType().equals(transactionType.getCode())) |
| .map(Action::getIdentifier) |
| .collect(Collectors.toList()); |
| |
| ProductInstance product = productInstanceService.findByAccountIdentifier(accountIdentifier).get(); |
| ProductDefinition productDefinition = productDefinitionService.findProductDefinition(product.getProductIdentifier()).get(); |
| |
| return productDefinition.getCharges() |
| .stream() |
| .filter(charge -> actionIds.contains(charge.getActionIdentifier())) |
| .collect(Collectors.toList()); |
| } |
| // Util |
| |
| |
| private LocalDateTime getNow() { |
| return LocalDateTime.now(Clock.systemUTC()); |
| } |
| private String getLoginUser() { |
| return UserContextHolder.checkedGetUser(); |
| } |
| |
| |
| private JournalEntry createJournalEntry(String actionIdentifier, String transactionType, String transactionDate, String message, String loginUser) { |
| final JournalEntry journalEntry = new JournalEntry(); |
| journalEntry.setTransactionIdentifier(actionIdentifier); |
| journalEntry.setTransactionType(transactionType); |
| journalEntry.setTransactionDate(transactionDate); |
| journalEntry.setMessage(message); |
| journalEntry.setClerk(loginUser); |
| return journalEntry; |
| } |
| |
| private void addCreditor(String accountNumber, double amount, HashSet<Creditor> creditors) { |
| Creditor creditor = new Creditor(); |
| creditor.setAccountNumber(accountNumber); |
| creditor.setAmount(Double.toString(amount)); |
| creditors.add(creditor); |
| } |
| |
| private void addDebtor(String accountNumber, double amount, HashSet<Debtor> debtors) { |
| Debtor debtor = new Debtor(); |
| debtor.setAccountNumber(accountNumber); |
| debtor.setAmount(Double.toString(amount)); |
| debtors.add(debtor); |
| } |
| |
| private BigDecimal calcChargeAmount(BigDecimal amount, Charge charge) { |
| return calcChargeAmount(amount, charge, null, false); |
| } |
| |
| private BigDecimal calcChargeAmount(@NotNull BigDecimal amount, @NotNull Charge charge, Currency currency, boolean norm) { |
| Double value = charge.getAmount(); |
| if (value == null) |
| return null; |
| |
| BigDecimal portion = BigDecimal.valueOf(100.00d); |
| MathContext mc = MathUtil.CALCULATION_MATH_CONTEXT; |
| BigDecimal feeAmount = BigDecimal.valueOf(MathUtil.nullToZero(charge.getAmount())); |
| BigDecimal result = charge.getProportional() |
| ? amount.multiply(feeAmount.divide(portion, mc), mc) |
| : feeAmount; |
| return norm ? MathUtil.normalize(result, currency) : result; |
| } |
| |
| @NotNull |
| private BigDecimal calcTotalCharges(@NotNull List<Charge> charges, BigDecimal amount) { |
| return charges.stream().map(charge -> calcChargeAmount(amount, charge)).reduce(MathUtil::add).orElse(BigDecimal.ZERO); |
| } |
| |
| |
| private TransactionEntity createTransaction(TransactionRequestData request, TransactionTypeEnum txnType, |
| LocalDateTime transactionDate, String tranType, |
| TransactionEntity parent, String accountId) { |
| TransactionEntity txn = new TransactionEntity(); |
| UUID uuid=UUID.randomUUID(); |
| |
| while(transactionRepository.findByIdentifier(uuid.toString()).isPresent()){ |
| uuid=UUID.randomUUID(); |
| } |
| |
| txn.setIdentifier(uuid.toString()); |
| txn.setRoutingCode(request.getRoutingCode()); |
| txn.setExternalId(request.getExternalId()); |
| txn.setTransactionType(txnType); |
| txn.setAmount(request.getAmount().getAmount()); |
| //txn.setFeeAmount(); |
| txn.setState(ActionState.ACCEPTED); |
| //txn.setCustomerAccountIdentifier(); |
| txn.setTransactionDate(transactionDate); |
| txn.setExpirationDate(request.getExpiration()); |
| txn.setCreatedBy(getLoginUser()); |
| txn.setCreatedOn(getNow()); |
| /*txn.setLastModifiedBy(); |
| txn.setLastModifiedOn();*/ |
| markLastTransaction(accountId, transactionDate); |
| txn.setAccountId(accountId); |
| txn.setType(tranType); |
| txn.setParentTransaction(parent); |
| transactionRepository.save(txn); |
| return txn; |
| } |
| |
| private void markLastTransaction(final String productInstanceIdentifier, LocalDateTime transactionDate) { |
| final Optional<ProductInstanceEntity> optionalProductInstance = |
| this.productInstanceRepository.findByAccountIdentifier(productInstanceIdentifier); |
| |
| final ProductInstanceEntity productInstanceEntity = optionalProductInstance.orElseThrow(() -> |
| ServiceException.notFound("Product instance {0} not found.", productInstanceIdentifier)); |
| |
| productInstanceEntity.setLastTransactionDate(transactionDate); |
| |
| this.productInstanceRepository.save(productInstanceEntity); |
| |
| } |
| public List<StatementResponse> fetchStatement(String accountId, |
| LocalDateTime fromDateTime, |
| LocalDateTime toDateTime) { |
| return transactionRepository.findByAccountIdAndTransactionDateBetween(accountId, fromDateTime, toDateTime) |
| .stream().map(txn -> { |
| StatementResponse statementObj = new StatementResponse(); |
| statementObj.setTransactionCode(txn.getIdentifier()); |
| statementObj.setTranType(txn.getTransactionType().name()); |
| statementObj.setAmount(txn.getAmount()); |
| statementObj.setTransactionDate(txn.getTransactionDate()); |
| statementObj.setSubTxnType(txn.getSubTxnType()); |
| statementObj.setType(txn.getType()); |
| statementObj.setDescription(txn.getDescription()); |
| return statementObj; |
| }).collect(Collectors.toList()); |
| } |
| |
| |
| public static class AccountWrapper { |
| @NotNull |
| private final Account account; |
| @NotNull |
| private final ProductInstance product; |
| @NotNull |
| private final ProductDefinition productDefinition; |
| @NotNull |
| private final Double withdrawableBalance; |
| |
| public AccountWrapper(Account account, ProductInstance product, ProductDefinition productDefinition, Double withdrawableBalance) { |
| this.account = account; |
| this.product = product; |
| this.productDefinition = productDefinition; |
| this.withdrawableBalance = withdrawableBalance; |
| } |
| } |
| } |