Examples of solutions that require a single script can be found directly in the specific script description page (links directly above). Solutions that need several scripts and configurations can be found in this section.
It is possible to integrate Cyclos with PayPal, allowing users to buy units with their PayPal account. This is done with a custom operation which allows users to confirm the payment in PayPal and then, once the payment is confirmed, a payment from a system account is performed to the corresponding user account, automating the process of buying units. However, keep in mind the rates charged by PayPal, which vary according to some conditions.
To do so, first you'll need a PayPal premium or business account (for testing – using PayPal sandbox – any account is enough). You'll need to go to the PayPal Developer page to create an application on >REST API apps>, and get the client id and secret.
Then several configurations are required in Cyclos. Scripts can only be created as global administrators switched to a network, so it is advised to use a global admin to perform the configuration. Carefully follow each of the following steps:
Make sure that the configuration for users use a correct root url. In System > System configuration > Configurations, select the configuration set for users and make sure the Main URL field points to the correct external URL. It will be used to generate the links which will be sent to PayPal redirect users back to Cyclos after confirming / canceling the operation.
This can be checked under System > Currencies select the currency used for this operation, mark the Enable transfer number option and fill in the required parameters.
Under System > System configuration > Record types, create a new system record type, with the following characteristics:
For this record type, create the following fields:
Under System > System configuration > Record types, create a new user record type, with the following characteristics:
For this record type, create the following fields:
Under System > Tools > Scripts, create a new library script, with the following characteristics:
# Settings for the access token record type auth.recordType = paypalAuth auth.clientId = clientId auth.clientSecret = clientSecret auth.token = token auth.tokenExpiration = tokenExpiration # Settings for the payment record type payment.recordType = paypalPayment payment.paymentId = paymentId payment.amount = amount payment.transaction = transaction # Settings for PayPal mode = sandbox currency = EUR paymentDescription = Buy Cyclos units # Settings for the Cyclos payment amountMultiplier = 1 accountType = debitUnits paymentType = paypalCredits # Messages error.invalidRequest = Invalid request error.transactionNotFound = Transaction not found error.transactionAlreadyApproved = The transaction was already approved error.payment = There was an error while processing the payment. Please, try again. error.notApproved = The payment was not approved message.canceled = You have cancelled the operation.\nFeel free to start again if needed. message.done = You have successfully completed the payment. Thank you.
import static groovyx.net.http.ContentType.* import static groovyx.net.http.Method.* import java.util.concurrent.CountDownLatch import org.apache.commons.codec.binary.Base64 import org.cyclos.entities.banking.PaymentTransferType import org.cyclos.entities.banking.SystemAccountType import org.cyclos.entities.users.RecordCustomField import org.cyclos.entities.users.SystemRecord import org.cyclos.entities.users.SystemRecordType import org.cyclos.entities.users.User import org.cyclos.entities.users.UserRecord import org.cyclos.entities.users.UserRecordType import org.cyclos.impl.banking.PaymentServiceLocal import org.cyclos.impl.messaging.AlertServiceLocal import org.cyclos.impl.system.ScriptHelper import org.cyclos.impl.users.RecordServiceLocal import org.cyclos.impl.utils.persistence.EntityManagerHandler import org.cyclos.model.EntityNotFoundException import org.cyclos.model.banking.accounts.SystemAccountOwner import org.cyclos.model.banking.transactions.PaymentVO import org.cyclos.model.banking.transactions.PerformPaymentDTO import org.cyclos.model.banking.transfertypes.TransferTypeVO import org.cyclos.model.messaging.alerts.SystemAlertType import org.cyclos.model.users.records.RecordDataParams import org.cyclos.model.users.records.UserRecordDTO import org.cyclos.model.users.recordtypes.RecordTypeVO import org.cyclos.model.users.users.UserLocatorVO import org.cyclos.utils.ParameterStorage import groovy.transform.TypeChecked import groovy.transform.TypeCheckingMode import groovyx.net.http.HTTPBuilder import groovyx.net.http.Method /** * Class used to store / retrieve the authentication information for PayPal * A system record type is used, with the following fields: client id (string), * client secret (string), access token (string) and token expiration (date) */ @TypeChecked class PayPalAuth { String recordTypeName String clientIdName String clientSecretName String tokenName String tokenExpirationName SystemRecordType recordType SystemRecord record Map<String, Object> wrapped public PayPalAuth(Map<String, Object> variables) { def params = variables.scriptParameters as Map<String, Object> recordTypeName = params.'auth.recordType' ?: 'paypalAuth' clientIdName = params.'auth.clientId' ?: 'clientId' clientSecretName = params.'auth.clientSecret' ?: 'clientSecret' tokenName = params.'auth.token' ?: 'token' tokenExpirationName = params.'auth.tokenExpiration' ?: 'tokenExpiration' // Read the record type and the parameters for field internal names recordType = (variables.entityManagerHandler as EntityManagerHandler) .find(SystemRecordType, recordTypeName) // Should return the existing instance, of a single form type. // Otherwise it would be an error def dataParams = new RecordDataParams(recordType: new RecordTypeVO(id: recordType.id)) record = (variables.recordService as RecordServiceLocal) .newEntity(dataParams) as SystemRecord if (!record.persistent) throw new IllegalStateException( "No instance of system record ${recordType.name} was found") wrapped = (variables.scriptHelper as ScriptHelper).wrap(record, recordType.fields) } public String getClientId() { wrapped[clientIdName] } public String getClientSecret() { wrapped[clientSecretName] } public String getToken() { wrapped[tokenName] } public Date getTokenExpiration() { wrapped[tokenExpirationName] as Date } public void setClientId(String clientId) { wrapped[clientIdName] = clientId } public void setClientSecret(String clientSecret) { wrapped[clientSecretName] = clientSecret } public void setToken(String token) { wrapped[tokenName] = token } public void setTokenExpiration(Date tokenExpiration) { wrapped[tokenExpirationName] = tokenExpiration } } /** * Class used to store / retrieve PayPal payments as user records in Cyclos */ @TypeChecked class PayPalRecord { String recordTypeName String paymentIdName String amountName String transactionName UserRecordType recordType Map<String, RecordCustomField> fields private EntityManagerHandler entityManagerHandler private RecordServiceLocal recordService private ScriptHelper scriptHelper public PayPalRecord(Map<String, Object> variables) { def params = variables.scriptParameters as Map<String, Object> recordTypeName = params.'payment.recordType' ?: 'paypalPayment' paymentIdName = params.'payment.paymentId' ?: 'paymentId' amountName = params.'payment.amount' ?: 'amount' transactionName = params.'payment.transaction' ?: 'transaction' entityManagerHandler = variables.entityManagerHandler as EntityManagerHandler recordService = variables.recordService as RecordServiceLocal scriptHelper = variables.scriptHelper as ScriptHelper recordType = entityManagerHandler.find(UserRecordType, recordTypeName) fields = [:] recordType.fields.each {f -> fields[f.internalName] = f} } /** * Creates a payment record, for the given user and JSON, * as returned from PayPal's create payment REST method */ public UserRecord create(User user, Number amount) { RecordDataParams newParams = new RecordDataParams( [user: new UserLocatorVO(id: user.id), recordType: new RecordTypeVO(id: recordType.id)]) def dto = recordService.getDataForNew(newParams).getDto() as UserRecordDTO def wrapped = scriptHelper.wrap(dto, recordType.fields) wrapped[amountName] = amount // Save the record DTO and return the entity Long id = recordService.save(dto) return entityManagerHandler.find(UserRecord, id) } /** * Finds the record by id */ public UserRecord find(Long id) { try { UserRecord userRecord = entityManagerHandler.find(UserRecord, id) if (userRecord.type != recordType) { return null } return userRecord } catch (EntityNotFoundException e) { return null } } /** * Removes the given record, but only if it is of the * expected type and hasn't been confirmed */ public void remove(UserRecord userRecord) { if (userRecord.type != recordType) { return } Map<String, Object> wrapped = scriptHelper .wrap(userRecord, recordType.fields) if (wrapped[transactionName] != null) return entityManagerHandler.remove(userRecord) } } /** * Class used to interact with PayPal services */ @TypeChecked class PayPalService { String mode String baseUrl String currency String paymentDescription String accountTypeName String paymentTypeName double multiplier SystemAccountType accountType PaymentTransferType paymentType PayPalAuth auth PayPalRecord record private ScriptHelper scriptHelper private PaymentServiceLocal paymentService private AlertServiceLocal alertService private ParameterStorage storage private Map<String, Object> params PayPalService(Map<String, Object> variables) { this.auth = new PayPalAuth(variables) this.record = new PayPalRecord(variables) scriptHelper = variables.scriptHelper as ScriptHelper paymentService = variables.paymentService as PaymentServiceLocal alertService = variables.alertService as AlertServiceLocal storage = variables.parameterStorage as ParameterStorage params = variables.scriptParameters as Map<String, Object> mode = params.mode ?: 'sandbox' if (mode != 'sandbox' && mode != 'live') { throw new IllegalArgumentException("Invalid PayPal parameter " + "'mode': ${mode}. Should be either sandbox or live") } baseUrl = mode == 'sandbox' ? 'https://api.sandbox.paypal.com' : 'https://api.paypal.com' currency = params.currency if (currency == null || currency.empty) { throw new IllegalArgumentException("Missing PayPal parameter 'currency'") } def emh = variables.entityManagerHandler as EntityManagerHandler accountTypeName = params.accountType if (accountTypeName == null || accountTypeName.empty) throw new IllegalArgumentException("Missing PayPal parameter 'accountType'") paymentTypeName = params.paymentType if (paymentTypeName == null || paymentTypeName.empty) throw new IllegalArgumentException("Missing PayPal parameter 'paymentType'") accountType = emh.find(SystemAccountType, accountTypeName) if (!accountType.currency.transactionNumber?.used) { throw new IllegalStateException("Currency " + accountType.currency + " doesn't have transaction number enabled") } paymentType = emh.find(PaymentTransferType, paymentTypeName, accountType) multiplier = Double.parseDouble((params.amountMultiplier as String) ?: "1") paymentDescription = params.paymentDescription ?: "" } /** * Creates an order in PayPal and the corresponding user record */ Map<String, Object> createOrder(User user, Number amount, String callbackUrl) { // Create the UserRecord for this payment UserRecord userRecord = record.create(user, amount) //store the record's id to retrieve it after the payment was confirmed in PayPal storage['recordId'] = userRecord.id // Create the payment in PayPal def json = createOrder(amount, callbackUrl) //store the PayPal order id to retrieve it after the payment was confirmed in PayPal storage['orderId'] = json.id return json } /** * Creates an order in PayPal with a given amount, without updating any record */ Map<String, Object> createOrder(Number amount, String callbackUrl) { callbackUrl += callbackUrl.contains("?") ? "&" : "?" String returnUrl = "${callbackUrl}success=true" String cancelUrl = "${callbackUrl}cancel=true" def jsonBody = [ intent: "CAPTURE", application_context: [ return_url: returnUrl, cancel_url: cancelUrl, user_action: "PAY_NOW" ], purchase_units: [ [ description: paymentDescription, amount: [ value: amount, currency_code: currency ] ] ] ] // Create the payment in PayPal return performRequest("${baseUrl}/v2/checkout/orders", jsonBody, POST) } /** * Capture the order (execute the payment in PayPal) */ Map<String, Object> captureOrder(String orderId) { return performRequest("${baseUrl}/v2/checkout/orders/${orderId}/capture", null, POST) } /** * Get the order information from PayPal */ Map<String, Object> getOrderFromPayPal(String orderId) { return performRequest("${baseUrl}/v2/checkout/orders/${orderId}", null, GET) } /** * Executes a PayPal payment, and creates the payment in Cyclos */ Map<String, Object> execute(UserRecord userRecord) { def wrapped = scriptHelper.wrap(userRecord) def orderId = storage['orderId'] as String // Execute the payment in PayPal def capturedOrder = captureOrder(orderId) as Map<String, Object> // Update the payment id wrapped[record.paymentIdName] = getPaymentIdFromCapturedOrder(capturedOrder) def vo try { // Try to perform the payment in Cyclos, if it fails, refund the payment in PayPal vo = perform(capturedOrder, userRecord.user) } catch (Exception ex) { refundCapturedOrder(capturedOrder, userRecord, userRecord.user) throw ex } if (vo != null) { // Update the record, setting the linked transaction wrapped[record.transactionName] = vo userRecord.lastModifiedDate = new Date() } return capturedOrder } /** * Refund the completed order using the refund link returned when the * order was captured and remove the user record if given */ void refundCapturedOrder(Map<String, Object> capturedOrder, UserRecord userRecord, User user) { def refundLink = getRefundLinkFromCapturedOrder(capturedOrder) if (refundLink) { def refundedOrder try { // Make the refund refundedOrder = performRequest(refundLink, null, POST) } catch (Exception ex) { //Do nothing because an alert is going to be created } if (!refundedOrder || refundedOrder.status != "COMPLETED") { createRefundFailAlert(capturedOrder, user) } else if (userRecord) { record.remove(userRecord) } } } /** * Create the system alert for a failed refund */ private void createRefundFailAlert(Map<String, Object> capturedOrder, User user) { def errorMessage = """User: ${user.username}. The PayPal payment (${getPaymentIdFromCapturedOrder(capturedOrder)}) was completed, but there was an error in Cyclos and the attempt to refund in PayPal failed.""" alertService.create(SystemAlertType.CUSTOM, errorMessage) } /** * Performs the payment in Cyclos */ PaymentVO perform(Map<String, Object> capturedOrder, User subject) { if (getPaymentStatusFromCapturedOrder(capturedOrder) == 'COMPLETED') { def amount = new BigDecimal(getAmountFromCapturedOrder(capturedOrder) as String) BigDecimal finalAmount = amount * multiplier // Perform the payment in Cyclos PerformPaymentDTO dto = new PerformPaymentDTO() dto.owner = SystemAccountOwner.instance() dto.subject = subject dto.amount = finalAmount dto.type = new TransferTypeVO(paymentType.id) return paymentService.perform(dto) } else { return null } } /** * Get the payment id of a captured order result */ @TypeChecked(TypeCheckingMode.SKIP) String getPaymentIdFromCapturedOrder(capturedOrder) { return capturedOrder.purchase_units[0].payments.captures[0].id } /** * Get the payment status of a captured order result. * We must get the status from the captured payment * because it will be NOT completed even when the order status is completed */ @TypeChecked(TypeCheckingMode.SKIP) String getPaymentStatusFromCapturedOrder(capturedOrder) { return capturedOrder.purchase_units[0].payments.captures[0].status } /** * Get the amount of a captured order result */ @TypeChecked(TypeCheckingMode.SKIP) String getAmountFromCapturedOrder(capturedOrder) { return capturedOrder.purchase_units[0].payments.captures[0].amount.value } /** * Get the refund link of a captured order result */ @TypeChecked(TypeCheckingMode.SKIP) String getRefundLinkFromCapturedOrder(capturedOrder) { return capturedOrder.purchase_units[0].payments.captures[0].links.find {it.rel == "refund"}?.href } /** * Performs a synchronous request, posting and accepting JSON */ @TypeChecked(TypeCheckingMode.SKIP) Map<String, Object> performRequest(url, jsonBody, Method method) { def http = new HTTPBuilder(url) CountDownLatch latch = new CountDownLatch(1) Map<String, Object> responseJson = null def responseError = [] // Check if we need a new token if (auth.token == null || auth.tokenExpiration < new Date()) { refreshToken() } // Perform the request http.request(method, JSON) { headers.'Authorization' = "Bearer ${auth.token}" headers.'Content-Type' = "application/json" body = jsonBody response.success = { resp, json -> responseJson = json as Map<String, Object> latch.countDown() } response.failure = { resp -> responseError << resp.statusLine.statusCode responseError << resp.statusLine.reasonPhrase latch.countDown() } } latch.await() if (!responseError.empty) { throw new RuntimeException("Error making PayPal request to ${url}" + ", got error code ${responseError[0]}: ${responseError[1]}") } return responseJson } /** * Refreshes the access token */ @TypeChecked(TypeCheckingMode.SKIP) private void refreshToken() { def http = new HTTPBuilder("${baseUrl}/v1/oauth2/token") CountDownLatch latch = new CountDownLatch(1) Map<String, Object> responseJson = null def responseError = [] http.request(POST, JSON) { String auth = Base64.encodeBase64String((auth.clientId + ":" + auth.clientSecret).getBytes("UTF-8")) headers.'Accept-Language' = 'en_US' headers.'Authorization' = "Basic ${auth}" send URLENC, [ grant_type: "client_credentials" ] response.success = { resp, json -> responseJson = json as Map<String, Object> latch.countDown() } response.failure = { resp -> responseError << resp.statusLine.statusCode responseError << resp.statusLine.reasonPhrase latch.countDown() } } latch.await() if (!responseError.empty) { throw new RuntimeException("Error getting PayPal token, " + "got error code ${responseError[0]}: ${responseError[1]}") } // Update the authentication data auth.token = responseJson.access_token auth.tokenExpiration = new Date(System.currentTimeMillis() + (((responseJson.expires_in as Integer) - 30) * 1000)) } }
Under System > Tools > Scripts, create a new custom operation script, with the following characteristics:
import org.cyclos.entities.users.User
import groovy.transform.TypeChecked
@TypeChecked
def createPayment(){
def variables = binding.variables
def formParameters = variables.formParameters as Map<String, Object>
def service = new PayPalService(variables)
def user = variables.user as User
def amount = formParameters.amount as Number
def returnUrl = variables.returnUrl as String
def result = service.createOrder(user, amount, returnUrl)
def links = result.links as Map<String, Object>[]
def link = links.find {it.rel == "approve"}
if (link) {
return link.href
} else {
throw new IllegalStateException("No approval url returned from PayPal")
}
}
createPayment()import org.cyclos.entities.users.UserRecord
import org.cyclos.impl.system.ScriptHelper
import org.cyclos.model.utils.RequestInfo
import org.cyclos.server.utils.ObjectParameterStorage
import groovy.transform.TypeChecked
@TypeChecked
def payPalCallback() {
def variables = binding.variables as Map<String, Object>
def scriptParameters = variables.scriptParameters as Map<String, Object>
def service = new PayPalService(variables)
def storage = variables.storage as ObjectParameterStorage
def recordId = storage['recordId'] as Long
def request = variables.request as RequestInfo
def record = service.record
// No record?
if (recordId == null) {
return "[ERROR] " +
(scriptParameters.'error.invalidRequest' ?: "Invalid request")
}
// Find the corresponding record
UserRecord userRecord = record.find(recordId)
if (userRecord == null) {
return "[ERROR] " +
(scriptParameters.'error.transactionNotFound'
?: "Transaction not found")
}
def wrapped = (variables.scriptHelper as ScriptHelper).wrap(userRecord)
if (request.getParameter("cancel")) {
// The operation has been canceled.
// Remove the record and send a message.
record.remove(userRecord)
return "[WARN]" + scriptParameters.'message.canceled'
?: "You have cancelled the operation.\nFeel free to start again if needed."
} else {
// Execute the payment
try {
def order = service.execute(userRecord)
if (service.getPaymentStatusFromCapturedOrder(order) == 'COMPLETED') {
return scriptParameters.'message.done'
?: "You have successfully completed the payment. Thank you."
} else {
return "[ERROR] " + scriptParameters.'error.notApproved'
?: "The payment was not approved"
}
} catch (Exception e) {
return "[ERROR] " + scriptParameters.'error.payment'
?: "There was an error while processing the payment. Please, try again."
}
}
}
payPalCallback()
Under System > Tools > Custom operations, create a new one with the following characteristics:
For this custom operation create the following form field:
Under System > Accounts configuration > Account types, choose the (normally unlimited) account from which payments will be performed to users. Then set its internal name to some meaningful name. The example configuration uses debitUnits as internal name, but it can be changed. Save the form.
Still in the details page for the account type, on the Transfer types tab, create a new Payment transfer type with the following characteristics:
Under System > User configuration > Groups, select the Network administrators group. Then, in the Permissions tab:
Click System > System records > Paypal authentication. If this menu entry is not showing up, refresh the browser page (by pressing F5) and try again. Update the Client ID and Client Secret fields exactly with the ones you got in the application you registered in the PayPal Developer page. Remember that PayPal has a sandbox, which can be used to test the application, and a live environment. For now, use the sandbox credentials. The other 2 fields can be left blank. Save the record.
Once the record is properly set, if you want to remove it from the menu, you can just remove the permission to view this system record in the adminitrator group page.
In System > User configuration > Products (permissions), select the member product for users which will run the operation.
In the PayPal library script, in parameters, there are several configurations which can be done. All those settings can be overridden in the custom operation's script parameters, allowing using distinct configurations for distinct operations. For example, it is possible to have distinct operations to perform payments in distinct currencies. In that case, the script parameters for each operation would define the currency again.
Here are some elements which can be configured:
Make sure the payment type is from an unlimited account, so payments in Cyclos won't fail because of funds. The way the example script is done, first the payment is executed in PayPal and, if authorized, a payment is made in Cyclos. If this payment fails, to avoid inconsistency between the Cyclos account and the PayPal payment, a refund payment is performed in PayPal. If that refund fails, it is created a Custom system alert so it is advisable to have admins receving that type of alert.
Loan features in Cyclos 4 can be implemented using scripting. As loans tend to be very specific for each project, having it implemented with scripts brings the possibility to tailor the behavior to each project.
The example provided works as follows:
In order to configure the loan script, follow carefully each of the following steps:
This can be checked under System > Currencies select the currency used for this operation, mark the Enable transfer number option and fill in the required parameters.
Under System > Accounts configuration > Transfer status flows, create a new one, with the following characteristics:
After saving, create the following statuses:
Under System > Accounts configuration > Payment fields, create a new one, with the following fields:
Under System > Accounts configuration > Account types, choose the (normally unlimited) account from which payments will be performed to users. Then set its internal name to some meaningful name. The example configuration uses debitUnits as internal name, but it can be changed later. Save the form.
Still in the system account type details page for the account type, on the Transfer types tab, create a new Payment transfer type with the following characteristics:
After saving, on the "Payment fields" tab, add the following custom fields:
If the loan can go through authorization, then create an authorization role in System > Account configuration > Authorization roles. Then, in the payment type details check the "Requires authorization" field. After saving, in the "Authorization levels" tab, add a new authorization level with that role. Afterwards, grant some administrator group the permission to manage that authorization role.
Under System > Accounts configuration > Account types, choose the user account which will receive payments. Then set its internal name to some meaningful name. The example configuration uses userUnits as internal name, but it can be changed later. Save the form.
Still in the user account type details page, on the Transfer types tab, create a new Payment transfer type with the following characteristics:
After saving, on the Payment fields tab, add the custom field named "Loan".
Under System > Tools > Scripts, create a new library script, with the following characteristics:
# Loan configuration loan.account = debitUnits loan.type = loanGrant #loan.description = # Repayment configuration repayment.account = userUnits repayment.type = loanRepayment #repayment.description = # Payment custom fields field.loan = loan field.repayment = repayment # Monthly compound interest rate (zero for none) monthlyInterestRate = 0 # Transfer status configuration status.flow = loan status.open = open status.closed = closed # Custom operation configuration operation.amount = amount operation.installments = numberOfInstallments operation.firstDueDate = firstDueDate # Messages message.invalidInstallments = The number of installments is invalid message.invalidLoanAmount = Invalid loan amount message.invalidFirstDueDate = The first due date cannot be lower than tomorrow message.loanGranted = The loan was successfully granted message.loanGranted.pending = The loan was granted and is now pending authorization message.authorization.expired = The loan cannot be authorized as the first due date is over
import org.cyclos.entities.banking.Payment import org.cyclos.entities.banking.PaymentTransferType import org.cyclos.entities.banking.ScheduledPayment import org.cyclos.entities.banking.SystemAccountType import org.cyclos.entities.banking.TransactionCustomField import org.cyclos.entities.banking.Transfer import org.cyclos.entities.banking.TransferStatus import org.cyclos.entities.banking.TransferStatusFlow import org.cyclos.entities.banking.UserAccountType import org.cyclos.entities.users.User import org.cyclos.impl.access.SessionData import org.cyclos.impl.banking.PaymentServiceLocal import org.cyclos.impl.banking.ScheduledPaymentServiceLocal import org.cyclos.impl.banking.TransferStatusServiceLocal import org.cyclos.impl.system.ConfigurationAccessor import org.cyclos.impl.system.ScriptHelper import org.cyclos.impl.utils.persistence.EntityManagerHandler import org.cyclos.model.ValidationException import org.cyclos.model.banking.accounts.SystemAccountOwner import org.cyclos.model.banking.transactions.InstallmentDTO import org.cyclos.model.banking.transactions.PaymentVO import org.cyclos.model.banking.transactions.PerformPaymentDTO import org.cyclos.model.banking.transactions.PerformScheduledPaymentDTO import org.cyclos.model.banking.transactions.ScheduledPaymentVO import org.cyclos.model.banking.transfers.TransferVO import org.cyclos.model.banking.transferstatus.ChangeTransferStatusDTO import org.cyclos.model.banking.transferstatus.TransferStatusVO import org.cyclos.model.banking.transfertypes.TransferTypeVO import org.cyclos.model.utils.TimeField import org.cyclos.server.utils.DateHelper import org.cyclos.utils.BigDecimalHelper import groovy.transform.TypeChecked @TypeChecked class Loan { Map<String, Object> config EntityManagerHandler emh PaymentServiceLocal paymentService ScheduledPaymentServiceLocal scheduledPaymentService TransferStatusServiceLocal transferStatusService ScriptHelper scriptHelper ConfigurationAccessor configuration double monthlyInterestRate SystemAccountType systemAccount UserAccountType userAccount PaymentTransferType loanType PaymentTransferType repaymentType TransactionCustomField loanField TransactionCustomField repaymentField TransferStatusFlow flow TransferStatus open TransferStatus closed Loan(Binding binding) { def variables = binding.variables config = [:] def params = variables.scriptParameters as Map<String, Object> [ 'loan.account': 'systemAccount', 'loan.type': 'loanGrant', 'loan.description': null, 'repayment.account': 'userUnits', 'repayment.type': 'loanRepayment', 'repayment.description': null, 'field.loan': 'loan', 'field.repayment': 'repayment', 'monthlyInterestRate' : null, 'status.flow': 'loan', 'status.open': 'open', 'status.closed': 'closed', 'operation.amount': 'amount', 'operation.installments': 'installments', 'operation.firstDueDate': 'firstDueDate', 'message.invalidInstallments': 'The number of installments is invalid', 'message.invalidLoanAmount': 'Invalid loan amount', 'message.invalidFirstDueDate': 'The first due date cannot be lower than tomorrow', 'message.loanGranted': 'The loan was successfully granted to the user', 'message.loanGranted.pending': 'The loan was granted and is now pending authorization', 'message.authorization.expired': 'The loan cannot be authorized as the first due date is over' ].each { k, v -> config[k] = params[k] ?: v } emh = variables.entityManagerHandler as EntityManagerHandler paymentService = variables.paymentService as PaymentServiceLocal scriptHelper = variables.scriptHelper as ScriptHelper scheduledPaymentService = variables.scheduledPaymentService as ScheduledPaymentServiceLocal transferStatusService = variables.transferStatusService as TransferStatusServiceLocal configuration = (variables.sessionData as SessionData).configuration as ConfigurationAccessor systemAccount = emh.find(SystemAccountType, config.'loan.account' as String) if (systemAccount.currency.transactionNumber == null || !systemAccount.currency.transactionNumber.used) { throw new IllegalStateException("The currency ${systemAccount.currency.name}" + " doesn't have transaction number enabled") } userAccount = emh.find(UserAccountType, config.'repayment.account' as String) loanType = emh.find(PaymentTransferType, config.'loan.type' as String, systemAccount) repaymentType = emh.find(PaymentTransferType, config.'repayment.type' as String, userAccount) if (!repaymentType.allowsScheduledPayments) { throw new IllegalStateException( "The repayment type ${repaymentType.name} doesn't allows scheduled payment") } loanField = emh.find(TransactionCustomField, config.'field.loan' as String) repaymentField = emh.find(TransactionCustomField, config.'field.repayment' as String) if (!loanType.customFields.contains(repaymentField)) { throw new IllegalStateException("The loan type ${loanType.name}" + " doesn't contain the custom field ${repaymentField.name}") } if (!repaymentType.customFields.contains(loanField)) { throw new IllegalStateException("The repayment type ${repaymentType.name}" + " doesn't contain the custom field ${loanField.name}") } flow = emh.find(TransferStatusFlow, config.'status.flow' as String) open = emh.find(TransferStatus,config.'status.open' as String, flow) closed = emh.find(TransferStatus, config.'status.closed' as String, flow) monthlyInterestRate = (config.'monthlyInterestRate' as String)?.toDouble() ?: 0 } BigDecimal calculateInstallmentAmount(BigDecimal amount, int installments, Date grantDate, Date firstInstallmentDate) { // Calculate the delay Date shouldBeFirstExpiration = DateHelper.add(grantDate, TimeField.DAYS, 30) int delay = (int) DateHelper.daysBetween(firstInstallmentDate, shouldBeFirstExpiration) if (delay < 0) { delay = 0 } double interest = monthlyInterestRate / 100.0 double numerator = ((1 + interest) ** (installments + delay / 30.0)) * interest double denominator = ((1 + interest) ** installments) - 1 BigDecimal result = amount * numerator / denominator return BigDecimalHelper.round(result, systemAccount.currency.precision) } void close(ScheduledPayment scheduledPayment) { def map = scriptHelper.wrap(scheduledPayment) Payment loan = map.get(loanField.internalName) as Payment Transfer loanTransfer = loan.transfer TransferStatus status = loanTransfer.getStatus(flow) if (status != closed) { // The loan was not closed: close it transferStatusService.changeStatus(new ChangeTransferStatusDTO([ transfer: new TransferVO(loanTransfer.id), newStatus: new TransferStatusVO(closed.id) ])) } } Payment grant(User user, Map<String, Object> formParameters) { BigDecimal loanAmount = formParameters[config.'operation.amount'] as BigDecimal int installments = formParameters[config.'operation.installments'] as int Date firstDueDate = formParameters[config.'operation.firstDueDate'] as Date Date minDate = DateHelper.shiftToNextDay(new Date(), configuration.timeZone) if (installments < 1 || installments > repaymentType.maxInstallments) throw new ValidationException(config.'message.invalidInstallments' as String) if (loanAmount < 1) throw new ValidationException(config.'message.invalidLoanAmount' as String) if (firstDueDate < minDate) throw new ValidationException(config.'message.invalidFirstDueDate' as String) // Grant the loan, copying the installments count and first due date PerformPaymentDTO perform = new PerformPaymentDTO([ from: SystemAccountOwner.instance(), to: user, type: new TransferTypeVO(loanType.id), amount: loanAmount, description: config.'loan.description' as String ]) def performBean = scriptHelper.wrap(perform) performBean[config.'operation.installments' as String] = installments performBean[config.'operation.firstDueDate' as String] = firstDueDate PaymentVO loanVO = paymentService.perform(perform) Payment loan = emh.find(Payment, loanVO.id) if (loan.transfer != null) { // The loan is processed. Create the repayment createRepayment(loan) } return loan } ScheduledPayment createRepayment(Payment payment) { Transfer loanTransfer = payment.transfer if (loanTransfer == null) { return null } TransferStatus currentStatus = loanTransfer.getStatus(flow) if (currentStatus != open) { throw new ValidationException( "The initial status for flow ${flow.name} in ${loanType.name} " + "is not the expected one: ${open.name}, but ${currentStatus?.name} instead") } // Read the scheduling information from the loan def loanBean = scriptHelper.wrap(payment) def existingRepayment = loanBean[repaymentField.internalName] if (existingRepayment != null) { return existingRepayment as ScheduledPayment } BigDecimal loanAmount = payment.amount Integer installments = loanBean[config.'operation.installments'] as Integer Date firstDueDate = loanBean[config.'operation.firstDueDate'] as Date // Make sure the first due date is not expired Date now = new Date() if (firstDueDate.before(now)) { throw new ValidationException(config.'message.authorization.expired' as String) } // Perform the repayment scheduled payment PerformScheduledPaymentDTO dto = new PerformScheduledPaymentDTO([ from: payment.toOwner, to: payment.fromOwner, type: new TransferTypeVO(repaymentType.id), amount: payment.amount, description: config.'repayment.description' as String ]) def dtoBean = scriptHelper.wrap(dto) dtoBean.installmentsCount = installments dtoBean.firstInstallmentDate = firstDueDate dtoBean[loanField.internalName] = payment // Interest if (monthlyInterestRate > 0.00001) { BigDecimal installmentAmount = calculateInstallmentAmount( loanAmount, installments, new Date(), firstDueDate) dto.installments = [] Date dueDate = firstDueDate for (int i = 0; i < installments; i++) { def installment = new InstallmentDTO() def instBean = scriptHelper.wrap(installment) instBean.dueDate = dueDate instBean.amount = installmentAmount dto.installments << installment dueDate = DateHelper.add(dueDate, TimeField.DAYS, 30) } dtoBean.amount = installmentAmount * installments } ScheduledPaymentVO repaymentVO = scheduledPaymentService.perform(dto) ScheduledPayment repayment = emh.find(ScheduledPayment, repaymentVO.id) // Update the loan with the repayment link loanBean[repaymentField.internalName] = repayment return repayment } } binding.variables.loan = new Loan(binding)
Create a new script for the custom operation, with the following characteristics:
import org.cyclos.entities.banking.Payment
import org.cyclos.entities.users.User
import groovy.transform.TypeChecked
@TypeChecked
def grantLoan() {
def variables = binding.variables
Loan loan = variables.loan as Loan
Payment payment = loan.grant(variables.user as User,
variables.formParameters as Map<String, Object>)
if (payment.transfer == null) {
return loan.config['message.loanGranted.pending']
} else {
return loan.config['message.loanGranted']
}
}
grantLoan()Create a new script for the transaction extension point, which will close the loan when all installments are processed:
import org.cyclos.entities.banking.ScheduledPayment
import org.cyclos.model.ValidationException
import org.cyclos.model.banking.transactions.ScheduledPaymentStatus
import groovy.transform.TypeChecked
@TypeChecked
def closeLoan() {
ScheduledPayment transaction = binding.variables.transaction as ScheduledPayment
if (transaction.status == ScheduledPaymentStatus.CANCELED) {
// Should never cancel a loan scheduled payment
throw new ValidationException("Cannot cancel a loan")
} else if (transaction.status == ScheduledPaymentStatus.CLOSED) {
// Close the loan
(binding.variables.loan as Loan).close(transaction)
}
}
closeLoan()Also, create another script for the authorization extension point, which will create the repayment scheduled payment once the loan is authorized:
import org.cyclos.entities.banking.Payment
import groovy.transform.TypeChecked
@TypeChecked
def createRepayment() {
Payment transaction = binding.variables.transaction as Payment
if (transaction.getTransfer() != null) {
// The transaction was authorized, create the repayment
(binding.variables.loan as Loan).createRepayment(transaction)
}
}
createRepayment()Under System > Tools > Custom operations, create a new one, with the following characteristics:
After saving, create the following fields:
Under System > Tools > Extension points, create a two new extension points, each with the following characteristics:
Under System > User configuration > Groups, select the Network administrators group (or the ones that will grant loans). Then, in the Permissions tab:
In System > User configuration > Products (permissions), select the member product for users which will be able to receive loans. In the Custom operations field, make the Grant loan operation enabled. Leave the run checkbox unchecked (or users would be able to grant loans to themselves!).
You can permit users to to repay loan installments anticipated in Units. For this you have to check in the member product 'process installment' and the user need to have permissions to make a payment of the transaction type used for the loan repayments.
This example allows enabling operations to be performed via USSD. As each USSD gateway has a different protocol, a generic solution is not available. This script assumes the USSD integration is provided by Global USSD.
The provided examples allows getting the account information and performing direct payments. The mobile phone number used in the USSD interaction must exist as a mobile phone in Cyclos for an active user.
It is recommended that a channel named USSD is created in Cyclos, so its settings won't affect other web service clients. For example, the script assumes there is no confirmation password. Also, having a separated channel allows a finer control for users if they want to enable or disable the channel. The steps below assume a specific channel is used.
On the System > System configuration > Channels menu, create a new channel, with the following fields:
On the System > System configuration > Configurations menu, select either the default or a specific configuration. On the channels tab, click USSD. Then fill in the fields as following:
Under System > Account configuration > Account types, select the user account. Then on the 'Transfer types' tab, create a new payment type with the following fields:
In the System > User configurations > Products (permissions), select a product which contains the account (or create a new one), adding the USSD payment type in 'User payments'.
Under System > Tools > Scripts, create a new library script, with the following characteristics:
### Settings # The session timeout, in seconds sessionTimeout=60 # The channel internal name which will be used for the operations channel=ussd ### Translations mainMenu.title=Main menu mainMenu.accountInfo=Account information mainMenu.payment=Perform payment accountInfo.type=Account accountInfo.balance=Balance: {0} accountInfo.reservedAmount=Reserved: {0} accountInfo.creditLimit=Negative limit: {0} accountInfo.availableBalance=Available: {0} accountInfo.noAccount=You don't have any account accountInfo.error.type=The account {0} is invalid payment.payee=Pay to user payment.error.payee=The user {0} is invalid payment.noPaymentType=No possible payment type to pay to {0} using this channel payment.type=Payment type payment.error.type=The payment type {0} is invalid payment.amount=Amount payment.error.amount=The amount is invalid: {0} payment.confirmation=Are you sure to pay {0} to {1}, with type {2}? payment.performed=You have successfully paid {0} to {1}, with type {2} payment.error.general=There was an unknown error when performing the payment payment.error.balance=There is no available balance to perform this payment payment.error.maxAmount=The maximum amount has been exceeded for this period payment.error.maxPayments=The maximum number of payments has been exceeded for this period payment.error.minTime=The minimum time between the last payment has not yet passed password.error.invalid={0} is invalid password.error.blocked={0} has been blocked general.submit=Submit general.unregisteredPhone=Your phone number, {0}, is not registered in Cyclos general.sessionExpired=Your session has expired. Please, restart the operation. general.returnToMainMenu=(Input 0 to return to Main Menu)" general.actionAborted=The action {0} was aborted
import java.text.MessageFormat
import org.cyclos.entities.banking.PaymentTransferType
import org.cyclos.entities.users.MobilePhone
import org.cyclos.entities.utils.CurrencyAmount
import org.cyclos.impl.access.SessionData
import org.cyclos.model.EntityNotFoundException
import org.cyclos.model.ValidationException
import org.cyclos.model.access.IndefinitelyBlockedPasswordException
import org.cyclos.model.access.TemporarilyBlockedPasswordException
import org.cyclos.model.banking.InsufficientBalanceException
import org.cyclos.model.banking.MaxAmountExceededException
import org.cyclos.model.banking.MaxPaymentsExceededException
import org.cyclos.model.banking.MinTimeBetweenPaymentsException
import org.cyclos.model.banking.accounts.AccountOwner
import org.cyclos.model.banking.accounts.AccountVO
import org.cyclos.model.banking.accounts.AccountWithStatusVO
import org.cyclos.model.banking.transactions.PaymentVO
import org.cyclos.model.banking.transactions.PerformPaymentDTO
import org.cyclos.model.banking.transfertypes.TransferTypeVO
import org.cyclos.model.users.users.UserLocatorVO
import org.cyclos.model.utils.ModelHelper
import org.cyclos.server.utils.ObjectParameterStorage
import org.cyclos.utils.BigDecimalHelper
import org.cyclos.utils.StringHelper
class Pages {
static String MAIN_MENU = "mainMenu"
static String ACBALANCE_ASKACCOUNT = "acBalanceAskAccount"
static String ACBALANCE_ASKPASSWORD = "acBalanceAskPassword"
static String ACBALANCE_DISPLAY = "acBalanceDisplay"
static String PAYMENT_ASKPAYEE = "payAskPayee"
static String PAYMENT_ASKAMOUNT = "payAskAmount"
static String PAYMENT_ASKPAYMENTTYPE = "payAskPaymentType"
static String PAYMENT_ASKPASSWORD = "payAskPassword"
static String PAYMENT_PERFORM = "payPerform"
}
class UssdHandler {
MobilePhone phone
SessionData userSessionData
ObjectParameterStorage session
boolean newSession
def binding
static void newXmlMessage(def xml, String message) {
if (StringHelper.isBlank(message)) {
return
}
xml.div(message)
xml.div("")
}
UssdHandler(MobilePhone phone, SessionData userSessionData, Object binding) {
this.phone = phone
this.userSessionData = userSessionData
this.binding = binding
def sessionKey = "ussd_" + phone.normalizedNumber
newSession = !binding.scriptStorageHandler.exists(sessionKey)
session = binding.scriptStorageHandler.get(sessionKey,
binding.scriptParameters.sessionTimeout as int)
}
boolean isNewSession() {
newSession
}
Object propertyMissing(String name) {
binding[name]
}
Object methodMissing(String name, args) {
throw new EntityNotFoundException(entityType: "UssdOperation", key: name)
}
/** Ask for the confirmation password */
private void askPassword(def xml, String pageToSend,
String title, String message) {
newXmlMessage(xml, message)
xml.div() {
xml.input(
navigationId: "form",
title: title,
name: "PASSWORD",
type: "number")
}
xml.div(scriptParameters["general.returnToMainMenu"])
xml.navigation(id: "form"){
xml.link(
pageId : pageToSend,
scriptParameters["general.submit"])
}
}
/** Ask for the payment receiver */
private void askPayee(def xml, String message) {
newXmlMessage(xml, message)
xml.div() {
xml.input(
navigationId: "form",
title: scriptParameters["payment.payee"],
name: "PAYEE",
type: "Text")
}
xml.div(scriptParameters["general.returnToMainMenu"])
xml.navigation(id: "form") {
xml.link(
pageId : Pages.PAYMENT_ASKPAYMENTTYPE,
scriptParameters["general.submit"])
}
}
/** Ask for the payment type */
private void askPaymentType(def xml, String message) {
def paymentTypes = (session.paymentTypes ?: [:]).collectEntries({ k, v ->
[
k,
entityManagerHandler.find(PaymentTransferType, v)
]
})
if (paymentTypes.size() == 1) {
// There is a single payment type - store it and ask the amount
request.parameters.PAYMENT_TYPE = "1"
payAskAmount(xml, "")
return
}
newXmlMessage(xml, message)
// Generate the option list
paymentTypes.each {
xml.div("${it.key}: ${it.value.name}")
}
// Generate the form to allow user choose
xml.div() {
xml.input(navigationId: "form",
title: scriptParameters["payment.type"],
name: "PAYMENT_TYPE",
type: "number")
}
xml.navigation(id: "form"){
xml.link(pageId : Pages.PAYMENT_ASKAMOUNT,
scriptParameters["general.submit"])
}
}
/** Ask for the payment amount */
private void askAmount(def xml, String message) {
newXmlMessage(xml, message)
xml.div() {
xml.input(navigationId: "form",
title: scriptParameters["payment.amount"],
name: "AMOUNT",
type: "number")
}
xml.div(scriptParameters["general.returnToMainMenu"])
xml.navigation(id: "form") {
xml.link(pageId : Pages.PAYMENT_ASKPASSWORD,
scriptParameters["general.submit"])
}
}
/** Check for a password, either returning true and don't touching
* the XML or returning false and sending an error in the XML */
private boolean checkPassword(def xml, String password, String nextPage) {
def accessPassword = userSessionData.channelAccessAccessor.accessPassword
try {
passwordHandler.checkPassword(false,
accessPassword,
userSessionData.loggedUser,
password)
return true
} catch (TemporarilyBlockedPasswordException |
IndefinitelyBlockedPasswordException e) {
askPassword(xml, nextPage,
accessPassword.name,
MessageFormat.format(scriptParameters["password.error.blocked"], accessPassword.name))
return false
} catch (Exception e) {
askPassword(xml, nextPage,
accessPassword.name,
MessageFormat.format(scriptParameters["password.error.invalid"], accessPassword.name))
return false
}
}
/** Performs the payment, returning the result if succeed or sending the XML
* error if not */
private PaymentVO performPayment(def xml, PerformPaymentDTO dto) {
try {
return paymentService.perform(dto)
} catch (ValidationException e) {
mainMenu(xml, e.validation?.firstError)
} catch (InsufficientBalanceException e) {
mainMenu(xml, scriptParameters["payment.error.balance"])
} catch (MaxAmountExceededException e) {
mainMenu(xml, scriptParameters["payment.error.maxAmount"])
} catch (MaxPaymentsExceededException e) {
mainMenu(xml, scriptParameters["payment.error.maxPayments"])
} catch (MinTimeBetweenPaymentsException e) {
mainMenu(xml, scriptParameters["payment.error.minTime"])
} catch (Exception e) {
mainMenu(xml, scriptParameters["payment.error.general"])
}
// there was some error
return null
}
/** Removes all payment-related attributes from the session */
private void clearSessionPayment() {
[
"payee",
"paymentTypes",
"paymentType",
"amount"
].forEach(session.&remove)
}
/** Handler for Pages.MAIN_MENU */
String mainMenu(def xml, String message) {
newXmlMessage(xml, message)
xml.navigation() {
xml.link(
accesskey: "1",
pageId: Pages.ACBALANCE_ASKACCOUNT,
scriptParameters["mainMenu.accountInfo"])
xml.link(
accesskey: "2",
pageId: Pages.PAYMENT_ASKPAYEE,
scriptParameters["mainMenu.payment"])
}
// Clear the session attributes for specific actions
clearSessionPayment()
}
/** Handler for Pages.ACBALANCE_ASKACCOUNT */
void acBalanceAskAccount(def xml, String message) {
List<AccountWithStatusVO> accountSummaries =
binding.accountService.getAccountsSummary(userSessionData.loggedUser,
null)
if (accountSummaries.isEmpty()) {
mainMenu(xml, scriptParameters["accountInfo.noAccount"])
return
}
// create a map with visible accounts and add this to context
def accounts = [:]
def option = 1
accountSummaries.each { a ->
accounts."${option}" = a
option++
}
session.accounts = accounts
askAccount(xml, null)
}
/** Handler for Pages.ASKACCOUNT */
void askAccount(def xml, String message) {
def accounts = session.accounts
if (accounts.size() == 1) {
request.parameters.ACCOUNT = "1"
acBalanceAskPassword(xml, null)
return
}
newXmlMessage(xml, message)
// Generate the option list
def key = 1
accounts.each {
xml.div("${it.key}: ${it.value.type.name}")
}
// Generate the form to allow user choose
xml.div() {
xml.input(navigationId: "form",
title: scriptParameters["accountInfo.type"],
name: "ACCOUNT",
type: "number")
}
xml.navigation(id: "form"){
xml.link(pageId : Pages.ACBALANCE_ASKPASSWORD,
scriptParameters["general.submit"])
}
}
/** Handler for Pages.ACBALANCE_ASKPASSWORD */
void acBalanceAskPassword(def xml, String message) {
def acc = request.parameters.ACCOUNT
// Check whether to return to the main menu
if (acc == "0") {
mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
scriptParameters["mainMenu.accountInfo"]))
return
}
// Validate the Account
def accounts = session.accounts
if (!accounts.containsKey(acc)) {
askAccount(xml,
MessageFormat.format(scriptParameters["accountInfo.error.type"], acc))
return
}
// Store the account summary type in the session
session.accountId = accounts[acc].id
askPassword(xml, Pages.ACBALANCE_DISPLAY,
userSessionData.channelAccessAccessor.accessPassword.name,
null)
}
/** Handler for Pages.ACBALANCE_DISPLAY */
void acBalanceDisplay(def xml, String message) {
def password = request.parameters.PASSWORD
// Check whether to return to the main menu
if (password == "0") {
mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
scriptParameters["mainMenu.accountInfo"]))
return
}
// Check PASSWORD
if (!checkPassword(xml, password, Pages.ACBALANCE_DISPLAY)) {
return
}
// Generate the Balance result string
def account = accountService.getAccountWithStatus(
new AccountVO(session.accountId as long), null)
xml.div(account.type.name)
def status = account.status
def balance = formatter.format(
ModelHelper.currencyAmount(account.currency,
status.balance))
xml.div(MessageFormat.format(scriptParameters["accountInfo.balance"],
balance))
if (BigDecimalHelper.isPositive(status.reservedAmount)) {
def reservedAmount = formatter.format(
ModelHelper.currencyAmount(account.currency,
status.reservedAmount))
xml.div(MessageFormat.format(
scriptParameters["accountInfo.reservedAmount"],
reservedAmount))
}
if (BigDecimalHelper.isPositive(status.creditLimit)) {
def creditLimit = formatter.format(
ModelHelper.currencyAmount(
account.currency,
status.creditLimit))
xml.div(MessageFormat.format(
scriptParameters["accountInfo.creditLimit"],
creditLimit))
}
if (!BigDecimalHelper.areEquals(status.balance,
status.availableBalance)) {
def availableBalance = formatter.format(
ModelHelper.currencyAmount(account.currency,
status.availableBalance))
xml.div(MessageFormat.format(
scriptParameters["accountInfo.availableBalance"],
availableBalance))
}
xml.navigation() {
xml.link(
accesskey : "0",
pageId : Pages.MAIN_MENU,
scriptParameters["mainMenu.title"])
}
}
/** Handler for Pages.PAYMENT_ASKPAYEE */
void payAskPayee(def xml, String message) {
askPayee(xml, null)
}
/** Handler for Pages.PAY_ASK_PAYMENT_TYPE */
void payAskPaymentType(def xml, String message) {
def payee = request.parameters.PAYEE
// Check whether to return to the main menu
if (payee == "0") {
mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
scriptParameters["mainMenu.payment"]))
return
}
// Validate the payee
AccountOwner accOwnerPayee
def locator = new UserLocatorVO(principal: payee)
try {
accOwnerPayee = transactionService.locateForPayment(locator).accountOwner
} catch (Exception e) {
askPayee(xml,
MessageFormat.format(scriptParameters["payment.error.payee"], payee))
return
}
// Add the payee to session context
session.payee = accOwnerPayee
// Get the allowed TT between payer and payee and generate options
def payer = userSessionData.loggedUser
def paymentData = transactionService.getPaymentToOwnerData(payer,
accOwnerPayee, null)
if (paymentData.paymentTypes.size == 0) {
askPayee(xml,
MessageFormat.format(scriptParameters["payment.noPaymentType"],
payee))
return
} else {
// create a map with allowed paymentTypes and add this to context
def paymentTypes = [:]
def option = 1
paymentData.paymentTypes.each { tt ->
paymentTypes."${option}" = conversionHandler.convert(PaymentTransferType,
tt)
option++
}
session.paymentTypes = paymentTypes
// Ask the payment type
askPaymentType(xml, null)
}
}
/** Handler for Pages.PAY_ASKAMOUNT */
void payAskAmount(def xml, String message) {
def tt = request.parameters.PAYMENT_TYPE
// Check whether to return to the main menu
if (tt == "0") {
mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
scriptParameters["mainMenu.payment"]))
return
}
// Validate the TT
def paymentTypes = session.paymentTypes
if (!paymentTypes.containsKey(tt)) {
askPaymentType(xml,
MessageFormat.format(scriptParameters["payment.error.type"], tt))
return
}
// Store the payment type in the session
session.paymentType = entityManagerHandler.find(PaymentTransferType,
paymentTypes[tt])
// Ask the amount
askAmount(xml, null)
}
/** Handler for Pages.PAYMENT_ASKPASSWORD */
void payAskPassword(def xml, String message) {
def amt = request.parameters.AMOUNT
// Check whether to return to the main menu
if (amt == "0") {
mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
scriptParameters["mainMenu.payment"]))
return
}
// Validate the AMOUNT
BigDecimal amount
try {
amount = new BigDecimal(amt)
} catch (Exception e) {
askAmount(xml, MessageFormat.format(scriptParameters["payment.error.amount"],
amt))
return
}
// Add the amount to the session
session.amount = amount
// Now ask the password
askPassword(xml, Pages.PAYMENT_PERFORM,
userSessionData.channelAccessAccessor.accessPassword.name,
getPaymentMessage(scriptParameters["payment.confirmation"]))
}
/** Handler for Pages.PAYMENT_PERFORM */
void payPerform(def xml, String message) {
def password = request.parameters.PASSWORD
// Check whether to return to the main menu
if (password == "0") {
mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
scriptParameters["mainMenu.payment"]))
return
}
// first validate PASSWORD
checkPassword(xml, password, Pages.PAYMENT_PERFORM)
// Build the PerformPaymentDTO
def dto = new PerformPaymentDTO()
dto.from = userSessionData.loggedUser
dto.to = session.payee
dto.amount = session.amount
dto.type = new TransferTypeVO(session.paymentType.id)
// perform the payment
def result = performPayment(xml, dto)
if (result) {
// Only handle the success, because on failure the XML is already sent
mainMenu(xml, getPaymentMessage(scriptParameters["payment.performed"]))
}
}
String getPaymentMessage(String template) {
def paymentType = session.paymentType
if (paymentType == null || session.amount == null) return null
def amount = new CurrencyAmount(paymentType.currency, session.amount)
return MessageFormat.format(template,
formatter.format(amount),
formatter.format(session.payee),
formatter.format(paymentType))
}
}
Create a new script for the custom web service, with the following characteristics:
import java.text.MessageFormat
import org.apache.commons.lang3.StringUtils
import org.cyclos.entities.users.MobilePhone
import org.cyclos.entities.users.QMobilePhone
import org.cyclos.impl.access.SessionDataFactory
import org.cyclos.model.users.users.UserStatus
import org.cyclos.model.utils.ResponseInfo
import groovy.xml.MarkupBuilder
// The XML builder will write to a StringWriter
def stringWriter = new StringWriter()
def xml = new MarkupBuilder(stringWriter)
xml.doubleQuotes = true
xml.omitNullAttributes = true
xml.mkp.xmlDeclaration(version:"1.0", encoding: "UTF-8")
// Resolve the normalized international phone number via the subscriber parameter
String phoneNumber = StringUtils.trimToNull(request.parameters.subscriber)
MobilePhone mobilePhone = null;
if (phoneNumber == null) {
return new ResponseInfo(422, "The subscriber parameter is missing")
} else {
// Find the mobile phone in Cyclos
phoneNumber = "+" + StringUtils.removeStart(phoneNumber, "+");
def mp = QMobilePhone.mobilePhone;
mobilePhone = entityManagerHandler.from(mp)
.where(mp.normalizedNumber.eq(phoneNumber),
mp.user().status.eq(UserStatus.ACTIVE))
.singleResult(mp)
}
if (mobilePhone == null) {
// The mobile phone is not found in Cyclos
xml.page(version: "2.0") {
div(MessageFormat.format(scriptParameters['general.unregisteredPhone'],
phoneNumber))
}
} else {
// Get session or create a new one
def runAs = SessionDataFactory.direct(mobilePhone)
.channel(scriptParameters.channel)
.requestData(sessionData.requestData)
.build()
def ussdHandler = new UssdHandler(mobilePhone, runAs, binding)
def page = binding.pathVariables.path ?: Pages.MAIN_MENU
def message = ""
if (page != Pages.MAIN_MENU && ussdHandler.newSession) {
// When there is a new session in a page that is not the main menu,
// assume the session has expired
message = scriptParameters['general.sessionExpired']
page = Pages.MAIN_MENU
}
// Invoke the UssdHandler method
invokerHandler.runAs(runAs) {
xml.page(version: "2.0") {
ussdHandler."${page}"(delegate, message)
}
}
}
// Now the output stringWriter should contain the XML output. Build the response.
def response = new ResponseInfo(status: 200, stringBody: stringWriter.toString())
response.setHeader("Content-Type","application/xml;charset=UTF-8")
return responseUnder System > Tools > Custom web services, create a new one, with the following characteristics:
In order to provide security in production environment, you need to set a IP Whilelist checking the IP address whitelist box.
You need an account at Global USSD with credits to be able to process USSD requests. To do so:
Then you will need to configure a service, which can be found in the "Services" option. Create one with the following fields:
Finally, assuming there is a user with a given mobile phone number, you can use the "push URL" shown in the Global USSD bot service page to start a session. Just perform a request to that URL, replacing the MSISDN text by the international mobile phone number, and BotID text with Bot service ID. Assuming the mobile phone's provider is supported by Global USSD, the user should see the USSD menu in his mobile phone.
This solution lets you edit records using custom operations so you can do it via Web services or from the Mobile app where this functionality is not supported. However, it could be used as a guide for other entities.
The logged user is able to search its own records, update or delete them and create new ones (i.e CRUD operations).
It is also possible to search records of other users (you can not do it directly in Cyclos) and run actions over them, in this example we created an action that simulates send an email to the record's owner (you can implement anything you like).
To configure this, follow carefully each of the following steps:
Under System > System configuration > Record types, create a new User record type, with the following characteristics:
For this record type, create the following fields:
Title
Description
Now give permissions:
to admin group (Groups > “Your admin group” > Permissions): in Records, check Enable, View, Create, Edit and Remove over “Daily note”.
to user group (Groups > “The corresponding product”): in Records, check Enable, View, Create, Edit and Remove over “Daily note”.
Under System > Tools > Scripts, create the next script, with the following characteristics:
import org.cyclos.model.system.fields.CustomFieldType import org.cyclos.model.system.fields.ICustomFieldValue import org.cyclos.model.system.scripts.CustomScriptException import org.cyclos.model.users.records.UserRecordVO import groovy.transform.TypeChecked @TypeChecked class Styles { static String infoBox = "white-space: normal; text-overflow: ellipsis;" static String fieldContainerLabel = "white-space: normal; text-overflow: ellipsis; font-weight: 400; font-size: 15px; color: #1865a3;" static String fieldContainerValue = "white-space: normal; text-overflow: ellipsis; margin: 2px 0 9px 0; font-size: 16px; line-height: 18px;" static String infoBoxInline = "white-space: normal; text-overflow: ellipsis;" static String fieldContainerLabelInline = "white-space: normal; text-overflow: ellipsis; font-weight: 400; font-size: 15px; color: #1865a3;" static String fieldContainerValueInline = "white-space: normal; text-overflow: ellipsis; margin: 2px 0 9px 0; font-size: 16px; line-height: 18px;" } @TypeChecked class Helper { static Object getValue(ICustomFieldValue fieldValue) { if (!fieldValue) { return null; } switch (fieldValue.getField().getType()) { case CustomFieldType.BOOLEAN: return fieldValue.getBooleanValue() case CustomFieldType.DATE: return fieldValue.getDateValue() case CustomFieldType.DECIMAL: return fieldValue.getDecimalValue() case CustomFieldType.INTEGER: return fieldValue.getIntegerValue() case CustomFieldType.RICH_TEXT: return fieldValue.getRichTextValue() case CustomFieldType.STRING: return fieldValue.getStringValue() case CustomFieldType.TEXT: return fieldValue.getTextValue() case CustomFieldType.URL: return fieldValue.getStringValue() case CustomFieldType.IMAGE: case CustomFieldType.FILE: case CustomFieldType.LINKED_ENTITY: case CustomFieldType.MULTI_SELECTION: case CustomFieldType.SINGLE_SELECTION: case CustomFieldType.DYNAMIC_SELECTION: throw new CustomScriptException("""There was an error searching records. Please, contact the administration""") default: throw new CustomScriptException("""Error searching records: Unimplemented custom field type. Please, contact the administration""") } } static Object getCustomValueByInternalName(UserRecordVO record, String internalName){ def value = record.getCustomValues().stream() .filter{ it.getField().getInternalName().equals(internalName) } .findAny() return getValue(value.orElse(null)) } }
Under System > Tools > Scripts, create the next script, with the following characteristics:
"The email was sent"
Under System > Tools > Scripts, create the next script, with the following characteristics:
recordType=dailyNote titleInternalName=title descInternalName=description
import org.cyclos.entities.users.User import org.cyclos.impl.users.RecordServiceLocal import org.cyclos.impl.utils.formatting.FormatterImpl import org.cyclos.model.users.records.UserRecordQuery import org.cyclos.model.users.records.UserRecordVO import org.cyclos.model.users.recordtypes.RecordTypeVO import org.cyclos.model.users.users.UserVO import groovy.transform.TypeChecked @TypeChecked class RecordBean { Long recordId String title String description String user RecordBean(Long id, String title, String description, String user) { this.recordId = id this.title = title; this.description = description this.user = user } } @TypeChecked RecordBean toRecordBean(UserRecordVO recordVO, String title, String description) { def titleRow = Helper.getCustomValueByInternalName(recordVO, title) as String def descRow = Helper.getCustomValueByInternalName(recordVO, description) as String def userRow = (binding.variables.formatter as FormatterImpl).format(recordVO.getUser()) return new RecordBean(recordVO.id, titleRow, descRow, userRow) } @TypeChecked def searchRecords() { def variables = binding.variables def scriptParameters = variables.scriptParameters as Map<String, Object> def formParameters = variables.formParameters as Map<String, Object> User user = variables.user as User RecordServiceLocal recordService = variables.recordService as RecordServiceLocal def type = scriptParameters.recordType as String def title = scriptParameters.titleInternalName as String def description = scriptParameters.descInternalName as String def query = new UserRecordQuery() if (formParameters.searchOnlyInMyRecords) { query.user = new UserVO(user.id) } query.currentPage = variables.currentPage as Integer query.pageSize = variables.pageSize as Integer query.skipTotalCount = true == variables.skipTotalCount query.type = new RecordTypeVO(RecordTypeVO.INTERNAL_NAME, type) query.keywords = formParameters.keywords as String def page = recordService.search(query) def rows = page.pageItems.stream().collect { toRecordBean(it as UserRecordVO, title, description) } return [ columns: [ [header: "Owner", property: "user",width:"15%"], [header: "Title", property: "title", width:"35%"], [header: "Description", property: "description",width:"50%"] ], rows: rows, totalCount: page.totalCount, hasNextPage: page.hasNextPage ] } return searchRecords()
Under System > Tools > Scripts, create the next script, with the following characteristics:
recordType=dailyNote titleInternalName=title descInternalName=description
import org.cyclos.impl.users.RecordServiceLocal
import org.cyclos.model.system.fields.CustomFieldValueDTO
import org.cyclos.model.users.records.RecordDataParams
import org.cyclos.model.users.recordtypes.RecordTypeVO
import org.cyclos.model.utils.NotificationLevel
import groovy.transform.TypeChecked
@TypeChecked
def createRecord() {
def msg = "The record was created successfully."
def error = false
try {
def variables = binding.variables
def scriptParameters = variables.scriptParameters as Map<String, Object>
RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
def formParameters = variables.formParameters as Map<String, Object>
def titleField = scriptParameters.titleInternalName
def descField = scriptParameters.descInternalName
def type = scriptParameters.recordType
def dataParams = new RecordDataParams()
dataParams.setRecordType(new RecordTypeVO(RecordTypeVO.INTERNAL_NAME, type))
def data = recordService.getDataForNew(dataParams)
def newRecord = data.getDto()
def customFieldValues = new ArrayList<CustomFieldValueDTO>()
data.getFields().stream()
.filter {
data.getEditableFieldIds().contains(it.id) &&
(it.getInternalName().equals(titleField) ||
it.getInternalName().equals(descField))
}
.forEach {
def valueDTO = new CustomFieldValueDTO();
valueDTO.setField(it)
if (it.getInternalName().equals(titleField)) {
valueDTO.setStringValue(formParameters.title as String)
} else {
valueDTO.setTextValue(formParameters.description as String)
}
customFieldValues.add(valueDTO)
}
newRecord.setCustomValues(customFieldValues)
recordService.save(newRecord)
} catch (Exception ex) {
error = true
msg = """There was an error trying to create the record.
Please, contact the administration."""
}
return [
notification: msg,
notificationLevel: error ? NotificationLevel.ERROR : NotificationLevel.INFORMATION,
backTo: error ? null : "searchRecords",
reRun: !error
]
}
return createRecord()
Under System > Tools > Scripts, create the next script, with the following characteristics:
import org.cyclos.entities.users.RecordCustomFieldValue import org.cyclos.entities.users.User import org.cyclos.entities.users.UserRecord import org.cyclos.entities.users.UserRecordType import org.cyclos.impl.access.SessionData import org.cyclos.impl.system.ScriptHelper import org.cyclos.impl.users.RecordFieldHandler import org.cyclos.impl.users.RecordServiceLocal import org.cyclos.impl.utils.formatting.FormatterImpl import groovy.transform.Field import groovy.transform.TypeChecked import groovy.xml.MarkupBuilder @Field Map<String, Object> variables = binding.variables @TypeChecked class RecordView{ UserRecordType type User user String formattedCreationDate String formattedLastModifiedDate User modifiedBy User createdBy Map<String, String> customValues RecordView(UserRecordType type, User user, String formattedCreationDate, String formattedLastModifiedDate, User modifiedBy, User createdBy, Map<String, String> customValues) { this.type = type this.user = user this.formattedCreationDate = formattedCreationDate this.formattedLastModifiedDate = formattedLastModifiedDate this.createdBy = createdBy this.modifiedBy = modifiedBy this.customValues = customValues } } @TypeChecked def RecordView toRecordView(UserRecord record) { FormatterImpl formatter = variables.formatter as FormatterImpl Map<String, String> customValues = [:] def recordFieldValues = new ArrayList<RecordCustomFieldValue>(record.customValues) def recordFieldHandler = variables.recordFieldHandler as RecordFieldHandler recordFieldHandler.sortFieldValues(record.type, recordFieldValues) recordFieldValues.each { def value = Helper.getValue(it) if (value != null) { customValues[it.field.name] = formatter.format(value) } } String formattedCreationDate = formatter.format(record.creationDate) String formattedLastModifiedDate = formatter.format(record.lastModifiedDate) return new RecordView(record.type as UserRecordType, record.user, formattedCreationDate, formattedLastModifiedDate, record.createdBy, record.modifiedBy, customValues) } def createContent(StringWriter out, RecordView record) { def html = new MarkupBuilder(out) html.div(style:"${Styles.infoBox}") { div { div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Owner:" } div(style:"${Styles.fieldContainerValue}") { mkp.yield formatter.format(record.user) } } if (record.type.isShowUpdateToUsers()) { div { div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Created by:" } div(style:"${Styles.fieldContainerValue}") { mkp.yield record.createdBy?.display } } div { div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Creation date:" } div(style:"${Styles.fieldContainerValue}") { mkp.yield record.formattedCreationDate } } if (!record.formattedLastModifiedDate.isEmpty()) { div { div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Last modification date:" } div(style:"${Styles.fieldContainerValue}") { mkp.yield record.formattedLastModifiedDate } } div { div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Last modification by:" } div(style:"${Styles.fieldContainerValue}") { mkp.yield record.modifiedBy?.display } } } } record.customValues.each{ name, val -> div { div(style:"${Styles.fieldContainerLabel}") { mkp.yield name } div(style:"${Styles.fieldContainerValue}") { mkp.yield val } } } } } @TypeChecked def viewRecord() { def sessionData = variables.sessionData as SessionData def formParameters = variables.formParameters as Map<String, Object> RecordServiceLocal recordService = variables.recordService as RecordServiceLocal ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper def id = scriptHelper.unmaskId(formParameters.recordId) def record = recordService.find(id) as UserRecord def out = new StringWriter() createContent(out, toRecordView(record)) if (record.user == sessionData.loggedBasicUser) { return [ content: out.toString(), actions: [ sendRecordEmail: [ enabled: false ] ] ] } else { return [ content: out.toString(), actions: [ removeRecord: [ enabled: false ], updateRecord: [ enabled: false ] ] ] } } return viewRecord()
Under System > Tools > Scripts, create the next script, with the following characteristics:
titleInternalName=title descInternalName=description
import org.cyclos.impl.system.ScriptHelper
import org.cyclos.impl.users.RecordServiceLocal
import org.cyclos.model.system.fields.CustomFieldValueDTO
import org.cyclos.model.users.records.RecordData
import org.cyclos.model.users.recordtypes.RecordCustomFieldDetailedVO
import org.cyclos.model.utils.NotificationLevel
import groovy.transform.TypeChecked
@TypeChecked
def updateRecord() {
def variables = binding.variables
def scriptParameters = variables.scriptParameters as Map<String, Object>
def formParameters = variables.formParameters as Map<String, Object>
RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper
def msg = "The record was updated successfully."
def error = false
try{
def titleField = scriptParameters.titleInternalName as String
def descField = scriptParameters.descInternalName as String
def newDesc = formParameters.newDescription as String
def newTitle = formParameters.newTitle as String
def id = scriptHelper.unmaskId(formParameters.recordId)
def data = recordService.getData(id) as RecordData
def newRecord = data.dto
def fieldValues = [
titleField: null,
descField: null
] as Map<String, CustomFieldValueDTO>
newRecord.getCustomValues().each{
if (it.field.internalName == titleField) {
fieldValues.put(titleField, it)
} else if (it.field.internalName == descField) {
fieldValues.put(descField, it)
}
}
fieldValues.each { k, v ->
def isTitle = titleField == k
def value = isTitle ? newTitle : newDesc
if (v) {
if (isTitle) {
v.stringValue = value
} else {
v.textValue = value
}
} else {
data.fields.findAll { RecordCustomFieldDetailedVO it ->
it.internalName == k &&
data.editableFieldIds.contains(it.id)
}.each { RecordCustomFieldDetailedVO it ->
def valueDTO = new CustomFieldValueDTO();
valueDTO.field = it
if (isTitle) {
valueDTO.stringValue = value
} else {
valueDTO.textValue = value
}
newRecord.customValues << valueDTO
}
}
}
recordService.save(newRecord)
} catch (Exception ex) {
error = true
msg = """There was an error trying to update the record.
Please, contact the administration."""
}
return [
notification: msg,
notificationLevel: error ? NotificationLevel.ERROR : NotificationLevel.INFORMATION,
backTo: error ? null : "recordDetails",
reRun: !error
]
}
return updateRecord()
import org.cyclos.impl.system.ScriptHelper
import org.cyclos.impl.users.RecordServiceLocal
import org.cyclos.utils.StringHelper
import groovy.transform.TypeChecked
@TypeChecked
def loadRecordFields() {
def variables = binding.variables
def scriptParameters = variables.scriptParameters as Map<String, Object>
def formParameters = variables.formParameters as Map<String, Object>
RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper
def titleField = scriptParameters.titleInternalName as String
def descField = scriptParameters.descInternalName as String
def id = scriptHelper.unmaskId(formParameters.recordId)
def newRecord = recordService.getData(id).dto
def titleFieldValue = newRecord.customValues.find {
it.field.internalName.equals(titleField)
}
def descFieldValue = newRecord.customValues.find {
it.field.internalName.equals(descField)
}
return [
newTitle: StringHelper.emptyIfNull(titleFieldValue?.stringValue),
newDescription: StringHelper.emptyIfNull(descFieldValue?.textValue)
]
}
return loadRecordFields()
Under System > Tools > Scripts, create the next script, with the following characteristics:
import org.cyclos.impl.system.ScriptHelper
import org.cyclos.impl.users.RecordServiceLocal
import org.cyclos.model.utils.NotificationLevel
import groovy.transform.TypeChecked
@TypeChecked
def removeRecord() {
def msg = "The record was removed successfully."
def error = false
def variables = binding.variables
RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper
def formParameters = variables.formParameters as Map<String, Object>
try {
recordService.remove(scriptHelper.unmaskId(formParameters.recordId))
} catch(Exception ex) {
error = true
msg = """There was an error trying to remove the record.
Please, contact the administration."""
}
return [
notification: msg,
notificationLevel: error
? NotificationLevel.ERROR : NotificationLevel.INFORMATION,
backTo: error ? null : "searchRecords",
reRun: !error
]
}
return removeRecord()
Under System > Tools > Custom operations, create a new one with the following characteristics:
Once saved, on the Form fields tab, create a new field, with the following characteristics:
Record id:
Under System > Tools > Custom operations, create a new one with the following characteristics:
Once saved, on the Form fields tab, create three new fields, with the following characteristics:
Record id:
Title:
Description:
Under System > Tools > Custom operations, create a new one with the following characteristics:
Under System > Tools > Custom operations, create a new one with the following characteristics:
Once saved, on the Form fields tab, create a new field, with the following characteristics:
Record id:
Once saved, on the Actions tab, add three new actions, with the following characteristics:
Under System > Tools > Custom operations, create a new one with the following characteristics:
Once saved, on the Form fields tab, create two new fields, with the following characteristics:
Title:
Description:
Under System > Tools > Custom operations, create a new one with the following characteristics:
Once saved, on the Form fields tab, create two new fields, with the following characteristics:
Keywords:
Search only in my records:
Once saved, on the Actions tab, add a new action, with the following characteristics:
This solution provides a custom operator for administrators to search the users' balances with 2 advantages over the regular user balances overview in Cyclos:
These options are not available in the regular balances search because they need to be calculated per user, whereas the current balance is stored in the database. This makes the script inviable when there are too many users. Still, some systems require this functionality.
However, as a drawback, the filters for users are also more limited in this script: it is only possible to filter by a specific group. So, if only the current balance is desired, it is advised to use the built-in functionality instead.
To configure this functionality, follow carefully each of the following steps:
Under System > Tools > Scripts, create the next script, with the following characteristics:
import java.util.stream.Collectors
import org.cyclos.impl.banking.AccountTypeServiceLocal
import org.cyclos.model.banking.accounttypes.AccountTypeNature
import org.cyclos.model.system.fields.DynamicFieldValueVO
import groovy.transform.TypeChecked
@TypeChecked
def loadUserAccountTypes() {
def variables = binding.variables
def accountTypeService = variables.accountTypeService as AccountTypeServiceLocal
return accountTypeService
.listAllAccessible()
.stream()
.filter { it.getNature() == AccountTypeNature.USER }
.map { new DynamicFieldValueVO(String.valueOf(it.id), it.name) }
.collect(Collectors.toList())
}
loadUserAccountTypes()
Under System > Tools > Scripts, create the next script, with the following characteristics:
import java.util.stream.Collectors
import org.cyclos.impl.access.SessionData
import org.cyclos.impl.users.GroupsHandler
import org.cyclos.model.system.fields.DynamicFieldValueVO
import groovy.transform.TypeChecked
@TypeChecked
def loadUserGroups() {
def variables = binding.variables
def groupsHandler = variables.groupsHandler as GroupsHandler
def sessionData = variables.sessionData as SessionData
return groupsHandler
.getAccessibleUserGroups(sessionData.getLoggedUser())
.stream()
.map { new DynamicFieldValueVO(String.valueOf(it.id), it.name) }
.collect(Collectors.toList())
}
loadUserGroups()
Under System > Tools > Scripts, create the next script, with the following characteristics:
import java.util.stream.Collectors
import org.cyclos.entities.banking.Account
import org.cyclos.entities.banking.QAccount
import org.cyclos.entities.system.ExportFormat
import org.cyclos.entities.users.QGroup
import org.cyclos.entities.users.QUser
import org.cyclos.entities.users.UserCustomField
import org.cyclos.impl.InvocationContext
import org.cyclos.impl.access.SessionData
import org.cyclos.impl.banking.AccountServiceLocal
import org.cyclos.impl.contentmanagement.TranslationHandler
import org.cyclos.impl.system.ScriptHelper
import org.cyclos.impl.users.UserCustomFieldServiceLocal
import org.cyclos.impl.utils.formatting.FormatterImpl
import org.cyclos.impl.utils.persistence.EntityManagerHandler
import org.cyclos.model.banking.BankingKeys
import org.cyclos.model.system.fields.DynamicFieldValueVO
import org.cyclos.model.users.UsersKeys
import org.cyclos.server.utils.DateHelper
import com.querydsl.core.types.Expression
import com.querydsl.core.types.dsl.Expressions
import groovy.transform.TypeChecked
@TypeChecked
def searchBalances(){
def variables = binding.variables
def entityManagerHandler = variables.entityManagerHandler as EntityManagerHandler
def translationHandler = variables.translationHandler as TranslationHandler
def accountService = variables.accountService as AccountServiceLocal
def formatter = variables.formatter as FormatterImpl
def exportFormat = variables.exportFormat as ExportFormat
def userCustomFieldService = variables.userCustomFieldService as UserCustomFieldServiceLocal
def scriptHelper = variables.scriptHelper as ScriptHelper
def formParameters = variables.formParameters as Map<String, Object>
def sessionData = variables.sessionData as SessionData
def a = QAccount.account
def u = QUser.user
def g = QGroup.group
def query = entityManagerHandler
.from(a)
.innerJoin(u).on(a.user.eq(u))
.innerJoin(g).on(u.group.eq(g))
def group = (formParameters.group as DynamicFieldValueVO)?.value
if (group) {
query.where(g.id.eq(Long.valueOf(group)))
}
def accountType = (formParameters.accountType as DynamicFieldValueVO)?.value
if (accountType) {
query.where(a.type().id.eq(Long.valueOf(accountType)))
}
def currentPage = variables.currentPage as int
def pageSize = variables.pageSize as int
query
.limit(pageSize)
.offset(pageSize * currentPage)
.orderBy(u.name.asc(), a.type().name.asc())
def totalCount = variables.skipTotalCount ? null : query.fetchCount()
def balanceExpression
def expressions = [
a.id,
a.user().displayForManagers,
a.type().name,
a.type().currencyId
] as List<Expression>
def date = formParameters.date as Date
if (date) {
query.where(a.creationDate.before(date))
def timeZone = sessionData.getConfiguration().getTimeZone()
date = DateHelper.shiftToEnd(date, timeZone)
balanceExpression = a.balance(Expressions.constant(date))
expressions << balanceExpression
}
List<UserCustomField> customFields = []
if (exportFormat && exportFormat.internalName != 'pdf') {
// When exporting tabular data, include the profile fields
customFields = userCustomFieldService.listAll().findAll() { it.includeInExport }
}
def cacheFlusher = InvocationContext.newCacheFlusher()
def rows = query.stream(expressions as Expression[]).map {
def result = [
id: it.get(a.id),
display: it.get(a.user().displayForManagers),
accountType: it.get(a.type().name),
currency: it.get(a.type().currencyId)
] as Map<String, Object>
if (date == null) {
// We need to fetch the available balance
def account = entityManagerHandler.find(Account, it.get(a.id))
def status = accountService.getAccountStatus(account, null, null)
result.balance = status.balance
result.availableBalance = status.availableBalance
} else {
result.balance = it.get(balanceExpression)
}
if (!customFields.empty) {
def account = entityManagerHandler.find(Account, it.get(a.id))
def fields = scriptHelper.wrap(account.owner, customFields)
customFields.each {
def value = fields[it.internalName]
result[it.internalName] = value instanceof Date ?
formatter.formatAsDate(value) : formatter.format(value)
}
}
cacheFlusher.flush()
return result
}.collect(Collectors.toList())
def columns = [
[
header: translationHandler.message(UsersKeys.Users.USER),
property: "display"
],
[
header: translationHandler.message(BankingKeys.Accounts.TYPE),
property: "accountType"
],
[
header: translationHandler.message(BankingKeys.Accounts.BALANCE),
property: "balance",
currencyProperty: "currency",
align: "right"
]
]
if (date == null) {
columns << [
header: translationHandler.message(BankingKeys.Accounts.AVAILABLE_BALANCE),
property: "availableBalance",
currencyProperty: "currency",
align: "right"
]
}
customFields.each {
columns << [ header: it.name, property: it.internalName ]
}
return [
columns: columns,
rows: rows,
totalCount: totalCount,
currentPage: currentPage
]
}
searchBalances()
Under System > Tools > Custom operations, create a new one with the following characteristics:
Once saved, on the Form fields tab, create three new fields, with the following characteristics:
Account type:
Group:
Date: