Extension points

From Complete Cyclos documentation wiki
Jump to: navigation, search

Payment

It is possible to deploy custom Java classes on the server which are triggered when payments are performed. There are three points that can be triggered and allow implementing custom behavior. Namely; before a payment has been committed and the account balance of the paying user has been verified, before committing a payment after account balance check, and after a payment has been has been performed, (see more detailed explanation further below).

In the payment type details page it is possible to specify the Java class name that will run when a payment of that specific type is run. It is also possible to set the class name on the local settings so that it will run on all payments of any type. The given Java class name must implement the nl.strohalm.cyclos.entities.accounts.transactions.TransferListener interface. It is possible to just extend the abstract nl.strohalm.cyclos.entities.accounts.transactions.TransferListenerAdapter which implements all methods in the TransferListener interface as empty methods. The interface methods are:

  1. onTransferProcessed(transfer): Called when the transfer is actually processed. Processed means that a payment has been done and does not require further authorization, that a pending transfer has been authorized or that an scheduled payment installment has been processed. This method is invoked in a separated database transaction, not in the one that processed the transfer. That means that no matter what happens on this method, the payment is already permanently committed. The usage scenario for this method is in cases where actually processed payments needs an external logging, notification or mirroring in other third party application.
  2. onTransferInserted(transfer): Called within the database transaction that created the transfer, right after inserting the transfer record into database. The transfer may be fully processed or still pending authorization. To check that, check whether the Transfer.getProcessDate() returns null (pending processing) or not null (already processed). Any exceptions thrown in this method will cause the main database transaction to rollback, and any modified database state will only be persisted if the main transaction is committed. The rationale for this method is when an external validation (on third party applications, for example) must be done before the payment is committed, but only if the payment is also valid in Cyclos.
  3. onBeforeValidateBalance(transfer): This method is invoked even before validating the balance of affected accounts. The Transfer passed to the method is not persistent yet, that is, it's identifier is null. Any exceptions thrown in this method will cause the main database transaction to rollback, and any modified database state will only be persisted if the main transaction is committed. This method may be used in scenarios where a deposit payment or loan can be generated 'on demand' to the source account in cases where it does not have enough balance to perform the payment.

For both onTransferInserted(transfer) and onBeforeValidateBalance(transfer) the following counts:

  • If any external identifier or tag should be set on the transfer for later checking, the Transfer.setTraceData might be used. If that value is modified, it will be persisted when the main transaction commits.
  • A descriptive error message may be displayed to the client, by throwing a nl.strohalm.cyclos.exceptions.ExternalException. The Exception.getMessage() will be displayed directly.
  • Any long-running operations will block the original payment response. If performing blocking operations (such as network communication), be sure to handle timeouts.
  • If integrating systems, it is often needed to handle failures on the Cyclos side to undo operations on the external system. To implement that, use nl.strohalm.cyclos.utils.transaction.CurrentTransactionData.addTransactionRollbackListener(listener). The listener will only be notified in case the main transaction in Cyclos is rolled back. Another approach is when the external system has a preparation phase and a commit phase. In that case, either onTransferInserted or onBeforeValidateBalance would start the process on the external application. They should use both CurrentTransactionData.addTransactionCommitListener(listener) and CurrentTransactionData.addTransactionRollbackListener(listener) to either commit or rollback the external operation (of course, timeout handling on the other side would be needed in order to guarantee that a failure in Cyclos would not indefinitely block the external operation.

Any listener will be instantiated only once, and reused for all notifications. Listeners are autowired by Spring, and have access to the full range of services in Cyclos. To use a service, use the local interfaces and create a setter with the correct bean name. It is very important that for services, the setter name ends with "Local", or the security layer will be user, and the security has already been validated. The corresponding services will be injected by Spring. Some samples will be shown ahead.

There two common ways of deploying the classes.

  • Make a Jar with classes and put them in the directory /WEB-INF/lib
  • Or put the compiled classes in /WEB-INF/classes

Code samples

Each of the provided samples shows a subclass of TransferListenerAdapter implementing one of it's methods, for the

Sending an e-mail whenever a payment is performed

(note that this is just an example, as Cyclos has both admin and member notification for payments)

import java.io.UnsupportedEncodingException;

import javax.mail.internet.InternetAddress;

import nl.strohalm.cyclos.entities.accounts.Account;
import nl.strohalm.cyclos.entities.accounts.Currency;
import nl.strohalm.cyclos.entities.accounts.MemberAccount;
import nl.strohalm.cyclos.entities.accounts.transactions.Transfer;
import nl.strohalm.cyclos.entities.accounts.transactions.TransferListenerAdapter;
import nl.strohalm.cyclos.entities.members.Member;
import nl.strohalm.cyclos.entities.settings.LocalSettings;
import nl.strohalm.cyclos.services.settings.SettingsServiceLocal;
import nl.strohalm.cyclos.utils.MailHandler;
import nl.strohalm.cyclos.utils.transaction.CurrentTransactionData;

/**
 * A TransferListener that sends an e-mail whenever a payment is performed
 */
public class EmailSenderTransferListener extends TransferListenerAdapter {
    private MailHandler          mailHandler;
    private SettingsServiceLocal settingsService;

    @Override
    public void onTransferProcessed(Transfer transfer) {
        LocalSettings localSettings = settingsService.getLocalSettings();

        // Get the variables to use on the body
        String type = transfer.getType().getName();
        String fromOwner = formatAccountOwner(transfer.getActualFrom());
        String toOwner = formatAccountOwner(transfer.getActualTo());
        Currency currency = transfer.getType().getCurrency();
        String formattedAmount = localSettings.
                getUnitsConverter(currency.getPattern()).
                toString(transfer.getActualAmount());

        // Set up the e-mail parts
        String subject = "New payment performed";
        InternetAddress to;
        try {
            to = new InternetAddress("admin@mail.com", "The Administrator");
        } catch (UnsupportedEncodingException e) {
            return;
        }
        String body = String.format(
                "A new payment has been performed:\n" +
                        "Type: %s\n" +
                        "From: %s\n" +
                        "To: %s\n" +
                        "Amount: %s",
                type, fromOwner, toOwner, formattedAmount);

        // Send the e-mail
        mailHandler.send(subject, null, to, body, false);

        // Exceptions when sending e-mails are never actually thrown
        if (CurrentTransactionData.getMailError() != null) {
            System.out.println("There was an error while sending the e-mail");
        }
    }

    public void setMailHandler(MailHandler mailHandler) {
        this.mailHandler = mailHandler;
    }

    public void setSettingsServiceLocal(SettingsServiceLocal settingsService) {
        this.settingsService = settingsService;
    }

    private String formatAccountOwner(Account account) {
        if (account instanceof MemberAccount) {
            Member member = ((MemberAccount) account).getMember();
            return member.getUsername() + " - " + member.getName();
        } else {
            return account.getType().getName();
        }
    }
}


Granting on-demand loans

This listener grants a loan to a member which is performing a payment whenever he/she doesn't have the entire amount left on the balance.
A custom field with internal name 'creditLimit' controls the maximum amount of open loans the member can have.
It is also assumed an specific transfer type identifier to grant loans (22 in this case). It will probably have to be adjusted.

import java.math.BigDecimal;
import java.util.Calendar;
import java.util.List;

import nl.strohalm.cyclos.entities.accounts.Account;
import nl.strohalm.cyclos.entities.accounts.AccountStatus;
import nl.strohalm.cyclos.entities.accounts.MemberAccount;
import nl.strohalm.cyclos.entities.accounts.loans.Loan;
import nl.strohalm.cyclos.entities.accounts.loans.Loan.Type;
import nl.strohalm.cyclos.entities.accounts.loans.LoanParameters;
import nl.strohalm.cyclos.entities.accounts.loans.LoanQuery;
import nl.strohalm.cyclos.entities.accounts.loans.LoanQuery.QueryStatus;
import nl.strohalm.cyclos.entities.accounts.transactions.Transfer;
import nl.strohalm.cyclos.entities.accounts.transactions.TransferListenerAdapter;
import nl.strohalm.cyclos.entities.accounts.transactions.TransferType;
import nl.strohalm.cyclos.entities.customization.fields.MemberCustomFieldValue;
import nl.strohalm.cyclos.entities.members.Member;
import nl.strohalm.cyclos.entities.settings.LocalSettings;
import nl.strohalm.cyclos.exceptions.ExternalException;
import nl.strohalm.cyclos.services.accounts.AccountDTO;
import nl.strohalm.cyclos.services.accounts.AccountServiceLocal;
import nl.strohalm.cyclos.services.settings.SettingsServiceLocal;
import nl.strohalm.cyclos.services.transactions.GrantSinglePaymentLoanDTO;
import nl.strohalm.cyclos.services.transactions.LoanServiceLocal;
import nl.strohalm.cyclos.services.transfertypes.TransactionFeePreviewDTO;
import nl.strohalm.cyclos.services.transfertypes.TransactionFeeServiceLocal;
import nl.strohalm.cyclos.services.transfertypes.TransferTypeServiceLocal;
import nl.strohalm.cyclos.utils.CustomFieldHelper;
import nl.strohalm.cyclos.utils.DataIteratorHelper;
import nl.strohalm.cyclos.utils.TimePeriod;
import nl.strohalm.cyclos.utils.TimePeriod.Field;

/**
 * Grants a loan whenever the payer doesn't have enough balance, up to a limit defined by a custom field
 */
public class OnDemandLoanTransferListener extends TransferListenerAdapter {

    private static final String        CREDIT_LIMIT_FIELD    = "creditLimit";
    private static final Long          LOAN_TRANSFER_TYPE_ID = 22L;
    private AccountServiceLocal        accountService;
    private LoanServiceLocal           loanService;
    private SettingsServiceLocal       settingsService;
    private TransferTypeServiceLocal   transferTypeService;
    private TransactionFeeServiceLocal transactionFeeService;

    @Override
    public void onBeforeValidateBalance(Transfer transfer) throws ExternalException {
        Account fromAccount = transfer.getActualFrom();
        if (!(fromAccount instanceof MemberAccount)) {
            // A payment from a system account. Nothing to do
            return;
        }

        // Get the current account status
        AccountStatus status = accountService.getCurrentStatus(new AccountDTO(fromAccount));
        BigDecimal availableBalance = status.getAvailableBalance();

        // There could be transaction fees. We need the loan to take that into account
        TransactionFeePreviewDTO preview = transactionFeeService.preview(transfer.getActualFromOwner(), transfer.getActualToOwner(), transfer.getType(), transfer.getAmount());
        BigDecimal totalPaymentAmount = preview.getFinalAmount().add(preview.getTotalFeeAmount());

        // Determine the loan amount
        BigDecimal loanAmount = availableBalance.subtract(totalPaymentAmount).negate();
        if (loanAmount.compareTo(BigDecimal.ZERO) <= 0) {
            // No loan is needed - the account has enough balance
            return;
        }

        LocalSettings localSettings = settingsService.getLocalSettings();

        // Validate the maximum credit, which is stored on a custom field
        Member member = ((MemberAccount) fromAccount).getMember();
        MemberCustomFieldValue fieldValue = CustomFieldHelper.getValue(CREDIT_LIMIT_FIELD, member.getCustomValues());
        BigDecimal creditLimit = fieldValue == null ? null : localSettings.getNumberConverter().valueOf(fieldValue.getValue());
        if (creditLimit == null || creditLimit.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ExternalException("No credit limit and not enough balance in account. Cannot perform payment.");
        }

        // Get the total open loans amount
        BigDecimal totalOpenLoanAmount = BigDecimal.ZERO;
        LoanQuery loanQuery = new LoanQuery();
        loanQuery.setIterateAll();
        loanQuery.setMember(member);
        loanQuery.setQueryStatus(QueryStatus.OPEN);
        List<Loan> openLoans = loanService.search(loanQuery);
        try {
            for (Loan loan : openLoans) {
                totalOpenLoanAmount = totalOpenLoanAmount.add(loan.getRemainingAmount());
            }
        } finally {
            DataIteratorHelper.close(openLoans);
        }
        if (totalOpenLoanAmount.add(loanAmount).compareTo(creditLimit) > 0) {
            throw new ExternalException("Credit limit reached. Cannot perform payment.");
        }

        // Find the transfer type, validating that it is a loan type
        TransferType transferType;
        try {
            transferType = transferTypeService.load(LOAN_TRANSFER_TYPE_ID);
        } catch (Exception e) {
            throw new ExternalException("Loan type couldn't be resolved");
        }
        LoanParameters loanParams = transferType.getLoan();
        if (loanParams == null || loanParams.getType() != Type.SINGLE_PAYMENT) {
            throw new ExternalException("Transfer type for on-demand loan is not a single payment loan type");
        }

        // Calculate the repayment date
        Calendar now = Calendar.getInstance();
        Integer repaymentDays = loanParams.getRepaymentDays();
        TimePeriod repaymentPeriod = new TimePeriod(repaymentDays, Field.DAYS);
        Calendar repaymentDate = repaymentPeriod.add(now);

        // Grant the loan
        GrantSinglePaymentLoanDTO dto = new GrantSinglePaymentLoanDTO();
        dto.setMember(member);
        dto.setAmount(loanAmount);
        dto.setAutomatic(true);
        dto.setTransferType(transferType);
        dto.setRepaymentDate(repaymentDate);
        dto.setDescription("On-demand loan");
        loanService.insert(dto);
    }

    public void setAccountServiceLocal(AccountServiceLocal accountService) {
        this.accountService = accountService;
    }

    public void setLoanServiceLocal(LoanServiceLocal loanService) {
        this.loanService = loanService;
    }

    public void setSettingsServiceLocal(SettingsServiceLocal settingsService) {
        this.settingsService = settingsService;
    }

    public void setTransactionFeeServiceLocal(TransactionFeeServiceLocal transactionFeeService) {
        this.transactionFeeService = transactionFeeService;
    }

    public void setTransferTypeServiceLocal(TransferTypeServiceLocal transferTypeService) {
        this.transferTypeService = transferTypeService;
    }
}


External system authorization for payments

This example shows integration with an external system (API) to authorize payments in Cyclos. When a user performs a payment in Cyclos that needs to be authorized externally by third party software the payment in Cyclos will be kept in a 'pending commit' status. Then, if the external system authorizes the payment it is committed in Cyclos (and any possible action occur like applying possible transaction fees and sending notifications etc). After the payment has been commited in Cyclos the external system (API) is notified with a success or failure. Then, according to the success of the transaction in Cyclos, commits or rollbacks that authorization remotely.

import nl.strohalm.cyclos.entities.accounts.transactions.Transfer;
import nl.strohalm.cyclos.entities.accounts.transactions.TransferListenerAdapter;
import nl.strohalm.cyclos.exceptions.ExternalException;
import nl.strohalm.cyclos.utils.transaction.CurrentTransactionData;
import nl.strohalm.cyclos.utils.transaction.TransactionCommitListener;
import nl.strohalm.cyclos.utils.transaction.TransactionRollbackListener;

/**
 * This example simulates an external authorization for performed payments in Cyclos
 */
public class ExternalAuthorizationTransferListener extends TransferListenerAdapter {

    private class TransactionListener implements TransactionCommitListener, TransactionRollbackListener {
        private final Object token;

        public TransactionListener(Object token) {
            this.token = token;
        }

        public void onTransactionCommit() {
            otherSystemAPI.commit(token);
        }

        public void onTransactionRollback() {
            otherSystemAPI.rollback(token);
        }
    }

    // An example of calls to other system
    private OtherSystemAPI otherSystemAPI;

    @Override
    public void onTransferInserted(Transfer transfer) throws ExternalException {
        // Perform the external system authorization
        Object token;
        try {
            token = otherSystemAPI.preparePayment(transfer);
        } catch (Exception e) {
            throw new ExternalException("Payment cannot be performed", e);
        }
        // If got to this point, the payment is externally authorized.
        // Add both transaction commit and rollback listeners to notify the external system
        TransactionListener listener = new TransactionListener(token);
        CurrentTransactionData.addTransactionCommitListener(listener);
        CurrentTransactionData.addTransactionRollbackListener(listener);
    }
}