Libraries are scripts which are included by other scripts, in order to reuse code, and are never used directly by other functionality in Cyclos.
Each script (including other libraries) can have any number of libraries as dependencies. However circular dependencies between libraries (for example, A depends on B, which depends on C, which depends on A) are forbidden (validated when saving a library).
The order in which the code on libraries is included in the final code respects the dependencies, but doesn't guarantee ordering between libraries in the same level. For example, if there are both C and B libraries which depend on A, it is guaranteed that A is included before B and C, but either B or C could be included right after A. So, in the example, your code shouldn't rely that B comes before C. In this case, the library C should depend on B to force the A, B, C order.
Contrary to other script types, libraries don't have bound variables per se: the bindings will be the same as the script including the library.
Also, as libraries are just included in other scripts, no direct examples are provided here. The provided example scripting solutions, however, use libraries.
These scripts are used to validate a custom field value. The field can be of any type (users, advertisements, user records, transactions and so on). The script code has the following variables bound (besides the default bindings)
object: The DTO which holds the custom field values. May be an instance of:
field: The org.cyclos.entities.system.CustomField.
value: The actual custom field value. Depends on the custom field type. May be one of:
The script should return one of the following:
To have a custom field which is validated as an e-mail, use the following script:
import org.apache.commons.validator.routines.EmailValidator return EmailValidator.getInstance().isValid(value)
To validate an IBAN account number as a custom field, the following script can be used:
import org.apache.commons.validator.routines.checkdigit.IBANCheckDigit
return IBANCheckDigit.IBAN_CHECK_DIGIT.isValid(value.replaceAll("\\s", ""))
In Brazil, people are identified by a number called CPF (Cadastro de Pessoas Fisicas). It has 2 veryfing digits, which have a known formula to calculate. Here's the example for validating it in Cyclos:
import static java.lang.Integer.parseInt
def boolean validateCPF(String cpf) {
// Strip non-numeric chars
cpf = cpf.replaceAll("[^0-9]", "")
// Obvious checks: needs to be 11 digits, and not all be the same digit
if (cpf.length() != 11 || cpf.toSet().size() == 1) {
return false
}
int add = 0
// Check for verifier digit 1
for (int i = 0; i < 9; i++) add += parseInt(cpf[i]) * (10 - i)
int rev = 11 - (add % 11)
if (rev == 10 || rev == 11) rev = 0
if (rev != parseInt(cpf[9])) return false
add = 0;
// Check for verifier digit 2
for (int i = 0; i < 10; i++) add += parseInt(cpf[i]) * (11 - i)
rev = 11 - (add % 11)
if (rev == 10 || rev == 11) rev = 0
if (rev != parseInt(cpf[10])) return false
return true
}
return validateCPF(value)
These scripts are used to generate the possible values for custom fields of type 'dynamic selection'. Each possible value is an instance of org.cyclos.model.system.fields.DynamicFieldValueVO. The field can be of any type (users, advertisements, user records, transactions and so on).
The script code has the following variables bound (besides the default bindings):
Also, depending on the custom field nature, there are the following additional bindings:
User (profile) fields:
Advertisement fields:
Record fields:
Transaction fields:
Custom operation fields:
Dynamic document fields:
In all cases, the script must return either one or a collection of:
This examples returns distinct values according to the user group. It should be used by a user custom field (also called profile fields).
import org.cyclos.model.system.fields.DynamicFieldValueVO
def values = []
// Common values
values << new DynamicFieldValueVO("common1", "Common value 1")
values << new DynamicFieldValueVO("common2", "Common value 2")
values << new DynamicFieldValueVO("common3", "Common value 3")
if (user.group.internalName == "business") {
// Values only available for businesses
values << new DynamicFieldValueVO("business1", "Business value 1")
values << new DynamicFieldValueVO("business2", "Business value 2")
values << new DynamicFieldValueVO("business3", "Business value 3")
} else if (user.group.internalName == "consumer") {
// Values only available for consumers
values << new DynamicFieldValueVO("consumer1", "Consumer value 1")
values << new DynamicFieldValueVO("consumer2", "Consumer value 2")
values << new DynamicFieldValueVO("consumer3", "Consumer value 3")
}
return values
This kind of script is responsible for generating account numbers, in case more control than the default (random generation) is needed. The script code has the following variables bound (besides the default bindings):
The script should return a string, which should match the mask set in the configuration (if any). If the script returns null or a blank string, no number is assigned for that account.
The script doesn't need to check if the account number already exists. This is done internally. If the number is already used, the script is called again (up to 10 times, then, an error is raised).
In this example, the mask ##/####### is expected for the account number. The prefix is composed of 2 digits:
The rest are 7 random digits.
import org.apache.commons.lang3.RandomStringUtils
import org.cyclos.entities.users.User
// Either unit or euro
String prefix = type.currency.internalName == 'internal_units' ? '0' : '1'
if (owner instanceof User) {
switch (owner.group.internalName) {
case 'business':
prefix += '1'
break
case 'consumers':
prefix += '2'
break
default:
prefix += '9'
}
} else {
prefix += '0'
}
return prefix + "/" + RandomStringUtils.randomNumeric(7)
These scripts are used to calculate the amount of an account fee (a fee which is charged periodically or manually over many accounts, according to the 'charged account fees' setting in member products). The script code has the following variables bound (besides the default bindings):
The script should return a number, which will be rounded to the currency's decimal digits. If null or zero is returned, the fee is not charged.
This example allows choosing a distinct account fee amount based on a profile field of the paying user. It is assumed a custom field of type single selection with the internal name rank. It should have 3 possible values, with internal names bronze, silver and gold.
// Depending on a user custom field, we'll pick the fee amount def amounts = [bronze: 10, silver: 7, gold: 5] def user = scriptHelper.wrap(account.owner) def rank = user.rank?.internalName ?: "bronze" return amounts [rank]
These scripts are used to calculate the amount of a transfer fee (a fee triggered by another transfer). The script code has the following variables bound (besides the default bindings):
The script should return a number, which will be rounded to the currency's decimal digits. If null or zero is returned, the fee is not charged.
This example allows choosing a distinct fee amount based on a profile field of the paying user. It is assumed a custom field of type single selection with the internal name rank. It should have 3 possible values, with internal names bronze, silver and gold. The script then chooses a different percentage according to the user rank.
if (transfer.fromSystem) {
// Only charge users
return 0
}
// Depending on a user custom field, we'll pick the fee amount
def percentages = [bronze: 0.07, silver: 0.05, gold: 0.02]
def from = scriptHelper.wrap(transfer.fromOwner)
def rank = from.rank?.internalName ?: "bronze"
def percentage = percentages[rank]
return transfer.amount * percentage
This example is similar to the above, but based on a transaction custom field in the payment itself. The main difference is the source for custom field values now depend on whether we're calculating the fee during a payment preview (used to show the user the paid fees before the transfer is acually processed) or for the actual transfer processing. That is because the transfer.transaction is not available during preview. However, to allow retrieving the custom fields during preview, there is an extra bound variable, called previewParameters (not available during transfer processing). Similar to the previous example, but this one assumes the single selection field has internal name category, and the possible values have internal names loan, repayment and buying.
def percentages = [loan: 0.05, repayment: 0.01, buying: 0.02] def source = previewParameters ?: transfer.transaction def bean = scriptHelper.wrap(source) def category = bean.category?.internalName ?: "buying" def percentage = percentages[category] return transfer.amount * percentage
These scripts are used to determine to which status(es) a transfer may be set after the current status. By default, if no script is used, the possible next statuses (as configured in the transfer status details page) will be available. Using a script, however, allows using finer-grained controls. For example, an specific status could be allowed only by specific administrators, or only under special conditions (for example, checking the account balance or any other condition).
The script code has the following variables bound (besides the default bindings):
The script should return one of the following:
In this example, any user can change a transfer status in a given flow. However, only administrators can set a transfer to the status with internal name finished.
// Only administrators can set the status to finished
return status.possibleNext.findAll { st ->
sessionData.admin || st.internalName != "finished"
}
These scripts can be used to manage user sessions (logins) externally. It can only be set in the network default configuration, as the custom session handling script. There are 4 related operations, each implemented in a code box on this script type:
On any of these functions, returning null or having an empty code block will result in the default session management taking place. This way it is possible to implement a custom handling only on special cases. For example, a custom session mechanism might be used only for privileged administrators, whose session tokens comply with an specific format. For reference, Cyclos sessions use 32-character alphanumeric strings, with no punctuation. So, for example, if session tokens generated for those administrators have a different format, say, an UUID, the script can differentiate which sessions tokens correspond to normal sessions (and return null on the Logout and Resolve functions for those token format) and handle only those specific sessions. Also, in such case, the login method could check the user group being logged in, and either perform the login on the underlying system (returning the generated session token) or return null for regular users.
Caution: Errors on any of these functions, specially the first three, may cause users not being able to login or access the system. A good security measure while developing such scripts is to handle a specific user (for example, if the login name is 'admin') with the default session resolution, and withdraw this case after the rest of the script is ready. If such situation occurs, a possible workaround is to login in global mode, then disable and lock the custom session handling in the configuration from which the network configuration inherits. Then edit the script and unlock it again in global mode.
The bound variables are:
This example stores user sessions in the Script storage. It is not a realistic example, as Cyclos itself is used to store sessions, but it does demonstrate the usage of a session handling script. Here are the sources for each of the 4 code boxes:
Function to perform the login:
def sessionToken = java.util.UUID.randomUUID().toString()
def storage = scriptStorageHandler.get(sessionToken, 1200)
storage.user = user
return sessionToken
Function to perform the logout:
scriptStorageHandler.remove(sessionToken) return true
Function to resolve a session given a token:
def storage = scriptStorageHandler.getIfValid(sessionToken) return storage?.user
Function to search for connected users:
import org.cyclos.entities.system.QScriptStorage
import org.cyclos.server.utils.JacksonParameterStorage
QScriptStorage ss = QScriptStorage.scriptStorage
// Get all storages whose keys are like UUID
def page = entityManagerHandler
.from(ss)
.where(ss.key.like("________-____-____-____-____________"),
ss.expirationDate.after(new Date()))
.orderBy(ss.creationDate.asc())
.page(query, ss)
page.pageItems = page.pageItems.collect {
def storage = new JacksonParameterStorage(objectMapper, it.content)
[sessionToken: it.key, user: storage.user, creationDate: it.creationDate]
}
return page
These scripts are used to check passwords. In order to use them, the password type's password mode needs to be "Script". The script code has the following variables bound (besides the default bindings):
The script should return a boolean, indicating whether the password is ok or not.
This is a very simple example, which checks for passwords according to the script parameters. The parameters can be set either in the script itself or in the password type. This example is very insecure, and shouldn't be used in production. Normally, scripts to check passwords would connect to third party applications, but this is just a very basic example.
// Just read the password value from the script parameters return scriptParameters[user.username] == password
These scripts are used on extension points (user, user record, transfer, …), and are attached to specific events (create, update, remove, chargeback, …). The extension point scripts have 2 functions:
The data has already been validated, but not saved yet. In this function, we know that the data entered by users is valid, but the main event has not been saved yet.
The data has been saved, but not committed to database yet. For example, if the script code throws an Exception, the database transaction will be rolled-back, and no data will be persisted.
Here are some example scenarios for performing custom logic, or integrating Cyclos with external systems using extension points:
limit. When a user is performing a payment, an extension point of type transaction could be used, in the function invoked after validation, to check the current balance. It the balance is not enough for the payment and the user has credit limit, a payment from a system account could be done automatically to the user, completing the amount for the payment.
A XA transaction could be done with an external system by creating data in the external database in the function which runs after validating, then preparing the commit in the function after the data is saved, and finally registering both a commit and a rollback listener (see the ScriptHelper in default bindings) to either commit or rollback the prepared transaction.
It is also possible to 'bind' Cyclos entities with extension points. For example a payment could create a new user record of a specific type and set some values in the record. When a user record value is changed this could trigger another action, for example changing the (bookkeeping) status of a payment.
A simple notification of performed payments could be implemented by registering a commit listener (see the ScriptHelper in default bindings) to implement the notification.
The profile information of a user needs to be mirrored in an external system. In this case, a user extension point, with the create / update events can be used to send this information. Additional information on addresses and phones can use the same mechanism (they are different extension points). Finally, a change status event for users, to the status REMOVED indicates that the user has been removed.
There could be payment custom fields which are not filled-in by users when performing payments, but by extension points of type transaction. Payment custom fields may be configured to not show up in the form, only automatically via extension points.
An extension point on a new Cyclos avertisment could publish the advertisment as well in an third party system.
These are just some examples. There are many possible uses for the extension points. In the future we will publish usefull extension points at this site.
All extension points have the following additional variables bound to its execution:
The following types of extension points exist:
Extension points which monitor events on users. Additional bindings:
Events:
Extension points which monitor events on addresses. Additional bindings:
Events:
Extension points which monitor events on user phones. Additional bindings:
Events:
Extension points which monitor events on records, either user or system records. Additional bindings:
Events:
Extension points which monitor events on advertisements. Additional bindings:
Events:
Extension points which monitor events on performed transactions.
The following additional bindings are available for both preview and confirm events:
Events:
Extension points which monitor transaction authorization actions. Additional bindings:
Events:
Argument Map (common for all events):
Events:
This example allows, with a custom profile field, to define an extra credit limit the user can use on demand. When performing a payment, if the available balance is not enough, a payment is performed from a system account to the user, up to the limit specified in that profile field. Once the payment is done, the profile field is subtracted. This example expects the system account to have the internal name debit_units, and it should have a payment transfer type to the user account. That payment transfer type should have the internal name extra_credit. Finally, the custom profile field needs to have the internal name availableCredit, and needs to be of type decimal, and enabled for the user. Then create an extension point of type Transaction, enabled and for the confirm event. This example only works for payments without fees.
import org.cyclos.entities.banking.Account
import org.cyclos.entities.banking.PaymentTransferType
import org.cyclos.entities.banking.SystemAccountType
import org.cyclos.model.banking.accounts.SystemAccountOwner
import org.cyclos.model.banking.transactions.PerformPaymentDTO
import org.cyclos.model.banking.transfertypes.TransferTypeVO
// Only process direct payments. Scheduled payments are skipped
if (!(performTransaction instanceof PerformPaymentDTO)) {
return
}
// Get the available credit as a profile field
def payer = scriptHelper.wrap(fromOwner)
BigDecimal availableCredit = payer.availableCredit?.abs()
if (availableCredit == null || availableCredit < 0.01) {
// Nothing to do - no available credit
return
}
// Get the account and balance
Account account = accountService.load(fromOwner, paymentType.from)
BigDecimal availableBalance = accountService.getAvailableBalance(account, null)
BigDecimal needs = performTransaction.amount - availableBalance
if (needs > 0 && needs <= availableCredit) {
// Needs some extra credit, and has it available - make a payment from system
// Find the system account and payment type
SystemAccountType systemAccountType = entityManagerHandler.find(
SystemAccountType, "debit_units")
PaymentTransferType paymentType = entityManagerHandler.find(
PaymentTransferType, "extra_credit", systemAccountType)
PerformPaymentDTO credit = new PerformPaymentDTO()
credit.from = SystemAccountOwner.instance()
credit.to = fromOwner
credit.type = new TransferTypeVO(paymentType.id)
credit.amount = needs
paymentService.perform(credit)
// Now there should be enough credit to perform the payment
// Update the user available credit
payer.availableCredit -= needs
}
This example allows, for the selected payment types in the extension point details, to send an e-mail to an speficic address.
import javax.mail.internet.InternetAddress
import org.cyclos.model.ValidationException
import org.cyclos.server.utils.MessageProcessingHelper
import org.springframework.mail.javamail.MimeMessageHelper
// Get the e-mail subject and body
def tx = scriptHelper.wrap(transaction)
def vars = [
payer: tx.fromOwner.name,
amount: formatter.format(tx.currencyAmount),
date: formatter.formatAsDate(new Date()),
time: formatter.formatAsTime(new Date())
]
def subject = MessageProcessingHelper.processVariables(scriptParameters.subject, vars)
if (subject == null || subject.empty) {
throw new ValidationException("Missing the 'subject' script parameter")
}
def body = MessageProcessingHelper.processVariables(scriptParameters.message, vars)
if (body == null || body.empty) {
throw new ValidationException("Missing the 'message' script parameter")
}
def toEmail = tx.email
def fromEmail = sessionData.configuration.smtpConfiguration.fromAddress
def sender = mailHandler.mailSender
// Send the message after commit, so we guarantee the transaction is persisted
// when the e-mail is sent
scriptHelper.addOnCommit {
def message = sender.createMimeMessage()
def helper = new MimeMessageHelper(message)
helper.to = new InternetAddress(toEmail)
helper.from = new InternetAddress(fromEmail)
helper.subject = subject
helper.text = body
// Send the message
sender.send message
}
These scripts are invoked when a user runs a custom operation. A custom operation is configured to return different data types, and the script must behave accordingly (see System – Operations for more details).
Custom operations can have different scopes:
Bound variables:
Return value: The required return value depends on the custom operation result type:
The custom operation scripts also support 2 other script blocks. They both receive as specific bind variables only the operation and either the user, record or advertisement (depending on scope). These blocks are:
Custom operations can have additional actions. These actions are shown to users when the operation is executed. Each action points to another custom operation with scope 'Internal'. The original custom operation can be configured for which actions are available, and which parameters are passed to that action. Each parameter may be mapped to a parameter of the original custom operation or left for the original operation script to resolve the parameters.
Actions can be configured on custom operations of any result type, but the main Cyclos web application will only display them when the result type is either plain text, rich text or result page. The action custom operation label is used as the button label in such cases, as the full name could be too large for buttons.
To control the actions, the original script has to return an object with a property called actions. It should be a map keyed by the action operation internal name, and whose values contains the following properties:
Here is an example of script returning a rich content with actions:
return [
content: "This is the content displayed after the operation is executed",
actions: [
action1: [
// action1 is the internal name of the internal custom operation used as action
parameters: [
input1: "Value for input 1",
input2: "1234" // Here input1 and input2 are internal names of
// form fields in the action1 custom operation
]
],
action2: [
enabled: false // action2 is disabled for this execution
]
]
]This example allows creating a "contact us" page, which sends an e-mail to a specified address. To use it, you will need the following content in the script parameters box:
to=admin@project.org from=noreply@project.org subject=Contact form message=The message was sent.\nThank you for your contact. mailHeader=An user has sent a contact form with the following data: mailFrom=From: mailEmail=E-Mail: mailSubject=Subject: mailMessage=Message: invalidEmail=Invalid e-mail address
Then, use the following script code:
import javax.mail.internet.InternetAddress
import org.cyclos.impl.utils.validation.validations.EmailValidation
import org.cyclos.model.ValidationException
import org.springframework.mail.javamail.MimeMessageHelper
def sender = mailHandler.mailSender
def message = sender.createMimeMessage()
def helper = new MimeMessageHelper(message)
if (!EmailValidation.isValid(formParameters.email)) {
throw new ValidationException(scriptParameters.invalidEmail);
}
helper.to = new InternetAddress(scriptParameters.to)
helper.from = new InternetAddress(scriptParameters.from)
helper.subject = scriptParameters.subject
helper.text = """
${scriptParameters.mailHeader}
${scriptParameters.mailFrom} ${formParameters.from}
${scriptParameters.mailEmail} ${formParameters.email}
${scriptParameters.mailSubject} ${formParameters.subject}
${scriptParameters.mailMessage} ${formParameters.message}
"""
sender.send message
return scriptParameters.message
If the account number (a feature new to Cyclos 4.4) is enabled, existing accounts will not have numbers automatically assigned. However, a custom operation can be created and executed a single time, assigning a number to all accounts (even system accounts) which don't have a number yet. To accomplish this, create a custom operation script with the following code:
import static org.cyclos.impl.utils.QueryHelper.processBatch
import org.cyclos.entities.banking.QAccount
def a = QAccount.account
def accounts = entityManagerHandler
.from(a)
.where(a.number.isNull())
.iterate(a)
int affected = 0
processBatch(entityManagerHandler, accounts) { account ->
def number = accountService.generateNumber(account.type, account.owner)
account.number = number
affected++
}
return "Generated the account number for ${affected} accounts"Examples of a custom operation which returning a text (a notification in that case) can be found in the loan solution example. An example of an external redirect is the PayPal integration example.
This is an example where the user selects a document to download. It is assumed that the custom operation has a form field of type single selection with internal name file. Then, each possible value should have the internal name corresponding to a pdf file in a given folder. Once the user chooses the file, it is downloaded.
import org.cyclos.model.ValidationException
// Assume there is a pdf file for each possible value of the field
String fileName = formParameters.file.internalName
String dir = scriptParameters.dir ?: "/usr/share/documents"
File file = new File(dir, "${fileName}.pdf")
if (!file.exists()) {
throw new ValidationException("File not found")
}
return [
content: file,
contentType: "application/pdf",
name: file.name,
length: file.length(),
lastModified: file.lastModified()
]
In this example, a user can see the other users he has traded with (either performed or received payments). The custom operation needs to have user scope and result type list. Also it needs to have the URL action as Cyclos location, and the location needs to be 'user_profile'. Finally, set as URL parameters the value 'id' (without quotes). For more details, see the next section.
import org.cyclos.entities.banking.QTransaction
import org.cyclos.entities.users.QUser
import com.querydsl.core.types.Projections
// This bean will hold the projection of results
class ResultBean {
Long id
String username
String name
Number tx
// All long numbers are passed through IdMask.
// Store the count as int instead, or it would be modified on serialization.
public void setTx(Number tx) {
this.tx = tx?.intValue()
}
}
QTransaction t = QTransaction.transaction1
QUser u = QUser.user
// Base query (group by, order by, limits and projection will be added later)
def query = entityManagerHandler
.from(t).innerJoin(u).on(t.fromUser().id
.when(user.id).then( t.toUser().id).otherwise(t.fromUser().id).eq(u.id))
.where(t.fromUser.eq(user).or(t.toUser.eq(user)))
// Get the number of users I've traded with
def totalCount = query.singleResult(u.id.countDistinct())
// Get the page of users / transactions
def projection = Projections.bean(ResultBean, u.id, u.username, u.name, u.id.count().as("tx"))
def rows = query.groupBy(u.id, u.username, u.name)
.orderBy(u.id.count().desc())
.offset(currentPage * pageSize)
.limit(pageSize)
.list(projection)
// Build the result
return [
columns: [
[header: "Login name", property: "username", width: "20%"],
[header: "Full name", property: "name", align: "center"],
[header: "Transactions", property: "tx", width: "20%", align: "right"]
],
rows: rows,
totalCount: totalCount
]
This example shows some input fields for users to request a loan. Then shows the loan details and an action for the user to send the loan application. When clicked an e-mail is sent with the request data, so the administration can actually handle that loan. As both scripts calculate the loan, another script of type library is used. It contains a parameter for the interest rate. So, first is the code for the "Loan application" library script:
import java.math.RoundingMode
import org.cyclos.entities.users.User
import groovy.xml.MarkupBuilder
/**
* Calculates the installment amount by composite monthly interests
*/
def installmentAmount(double rate, double totalAmount, int installments) {
rate /= 100.0
double cf = rate / (1 - (1 / Math.pow(1 + rate, installments)))
return new BigDecimal(totalAmount * cf).setScale(2, RoundingMode.HALF_UP)
}
/**
* Returns an HTML with the loan request details
*/
String loanRequestHTML(double rate, double reqAmount, int installments, User user) {
def instAmount = installmentAmount(rate, reqAmount, installments)
def totalAmount = instAmount * installments
def out = new StringWriter()
MarkupBuilder html = new MarkupBuilder(out)
html.div {
table {
if (user != null) {
tr {
td width:"200px", { b "Requested by user" }
td "${user.name} (${user.username})"
}
}
tr {
td width:"200px", { b "Monthly interest rates" }
td "${formatter.format(rate as BigDecimal)}% per month"
}
tr {
td { b "Requested amount" }
td formatter.format(reqAmount, 2)
}
tr {
td { b "Total amount to be repaid" }
td formatter.format(totalAmount as BigDecimal, 2)
}
tr {
td colspan: 2, { b "Installments"
} }
tr {
td style:"text-align:center", { b "Due date" }
td style:"text-align:right", { b "Due amount" }
}
def cal = Calendar.getInstance()
for (int i = 0; i < installments; i++) {
cal.add(Calendar.MONTH, 1)
tr {
td style:"text-align:center", { b formatter.formatAsDate(cal.time) }
td style:"text-align:right", formatter.format(instAmount, 2)
}
}
}
}
return out.toString()
}
This script needs a parameter which is the interest rate. So, paste this in the script parameters field:
monthlyInterests=0.75
email=admin@admin-email.com
Here is the code for the custom operation that requests the loan. Don't forget to include the loan application library in the script.
def rate = scriptParameters.monthlyInterests as double
def reqAmount = formParameters.amount as BigDecimal
def instCount = formParameters.installments as int
return [
title: "Loan request details",
content: loanRequestHTML(rate, reqAmount, instCount, null),
actions: [
submitLoanApplication: [
parameters: [
user: user.id
]
]
]
]
And here is the code for the custom operation that submits the loan request. The script should also include the loan application library.
import javax.mail.internet.InternetAddress import org.springframework.mail.javamail.MimeMessageHelper def rate = scriptParameters.monthlyInterests as double def reqAmount = formParameters.amount as BigDecimal def instCount = formParameters.installments def user = formParameters.user def body = loanRequestHTML(rate, reqAmount, instCount, user) def sender = mailHandler.mailSender def message = sender.createMimeMessage() def helper = new MimeMessageHelper(message, true, "UTF-8") helper.to = new InternetAddress(scriptParameters.email) helper.from = new InternetAddress(user.email, user.name) helper.subject = "Loan request" helper.setText(body, true) sender.send message return "The loan request was sent to the administration"
Before creating the custom operation for the loan application itself, create the one for the action, with the following fields:
Then, after saving, add the following form parameters:
Then create the custom operation with the loan application form:
Then, after saving, add the following form parameters:
And add another action in the actions tab:
After granting permission to the Loan application custom operation, it should appear in the menu
Custom operations that return a page of results are very versatile. For example, they can be printed as PDF or exported to CSV, or page results (if the script returns the total count).
Also, on the custom operation form it is possible to define an action to be executed when a row is clicked by the user. The possible actions are:
In all cases an action is set to a row, parameters can be passed to the next page. This is very important, as will provide context on which data was selected. For an internal custom operation to receive a parameter, first on the result page custom operation the field 'URL parameters' must be set, having a comma-separated value of object properties to be passed to the internal custom operation. This will pass all such properties from the clicked row to the internal custom operation. Then, the internal custom operation needs to have form fields defined with the matching internal name. The following is an example script for a custom operation which lists fictional external records. It needs to have as URL action the custom operation presented ahead to show an external record details, and pass the URL parameter 'recordId' (without quotes):
return [
columns: [
[header:"Name", property:"name"]
],
rows: [
[name: "Record 1", recordId: 1],
[name: "Record 2", recordId: 2],
[name: "Record 3", recordId: 3],
[name: "Record 4", recordId: 4],
[name: "Record 5", recordId: 5],
[name: "Invalid Record", recordId: 99999],
]
]
Then another custom operation, which should be defined as internal, and have a form field which internal name 'recordId' (without quotes):
import org.cyclos.model.EntityNotFoundException // Validate the id def recordId = formParameters.recordId def validIds = 1..50 if (!(recordId in validIds)) { throw new EntityNotFoundException([ entityType: "External record", key: recordId as String]) } return [ title: "Details for record ${recordId}", content: "This is the description for record ${recordId}" ]
Custom operations of scope bulk action are executed once per user affected by the bulk action. If an operation has to be executed over a set of users, it can be convenient to create a bulk action for that.
In order to run a bulk action that runs a custom operation, first the script needs to be created. Then the bulk action, with its (optional) form parameters. Finally, the administrator needs permission to manage bulk actions and also permission to run that custom operation over users.
Here is an example of a custom operation that performs a payment from a system account to each user. The script checks if the user has the account that receives the payment. If not, it is marked as skipped for that bulk action. If the user has the account, the payment is performed. The script needs as parameters, the internal name of the system account and payment type, like this (make sure to check that the internal names are correct):
systemAccount=debit paymentType=toUserThen the custom operation script block should be as follow:
import org.cyclos.entities.banking.TransferType
import org.cyclos.model.EntityNotFoundException
import org.cyclos.model.banking.accounts.SystemAccountOwner
import org.cyclos.model.banking.transactions.PerformPaymentDTO
import org.cyclos.model.banking.transfertypes.TransferTypeVO
import org.cyclos.model.users.bulkactions.BulkActionUserStatus
def tt = entityManagerHandler.find(TransferType,
"${scriptParameters.systemAccount}.${scriptParameters.paymentType}")
// Check if the user has the destination account type
try {
accountService.load(user, tt.to)
} catch (EntityNotFoundException e) {
return BulkActionUserStatus.SKIPPED
}
// Perform the payment
def dto = new PerformPaymentDTO()
dto.from = SystemAccountOwner.instance()
dto.to = user
dto.type = new TransferTypeVO(tt.id)
dto.amount = formParameters.amount
paymentService.perform(dto)
return BulkActionUserStatus.SUCCESS
These scripts are invoked when a request is received in some path under <cyclos-root-url>[/network]/run/**. To actually run them, it is needed to create a custom web service definition in the "System - Tools - Custom web services" menu.
The custom web services have the following important properties:
Bound variables:
Return value: The script may return one of the following data:
If the script captures an error and wants to customize the response, instead of silencing the exception in a catch clause and returning a org.cyclos.model.utils.ResponseInfo, which will cause the current transaction to commit, possibly leaving the database in an inconsistent state, the script should throw a org.cyclos.model.utils.ResponseException, which contains a ResponseInfo internally. This way the main transaction is rolled back. Other exceptions than ResponseExceptions are returned as HTTP status codes other than 200, and the details are returned as JSON, in the same way as ???.
Sometimes it is useful to extend the Cyclos API to clients, like doing specific payments, or running a series of operations in a single request. However, it is important to use the same permissions as the user would normally have, to prevent security breaches. To do so, 3 steps are needed:
This example allows a caller to quickly perform a payment between 2 users. It is assumed that the URL mapping is something like payment/{from}/{to}/{amount} and there is a single possible payment type between the 2 users.
import org.cyclos.model.banking.transactions.PerformPaymentDTO
import org.cyclos.model.users.users.UserLocatorVO
def pmt = new PerformPaymentDTO()
pmt.from = new UserLocatorVO(principal: pathVariables.from)
pmt.to = new UserLocatorVO(principal: pathVariables.to)
pmt.amount = pathVariables.getDecimal('amount')
// Perform the payment and return the complete PaymentVO
return paymentService.perform(pmt)
These scripts are invoked before and / or after specific service operations. The services are those that extend org.cyclos.services.Service, not the REST api. The REST services use the internal services, so, ultimately, they can be intercepted too.
In order to apply these kind of scripts, a service interceptor needs to be created, and among its properties, the following can be highlighted:
Multiple service interceptors may apply over the same operation. Hence, the order is important. For this reason interceptors are manually ordered.
Interceptors run in the same database transaction as the regular service operation. Each operation defines whether the transaction is read-write or read-only. Operations that just read data run in a read-only transaction. In that case, attempting to write data in the database will fail. Also, even if the transaction is read-write, in the script that runs after the operation, it might happen that an error was thrown, marking the transaction as rollback. As such, service interceptor scripts should be very careful when writing to the database. If this is needed, it is recommended to do it in another database transaction, running after the original transaction ends. The ScriptHelper class (which is bound to the script context on the scriptHelper variable) provides the addOnCommitTransactional and addOnRollbackTransactional methods which allow running a closure after the main transaction ends either as commit or rollback). Those methods run the code block itself inside another transaction, in which it is safe to write to database.
There is a shared context for all interceptors, of type org.cyclos.impl.system.ServiceInterceptorContext.
This context can be used to replace parameters before the original operation invocation, or even to skip the invocation altogether and return a value determined by the script.
Also, the context can be used to store attributes which will be shared amongst interceptors or between the code that runs before and after the service invocation itself.
The propertyMissing mechanism from Groovy is supported by the context implementation. For example, context.myVariable = 'x' will set the attribute myVariable.
Bound variables:
The return value from the script, in both codes that run before or after, is ignored.
If there is an error in the service interceptor script, and it is applied to crucial services, such as login or the application configuration, it may render the network unusable. In order to recover from it, it is possible to go to the global mode (<cyclos_root_url>/global), go to the network details and click on "Disable service interceptors". It will disable all service interceptors for that network, allowing the regular usage again. After fixing the scripts, any interceptors need to be manually enabled again.
This example will set the default filters on transfer overview to not include chargebacks, neither transfers that were charged back. A service interceptor needs to be applied on the AccountService.getAccountHistoriesOverviewData operation. The script should have this on the code that runs after the service is executed (the code for before may be left empty):
import org.cyclos.model.banking.accounts.AccountHistoriesOverviewQuery
import org.cyclos.model.banking.transfers.TransferNature
if (context.success) {
AccountHistoriesOverviewQuery query = context.result.query
// Include all transfer natures except chargeback
query.natures = EnumSet.complementOf(EnumSet.of(TransferNature.CHARGEBACK))
// Also don't include transfers that were themselves charged-back
query.chargedBack = false
}
This example processes the content of a menu entry to replace variables. The example variables are profile fields of the logged user. A service interceptor needs to be applied on the MenuEntryService.getMenuItemDetails operation. The script should have this on the code that runs after the service is executed (the code for before may be left empty):
import org.cyclos.model.contentmanagement.contentitems.MenuItemDetailedVO
import org.cyclos.utils.StringHelper
if (context.success) {
MenuItemDetailedVO item = contex.result
if (item.content != null && sessionData.loggedIn) {
def profileFieldVariables = profileFieldHandler.getProfileFieldVariables(sessionData.loggedBasicUser);
item.content = StringHelper.replaceVariables(item.content, profileFieldVariables);
}
}
This example sets mobile phones to be enabled for SMS by default when registering a user by administrator or broker. To achieve this, create a service interceptor that captures the UserService.getDataForNew operation. The script should have this on the code that runs after the service is executed (the code for before may be left empty):
if (context.success) {
def phoneData = context.result?.mobilePhoneData
if (phoneData?.canManuallyVerify) {
phoneData.verified = true
}
}
These scripts are called periodically by custom scheduled tasks. See System – Scheduled tasks for more details.
The bound variables are:
Return value:
This example imports a file with users, which is expected to be located at a given directory in the file system. For other import types, it is just a matter of using distinct org.cyclos.model.system.imports.ImportedFileDTO subclasses (some require setting some parameter, like in the example, the group for users). The scheduled task just triggers the import. From that point, the import is processed on the background, and the status can be monitored on System - Tools - Imports menu.
To use it, you will need the following content in the script parameters box (either in script itself or in the custom scheduled task's script parameters):
filename=/tmp/imports/users.csv group=consumers
Then use the following code in the script box:
import org.cyclos.model.system.imports.UserImportedFileDTO
import org.cyclos.model.users.groups.GroupVO
import org.cyclos.model.utils.FileSizeUnit
import org.cyclos.server.utils.SerializableInputStream
// Resolve the users filename and the group
String filename = scriptParameters['filename']
String groupInternalName = scriptParameters['group']
// Download the file to a local temp file
File file = new File(filename)
if (!file.exists()) {
return "The expected file, ${filename}, doesn't exist"
}
if (file.length() == 0) {
return "The file ${filename} is empty"
}
// Caution! the SerializableInputStream automatically deletes the file
// when closed, except when calling, except when calling .file()
def stream = new SerializableInputStream(file)
stream.file()
// Import
UserImportedFileDTO dto = new UserImportedFileDTO()
dto.fileName = filename
// It is important to mark the file as automatic import,
// otherwise manual interaction would be required for processing
dto.processAutomatically = true
dto.group = new GroupVO([internalName: groupInternalName])
importService.upload(dto, stream)
// Build a result string
def fileSize = FileSizeUnit.nearestFileSize(file.length())
return "Started import of ${filename}. File size is ${fileSize}"
In this example, every time the scheduled task runs, a static HTML file is updated. In the file, it is written the total number of users and the balances of each system account.
import groovy.xml.MarkupBuilder
import org.cyclos.entities.users.QUser
import org.cyclos.model.banking.accounts.AccountWithStatusVO
import org.cyclos.model.banking.accounts.SystemAccountOwner
import org.cyclos.model.users.groups.BasicGroupNature
import org.cyclos.model.users.users.UserStatus
def now = new Date()
QUser u = QUser.user
int users = entityManagerHandler
.from(u)
.where(u.status.ne(UserStatus.REMOVED),
u.group.nature.eq(BasicGroupNature.USER_GROUP))
.count()
List<AccountWithStatusVO> accounts = accountService.
getAccountsSummary(SystemAccountOwner.instance(), null)
File out = new File("/var/www/html/summary.html")
def sessionData = binding.sessionData
def formatter = binding.formatter
MarkupBuilder builder = new MarkupBuilder(new FileWriter(out))
builder.html {
head {
title "${sessionData.configuration.applicationName} summary"
meta charset: "UTF-8"
}
body {
p {
b "Total users"
span ": ${users}"
}
accounts.each { a ->
p {
b a.type.name
span " balance: ${formatter.format(a.status.balance)}"
}
}
br()
br()
br()
p style: "font-size: small", "Last updated: ${formatter.format(now)}"
}
}
return "File ${out.absolutePath} updated"
These scripts are invoked when a user executes a custom sms operation, as configured in the sms channel in the configuration. The function should implement the logic for that operation.
Bound variables:
There are no expected return values for this script.
In this example SMS operation, users can pay taxi drivers via SMS. Make sure all the following are configured:
Then, customers can perform the payment by sending an sms in the format: taxi <taxi id> <amount>. Below is the script that should be used:
import org.cyclos.model.ValidationException
import org.cyclos.model.banking.TransferException
import org.cyclos.model.banking.transactions.PerformPaymentDTO
import org.cyclos.model.banking.transactions.PerformPaymentData
import org.cyclos.model.messaging.sms.OutboundSmsType
import org.cyclos.model.users.users.UserLocatorVO
// Read the parameters
String taxiId = parameterProcessor.nextString("taxiId")
BigDecimal amount = parameterProcessor.nextDecimal("amount")
// Find the user by taxi id
def locator = new UserLocatorVO(
principalType: "taxiId",
principal: taxiId)
// Find the payment type
PerformPaymentData data = transactionService.getPaymentData(
phone.user, locator)
if (data.paymentTypes?.empty) {
throw new ValidationException("No possible payment types")
}
// Perform the payment
def pmt = new PerformPaymentDTO()
pmt.amount = amount
pmt.from = data.from
pmt.to = data.to
pmt.type = data.paymentTypes[0]
try {
vo = paymentServiceSecurity.perform(pmt)
outboundSmsHandler.send(phone,
"The payment was successful",
OutboundSmsType.SMS_OPERATION_RESPONSE)
// Also notify the taxi, for example, by connecting to the
// taxi company system, which notifies the taxi driver...
} catch (TransferException e) {
outboundSmsHandler.send(phone,
"The payment couldn't be performed",
OutboundSmsType.SMS_OPERATION_RESPONSE)
}
These scripts are invoked when a gateway sends SMS messages to Cyclos. There are two functions in this script: one to generate the gateway response and another one to resolve basic SMS data from an inbound HTTP request. Both functions are optional, defaulting to the normal behavior (when not using a script).
The common bound variables are:
The functions are:
Resolve basic SMS data: Function used to read an inbound sms request and return an object containing the phone number, the SMS message and the splitted SMS message into parts. Only the phone number and SMS message are required. If the message parts are empty, it will be assumed the message will be split by spaces.
Generate gateway response: Function used to determine the HTTP status code, headers and body to be returned to the SMS gateway. It can be called either when the bare minimum parameters – mobile phone number and sms message – were not sent by the gateway or when the gateway has sent a valid SMS. Keep in mind that if an operation has resulted in error, from a gateway perspective, the SMS was still delivered correctly, and the response should be a successful one. Maybe when the bare minimum parameters weren't send, the script could choose to return a different message. When no code is given, the default processing will be done, returning the HTTP status code 200 with "OK" in the body.
This example assumes the request body is a JSON object:
import java.nio.charset.StandardCharsets
import org.cyclos.impl.utils.sms.InboundSmsBasicData
def body = new InputStreamReader(request.body, StandardCharsets.UTF_8)
def json = objectMapper.readTree(body)
InboundSmsBasicData result = new InboundSmsBasicData()
result.phoneNumber = json.get("phoneNumber")?.asText()
result.message = json.get("message")?.asText()
return result
This example reads the phone number from a request header, and the message from the request body:
import org.apache.commons.io.IOUtils import org.cyclos.impl.utils.sms.InboundSmsBasicData // Read the phone from a header, and the message from the body InboundSmsBasicData result = new InboundSmsBasicData() result.phoneNumber = request.headers."phone-number" result.message = IOUtils.toString(request.body, "UTF-8") return result
These scripts are invoked to send SMS messages. By default, Cyclos connects to gateways via HTTP POST / GET, which can be set in the configuration. However, the sending can be customized (or totally replaced) via a script. As in most cases the custom sending just wants to customize some aspects of the sending, not all, it is possible that the script just creates a subclass of org.cyclos.impl.utils.sms.GatewaySmsSender, customizing some aspects of it (for example, by overridding the buildRequest method and adding some headers, or the resolveVariables method to have some additional variables which can be sent in the POST body).
Bound variables:
Return value:
This example posts the SMS message as JSON to the gateway, and awaits the response before returning the status:
import static groovyx.net.http.ContentType.*
import static groovyx.net.http.Method.*
import java.util.concurrent.CountDownLatch
import org.cyclos.model.messaging.sms.OutboundSmsStatus
import groovyx.net.http.HTTPBuilder
// Read some gateway data from the configuration
def smsConfig = configuration.outboundSmsConfiguration
def url = smsConfig.gatewayUrl
def user = smsConfig.username
def pwd = smsConfig.password
// Send the POST request
def http = new HTTPBuilder(url)
if (user) {
// Maybe send the username and passowrd
def auth = user;
if (pwd) {
auth += ":${pwd}"
}
http.headers["Authorization"] = "Basic ${auth.bytes.encodeBase64()}"
}
http.headers["Content-Type"] = "application/json; charset=UTF-8"
CountDownLatch latch = new CountDownLatch(1)
def error = false
http.request(POST, JSON) {
body = [
to: phoneNumber,
text: message
]
response.success = { resp, result ->
latch.countDown()
}
response.failure = { resp ->
error = true
latch.countDown()
}
}
//Await for the response
latch.await()
return error ? OutboundSmsStatus.SUCCESS : OutboundSmsStatus.UNKNOWN_ERROR
This example posts the SMS message as XML to the gateway, and awaits the response before returning the status:
import static groovyx.net.http.ContentType.*
import static groovyx.net.http.Method.*
import java.util.concurrent.CountDownLatch
import org.cyclos.model.messaging.sms.OutboundSmsStatus
import groovyx.net.http.HTTPBuilder
// Read some gateway data from the configuration
def smsConfig = configuration.outboundSmsConfiguration
def url = smsConfig.gatewayUrl
def user = smsConfig.username
def pwd = smsConfig.password
// Send the POST request
def http = new HTTPBuilder(url)
if (user) {
// Maybe send the username and passowrd
def auth = user
if (pwd) {
auth += ":${pwd}"
}
http.headers["Authorization"] = "Basic ${auth.bytes.encodeBase64()}"
}
http.headers["Content-Type"] = "application/xml; charset=UTF-8"
CountDownLatch latch = new CountDownLatch(1)
def error = false
http.request(POST, XML) {
// Pass the body as a closure - parsed as XML
body = {
"sms-message" {
"destination-phone" phoneNumber
text message
}
}
response.success = { resp, xml ->
latch.countDown()
}
response.failure = { resp ->
error = true
latch.countDown()
}
}
//Await for the response
latch.await()
return error ? OutboundSmsStatus.UNKNOWN_ERROR : OutboundSmsStatus.SUCCESS
These scripts are used to generate the possible urls based on the underlying context.
The script code has the following variables bound (besides the default bindings ):
Related User:
Location:
Entity id:
URL file part:
This examples returns distinct values according to location.
import org.cyclos.model.utils.Location
def root = 'https://mydomain.com'
if(location == null){
return null
}
switch(location){
case Location.EXTERNAL_PAYMENT:
return root + '/external-payment/' + entityId
case Location.TRANSFER:
return root + '/transfer/' + entityId
default:
return null
}