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:
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.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.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:
Transfer.setTraceData might be used. If that value is modified, it will be persisted when the main transaction commits.nl.strohalm.cyclos.exceptions.ExternalException. The Exception.getMessage() will be displayed directly.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.
Each of the provided samples shows a subclass of TransferListenerAdapter implementing one of it's methods, for the
(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();
}
}
}
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;
}
}
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);
}
}