4.3. Script types

4.3.1. Library

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.

4.3.2. Custom field validation

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)

The script should return one of the following:

  • A boolean, indicates that the value is either valid / invalid. When invalid, the general "<Field name> is invalid" error will be displayed;
  • A string, means the field is invalid, and the string is the error message. To concatenate the field name directly, use the {0} placeholder, like: "{0} has an unexpected value";
  • Any other result will be considered valid.

4.3.2.1. Examples

E-mail

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)

IBAN account number

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", ""))

CPF Validation

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)

4.3.3. Load custom field values

These scripts are used to load a list of allowed values for a custom field. Custom fields of type dynamic selection are required to have such script. Several other field types can have an optional load values script: string, integer, decimal, date, url, enumerated or linked entity. Enumerated fields naturally have a list of static possible values. The script, however, can be used to show a subset of those options to specific users. Multi-line text, rich text, boolean, image and file types cannot have a load custom field values script.

If a custom field of type string, integer, decimal, date, url or linked entity has a load values script, Cyclos will use a single selection or radio button group widget instead of the regular widget for the custom field. Also, when a load custom field values script is used, the server-side validation will ensure that saved values are valid according to the allowed values list.

The script has a separated code block which loads values for custom fields being used as search filter. The field types supporting load values when filtering are: dynamic selection, linked entity and enumerated. In that case, the bound variables will be different than the ones for the code block that runs over fields used to create or edit some entity (user, advertisement, record, etc).

In all cases, the script will have the following variables bound (besides the default bindings):

Also, depending on the custom field nature, there are the following additional bindings, both for the script that runs when creating or modifying an entity and for the script that runs over custom fields used as search filters:

The expected result type should match the custom field type. Must be either one, a collection or an array of:

4.3.3.1. Examples

Dynamic selection on user profile field: values depending on the user group

This example applies to a custom user profile field, and returns distinct values according to the user group.

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

And here is the script returning all available values, to be used for search filters:

import org.cyclos.model.system.fields.DynamicFieldValueVO

return [
    new DynamicFieldValueVO("common1", "Common value 1"),
    new DynamicFieldValueVO("common2", "Common value 2"),
    new DynamicFieldValueVO("common3", "Common value 3"),
    new DynamicFieldValueVO("business1", "Business value 1"),
    new DynamicFieldValueVO("business2", "Business value 2"),
    new DynamicFieldValueVO("business3", "Business value 3"),
    new DynamicFieldValueVO("consumer1", "Consumer value 1"),
    new DynamicFieldValueVO("consumer2", "Consumer value 2"),
    new DynamicFieldValueVO("consumer3", "Consumer value 3")
]

Linked user: list the brokers only

This example applies to a custom field of type linked entity - user. It returns all active brokers in the system, so the user can select one.

import org.cyclos.model.access.Role
import org.cyclos.model.users.users.UserQuery
import org.cyclos.model.users.users.UserStatus

def q = new UserQuery()
q.setUnlimited()
q.roles = [Role.BROKER]
q.userStatus = [
    UserStatus.ACTIVE,
    UserStatus.BLOCKED
]
return userService.search(q)

Linked transaction on transaction field: list the open loans

This example lists all transactions of a specific payment type (loan grant) to the user performing the payment, filtering by a specific transfer status (open). It could be used on a payment from user to system to repay the loan, which would also need additional processing from a extension point script to mark the loan as repaid (script not included in this example).

import org.cyclos.entities.banking.AccountType
import org.cyclos.model.banking.accounts.AccountHistoryQuery
import org.cyclos.model.banking.accounts.AccountVO
import org.cyclos.model.banking.transferstatus.TransferStatusVO
import org.cyclos.model.banking.transfertypes.TransferTypeVO

// Find the account
def accountType = entityManagerHandler.find(AccountType, 'user')
def account = accountService.load(fromOwner, accountType)

// The account history has transfers. We need the transactions.
def q = new AccountHistoryQuery()
q.setUnlimited()
q.account = new AccountVO(account.id)
q.transferTypes = [
    new TransferTypeVO(internalName: 'debit.loan')
]
q.statuses = [
    new TransferStatusVO(internalName: 'loan.open')
]
def transfers = accountService.searchAccountHistory(q).pageItems

// Return the transaction ids
return transfers.collect {it.transactionId}

4.3.4. Account number generation

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).

4.3.4.1. Examples

Controlling the prefix according to the currency and user group

In this example, the mask ##/####### is expected for the account number. The prefix is composed of 2 digits:

  • The first one is 0 if the currency is unit, or 1 otherwise.
  • The second one is 0 for system, 1 for business, 2 for consumers of 9 otherwise.

The rest are 7 random digits.

import org.cyclos.entities.users.User
import org.cyclos.utils.StringHelper

// 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 + "/" + StringHelper.randomNumeric(7)

4.3.5. Account fee calculation

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.

4.3.5.1. Examples

Charge a different amount according to the user rank

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]

4.3.6. Transfer fee calculation

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.

4.3.6.1. Examples

Charging a fee according to a user profile field

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

Charging a fee according to a payment custom field

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

Distributing a fee exactly to different accounts

Some systems charge a fee from users (be it a transfer fee or account fee) which is itself distributed amongst different accounts. For example, a 5% transaction fee is charged from users, and that fee amount is distributed like 12% to account A, 27% to account B and 61% to account C. So, the transaction fee transfer type itself has other 3 fees. The problem in making them all percentage is that each fee charge rounds the charged amount (generally to 2 decimal places, according to the currency), and that may cause the total distributed amount to be different from the total fee amount. A solution for this problem is to make one of the fees calculated by script, so it sums up what each other fee has charged, and charges the remaining. Generally the fee with the largest charge percentage would then use this script, while all other fees will be configured as percentages.

import org.cyclos.entities.banking.Transfer
import org.cyclos.model.banking.transferfees.TransferFeeChargeMode
import org.cyclos.utils.BigDecimalHelper

Transfer transfer = binding.transfer
BigDecimal amount = transfer.amount

// Sum what the other fees will charge
int scale = transfer.currency.precision
BigDecimal others = 0
for (def fee in transfer.type.transferFees) {
    if (fee.chargeMode == TransferFeeChargeMode.PERCENTAGE) {
        others += BigDecimalHelper.round(amount * fee.amount, scale)
    }
}

// Charge the rest
return BigDecimalHelper.round(amount - others, scale)

4.3.7. Transfer status handling

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:

4.3.7.1. Examples

Restricting a specific status for administrators

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"
}

4.3.8. Session handling

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:

  • Login: Called when a session is created. Should return a session token (string) or an object / Map compatible with org.cyclos.entities.access.Session. If the script returns null, the default login is performed.
  • Logout: Called when a user logs out or is disconnected by an administrator. If the script returns null or false, the default logout is performed.
  • Resolve: Given the input session token, should return either the logged user (can be either a org.cyclos.entities.users.BasicUser or a org.cyclos.impl.users.LocateUserResult) or the org.cyclos.entities.access.Session directly, which should at least contain the session token, the user attributes and the boolean value indicating if it's a trusted session. If the resolved session is trusted then the correct result must be a org.cyclos.entities.access.Session to avoid loss the flag when converting the result. If the script returns null, the default session resolution is performed.
  • Set properties: Called when properties of a session are being modified.
  • Search connected users: This script is called when an administrator searches for connected user, as well as on the administrator home page, as the number of connected users is shown. Should return either a list or org.cyclos.utils.Page of results, where each element must be either a org.cyclos.entities.access.Session or a compatible object, containing at least the sessionToken and user properties filled in. If the script returns null, the default sessions search is performed.

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 (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:

  • user: The org.cyclos.entities.users.BasicUser performing login. Only available on the login function.
  • principal: The org.cyclos.impl.access.UserPrincipal for the user performing login. Only available on the login function.
  • sessionProperties: The org.cyclos.entities.access.SessionProperties either for the session being created or updated. Only available on the login and setProperties functions.
  • trusted: A boolean value indicating if the session will be a trusted one, i.e, the user is performing a login from a trusted device. Trusted sessions doesn't require a confirmation password regardless the channel configuration. Only available on the login function.
  • channel: The org.cyclos.entities.access.Channel representing the channel for which the session should be valid.
  • remoteAddress: The remote IP address (string) for which the session should be valid.
  • sessionToken: The session token (string) that is either being resolved (on the resolve function) or invalidated (on the logout function).
  • sessionTimeout: The org.cyclos.entities.utils.TimeInterval that should be used as session timeout. Never null, it could be a custom timeout for this specific session or that defined for the corresponding channel configuration.
  • query: The org.cyclos.model.users.users.ConnectedUserQuery for the search function.

4.3.8.1. Examples

Storing sessions on Cyclos script storages

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 5 code boxes:

Function to perform the login:

import org.apache.commons.lang3.RandomStringUtils
import org.cyclos.entities.access.Channel
import org.cyclos.entities.access.SessionProperties
import org.cyclos.entities.utils.TimeInterval
import org.cyclos.impl.access.UserPrincipal
import org.cyclos.impl.system.ScriptStorageHandler

ScriptStorageHandler scriptStorageHandler = binding.scriptStorageHandler
UserPrincipal principal = binding.principal
TimeInterval sessionTimeout = binding.sessionTimeout;
String remoteAddress = binding.remoteAddress
SessionProperties sessionProperties = binding.sessionProperties
Channel channel = binding.channel

String token = RandomStringUtils.randomAlphanumeric(64)
int timeout = sessionTimeout.milliseconds / 1000
def storage = scriptStorageHandler.get("session_${token}", timeout)
storage.principal = principal
storage.remoteAddress = remoteAddress
storage.timeout = sessionTimeout
storage.sessionProperties = sessionProperties
storage.channel = channel

return token

Function to perform the logout:

import org.cyclos.impl.system.ScriptStorageHandler

ScriptStorageHandler scriptStorageHandler = binding.scriptStorageHandler
String sessionToken = binding.sessionToken

return scriptStorageHandler.remove("session_${sessionToken}")

Function to resolve a session given a token:

import org.cyclos.entities.access.Session
import org.cyclos.impl.system.ScriptStorageHandler

ScriptStorageHandler scriptStorageHandler = binding.scriptStorageHandler
String sessionToken = binding.sessionToken

def storage = scriptStorageHandler.getIfValid("session_${sessionToken}")
Session session = null
if (storage != null) {
    session = new Session()
    session.initFrom(storage.principal)
    session.sessionToken = sessionToken
    session.properties = storage.sessionProperties
    session.channel = storage.channel
    session.remoteAddress = storage.remoteAddress
    session.sessionTimeout = storage.timeout
}
return session

Function to set the session properties:

import org.apache.commons.lang3.RandomStringUtils
import org.cyclos.entities.access.SessionProperties
import org.cyclos.entities.utils.TimeInterval
import org.cyclos.impl.system.ScriptStorageHandler

import org.cyclos.entities.users.BasicUser
ScriptStorageHandler scriptStorageHandler = binding.scriptStorageHandler
String sessionToken = binding.sessionToken
SessionProperties sessionProperties = binding.sessionProperties

def storage = scriptStorageHandler.getIfValid("session_${sessionToken}")
if (storage != null) {
    storage.sessionProperties = sessionProperties    
}

Function to search for connected users:

import org.cyclos.entities.system.QScriptStorage
import org.cyclos.entities.system.ScriptStorage
import org.cyclos.impl.access.UserPrincipal
import org.cyclos.impl.utils.persistence.EntityManagerHandler
import org.cyclos.model.users.users.ConnectedUserQuery
import org.cyclos.server.utils.JacksonParameterStorage
import org.cyclos.utils.PageImpl
import org.cyclos.utils.StringHelper

import com.fasterxml.jackson.databind.ObjectMapper

ConnectedUserQuery query = binding.query
EntityManagerHandler entityManagerHandler = binding.entityManagerHandler
ObjectMapper objectMapper = binding.objectMapper
QScriptStorage ss = QScriptStorage.scriptStorage

// First we get the persisted script storages which start with 'session_'
PageImpl page = entityManagerHandler
        .from(ss)
        .where(ss.key.like("session\\_${StringHelper.repeat('_', 64)}", '\\'.charAt(0)),
        ss.expirationDate.after(new Date()))
        .orderBy(ss.creationDate.asc())
        .page(query, ss)

// Each one is parsed as JSON and converted to the expected format
page.pageItems = page.pageItems.collect { ScriptStorage it ->
    def storage = new JacksonParameterStorage(objectMapper, it.content)
    UserPrincipal principal = storage.principal
    return [
        sessionToken: StringHelper.removeStart(it.key, "session_"),
        user: principal.basicUser,
        creationDate: it.creationDate,
        channel: storage.channel,
        remoteAddress: storage.remoteAddress
    ]
}
return page

4.3.9. Password handling

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.

4.3.9.1. Examples

Matching passwords to the script parameters

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

4.3.10. Extension points

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:

  • Custom credit 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. If 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 advertisement could publish the advertisement 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:

4.3.10.1. User extension point

Extension points which monitor events on users, including administrators, brokers and regular users. Additional bindings:

Events:

  • create: a user is being registered. IMPORTANT: When e-mail validation is enabled, the user will be pending until confirming the e-mail. If you have e-mail confirmation enabled, this event might not be what you need, but activate instead.
  • activate: a user is being activated for the first time. For example, if e-mail validation is enabled, after the user confirming the e-mail address this event will be triggered. However, the initial status for users (set in group) might be, for example, disabled. In that case, only when the user is first activated this event will be triggered.
  • update: a user profile (full name, login name, e-mail or custom fields) is being edited. Additional bindings:
  • changeGroup: The user's group is being changed.
  • changeStatus: The user's status is being changed. Argument Map:

4.3.10.2. Operator extension point

Extension points which monitor events on operators. Additional bindings:

Events:

4.3.10.3. Address extension point

Extension points which monitor events on addresses. Additional bindings:

Events:

  • create:An address is being created.
  • update: An address is being updated. Additional bindings:
    • currentCopy: A detached copy of the address being edited, as org.cyclos.entities.users.UserAddress
  • delete: An address is being deleted.

4.3.10.4. Phone extension point

Extension points which monitor events on user phones. Additional bindings:

Events:

  • create: A phone is being created.
  • update: A phone is being updated. Additional bindings:
    • currentCopy: A detached copy of the phone being edited, as org.cyclos.entities.users.Phone
  • delete: A phone is being deleted.

4.3.10.5. Record extension point

Extension points which monitor events on records, either user or system records. Additional bindings:

Events:

  • create: A record is being created.
  • update: A record is being created. Additional bindings:
  • delete: A record is being created.

4.3.10.6. Advertisement extension point

Extension points which monitor events on advertisements. Additional bindings:

Events:

  • create: An advertisement is being created.
  • update: An advertisement is being updated. Additional bindings:
  • delete: An advertisement is being deleted.

4.3.10.7. Transaction extension point

Extension points which monitor events on performed transactions.

The following additional bindings are available for both preview and confirm events:

Events:

4.3.10.8. Transaction authorization extension point

Extension points which monitor transaction authorization actions. Additional bindings:

Events:

  • authorize: The transaction is being authorized. Be careful: there might be more authorization levels which need to be authorized before the transaction is finally processed. Additional bindings:
  • deny: The transaction is being denied by the authorizer.
  • cancel: The transaction is being canceled by the performer.
  • expire: The transaction is being expired by the system through a polling task. If the transfer type requires authorization, it is possible to define an expiration period to avoid leaving the payment indefinitely pending state.

4.3.10.9. Transfer extension point

Argument Map (common for all events):

  • transfer: The transfer being affected.

Events:

4.3.10.10. Voucher extension point

Argument Map (common for all events):

  • voucher: The voucher being affected.

Events:

  • generate: A voucher is being generated.
  • buy: A voucher is being bought by a user.
  • redeem: A voucher is being redeemed. Additional bindings:
    • redeemer: The voucher redeemer
  • cancel: A generated voucher is being canceled.
  • expire: A voucher is being expired.

4.3.10.11. Agreement extension point

Extension points which triggers when an agreement is accepted or when an optional agreement is no longer accepted by a user.

On public registrations, if the e-mail validation is enabled, the events won't trigger. Only after the user is activated, an event will trigger for each of the accepted agreements.

Additional bindings:

Events:

  • accept: An agreement is being accepted.
  • reject: An optional agreement which was previously accepted is no longer accepted.

4.3.10.12. Import extension points

Extension points which monitor events on imports, such as when importing users, transfers, transactions, etc. Additional bindings:

Events:

  • File status changed: The whole imported file status has changed. Additional bindings:
  • Line read: An imported line was read from the CSV file. Additional bindings:
  • Line processed: A line is being processed. On the validated phase the line isn't yet processed. On the saved phase, the line was processed, either with success or error. Additional bindings:
    • line: The org.cyclos.entities.system.ImportedLine being processed
    • entity: Only on the saved phase when success (null when error). The entity which was created. The actual type depends on the import type. Can be a user, an advertisement, a transfer, a transaction, a record, a voucher, etc.
    • error: Only on the saved phase when error (null when success). The Java error which was thrown when processing the line

4.3.10.13. Examples

Granting extra credit (on demand) before payments

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 debitUnits, and it should have a payment transfer type to the user account. That payment transfer type should have the internal name extraCredit. 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. Use this in the "Script code executed when the data is saved" code block:

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, "debitUnits")
    PaymentTransferType paymentType =  entityManagerHandler.find(
            PaymentTransferType, "extraCredit", 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
}

Send an e-mail on every payment

This example allows, for the selected payment types in the extension point details, to send an e-mail to an specific address. Use this in the "Script code executed when the data is saved" code block:

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
}

Assign / unassign individual products when the user accepts / rejects agreements

Starting with Cyclos 4.13, there are optional agreements. This example assigns / unassigns individual products to the user that accepts / rejects agreements.

The script parameters must be in the form:

agreementInternalName1=productInternalName1
agreementInternalName2=productInternalName2
...

Make sure the "Run with all permissions" checkbox is selected. Then, use this script in the "Script code executed when the data is saved" code block:

import org.cyclos.entities.access.Agreement
import org.cyclos.entities.users.User
import org.cyclos.impl.users.ProductsUserServiceLocal
import org.cyclos.model.system.extensionpoints.AgreementExtensionPointEvent
import org.cyclos.model.users.products.ProductVO
import org.cyclos.model.users.users.UserVO

// Get the variables from context
AgreementExtensionPointEvent event = binding.event
User user = binding.user
Agreement agreement = binding.agreement
ProductsUserServiceLocal productsUserService = binding.productsUserService
Map<String, String> scriptParameters = binding.scriptParameters 

// Lookup the product by agreement internal name
def productVO = new ProductVO(internalName: scriptParameters[agreement.internalName])
def userVO = new UserVO(user.id)
def assigned = user.products.find { it.internalName == productVO.internalName } != null

if (event == AgreementExtensionPointEvent.ACCEPT) {
    // Assign the individual product
    if (!assigned) {        
        productsUserService.assign(productVO, userVO)
    }
} else {
    // Unasign the individual product
    if (assigned) {        
        productsUserService.unassign(productVO, userVO)
    }
}

In the extension point itself, select all agreements whose internal names are included in the script parameters, the user groups and both events.

Enforcing the user remains with a minimum balance for a payment type

This example forces the user to remain with a minimum balance for the payment types configured in the extension point. The extension point should be of type transaction, and the event should be confirm. Paste the following on "Script code executed when the data is validated" code block:

import org.cyclos.entities.banking.Account
import org.cyclos.entities.utils.CurrencyAmount
import org.cyclos.impl.banking.AccountServiceLocal
import org.cyclos.impl.utils.formatting.FormatterImpl
import org.cyclos.model.ValidationException
import org.cyclos.model.banking.transactions.PerformTransactionDTO

Account account = binding.fromAccount
PerformTransactionDTO performTransaction = binding.performTransaction
AccountServiceLocal accountService = binding.accountService
Map<String, String> scriptParameters = binding.scriptParameters
FormatterImpl formatter = binding.formatter

def minBalance = new BigDecimal(scriptParameters.minBalance)

def balance = accountService.getBalance(account, null)
def newBalance = balance - performTransaction.amount
if (newBalance < minBalance) {
    throw new ValidationException("""This operation cannot be processed,
        as your new account balance would be
        ${formatter.format(new CurrencyAmount(account.currency, newBalance))},
        below the minimum allowed balance of
        ${formatter.format(new CurrencyAmount(account.currency, minBalance))}""")
}

4.3.11. Custom operations

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:

  • System: Those are executed by administrators (with granted permissions), directly from the main menu;
  • User: Custom operations which are related to a user, and can either be executed by the own user (with granted permissions), from the main menu or run by administrator or brokers (also, with granted permissions) when viewing the user profile. In both cases, the custom operation needs to be enabled to users via member products. For example, there might be operations which applies only to businesses, not consumers, and even administrators with permission to run them shouldn't be able to run them over consumers. It is enforced that administrators / brokers will only be able to run custom operations over users they manage;
  • Menu: These custom operations are executed by a custom menu entry. This is the only possible custom operation scope that can be run by guests. A classical example of this is a "Contact us" page;
  • Internal: An internal custom operation is executed either as an action (see below) or when the user clicks a row returned by another custom operation which returns a table with results;
  • Advertisement: Custom operations which are executed over an advertisement;
  • Record: Custom operations which are executed over a record;
  • Transfer: Custom operations which are executed over a transfer (balance transfer between accounts);
  • Contact: Custom operations which are executed over a contact in a user's contact list;
  • Additional contact information: Custom operations which are executed over an additional contact information in a user's profile;
  • Bulk action: Custom operations executed on bulk actions, over each user individually.

Bound variables:

Return value:

The required return value depends on the custom operation result type. In all cases, the result type for the CustomOperationService.run() method is a org.cyclos.model.system.operations.RunCustomOperationResult. But, depending on the custom operation result type, the value returned by the script is handled differently, as shown below:

  • Plain text or Rich text: In these cases, the result has a title and a content. The script must return one of the following:
    • A plain string, which is considered as the result content. The header will be the custom operation name;
    • An object (or map) containing the following properties:
      • content: The result content;
      • title: The result title.
  • Notification: In all cases, notifications are assumed to be HTML formatted. The script must return one of the following:
    • A plain string, which is considered as an information notification;
    • A string prefixed with either [INFO], [WARN] or [ERROR]. In this case, those prefixes are removed from the notification and the notification level is set ;
    • An object (or map) containing the following properties:
  • File download: The script must return an instance of org.cyclos.model.utils.FileInfo, or an object or Map with the same properties. The properties are:
    • content: Required. The file content. May be an InputStream, a File or a String (containing the file content itself).
    • contentType: Required. The MIME type, such as text/plain, text/html, image/jpeg, application/pdf, etc.
    • name: Optional file name, which will be used by browsers to suggest the file name to save.
    • length: Optional file length, which may aid browsers to monitor the progress of file downloads.
  • Page results: The script must return an object (or map) with the following properties:
    • columns: Either this or headers must be returned. Contains each column definition. Each column is a org.cyclos.model.system.operations.PageResultColumn or equivalent object. Each column can define a result property to display (otherwise it is assumed that each result is an array, accessed by index). Additionally, defines the type, header, width, align, vertical align. The type is a org.cyclos.model.system.operations.PageResultColumnType or equivalent string, such as: 'string', 'boolean', 'number', 'date' or 'currencyAmount'. Returning as currency amount is a special case, where exports can use this information to correctly format the amount. Also, when the type is boolean, number or date, results are sent with a suitable representation in a standard form, rather than formatted.
    • headers: Can be returned instead of columns. A list containing the column headers. Is supported to ease simple cases and to maintain compatibility with scripts written from Cyclos versions before 4.5.
    • rows: Optional. A list of objects, each containing properties. Each column matches the corresponding object property to display each cell. An object can have additional properties, which can be used to pass parameters to the url when clicking a row.
    • results: Optional. Can be returned instead of rows. A list of lists, containing the table cells. The inner lists should have the same size as the columns.
    • totalCount: Optional, used to page results. If a total count is returned, a result page navigator is shown to the user, and records can be returned page-by-page. The script should probably use the currentPage and pageSize bound variables.
    • hasNextPage: Indicates that there are more rows to be returned than this page. Ignored if the totalCount is returned. Another way to return this is to limit results to 'pageSize + 1'. When more results are returned than the page size, the list is truncated, but Cyclos has the information that there's more data.
  • URL: The script must return one of the following:
    • A plain string, which is considered as the URL, and the user is redirected to that URL in the same browser window;
    • An object (or map) containing the following properties:
      • url: The destination URL
      • newWindow: A boolean value indicating whether the application will open a new browser window with the destination URL. Most browsers block popups by default, and opening in a new window is probably considered a popup by browsers. Hence, when opening a new window, on the first execution, users might be prompted whether the popup is allowed. Then they might need to run the operation again once the popup is allowed.
  • External redirect: This return type has 2 different scripts:
    • The first script should prepare the data in some external system, and then return the URL to which the user should be redirected. An example using this kind of script is the PayPal Integration, in which the first scripts creates a payment in PayPal, to later one be confirmed by the user. Two noteworthy variables bound to the script context which are necessary for this script are:
      • returnUrl: The Cyclos URL that will call the second script. Normally, external services receive such URL to redirect the user once the operation is finished.
      • parameterStorage: A parameter storage which can be used to share data between the fist and the second script. Any data stored here will be automatically persisted and retrieved for the second script. There is a limit of a few hours (4-5) for the same execution context to be valid between the first and the second scripts, after which it is removed.
    • The second script is triggered after the external site redirects the user back to Cyclos. This script must return an HTML content which is shown to the user. After being redirected back to Cyclos, the previous web application state, such as breadcrumb, current page, etc, will be lost. Just the returned HTML content will be shown.
  • Bulk action: The script is executed for each user affected by the bulk action, and must return one of the following:
    • Null: Represents the user was skipped;
    • Boolean: True represents the user was processed, false means the user was skipped;
    • Status: If the result is a org.cyclos.model.users.bulkactions.BulkActionUserStatus, or a string corresponding to one of its item names, a that status is assumed, with no message;
    • String: The string (unless represents the name of a BulkActionUserStatus item) represents the message, assuming the user was successfully processed;
    • Throwable: An error. Normally errors are expressed by throwing exceptions, but it is also possible to return one;
    • Specific result: An instance of org.cyclos.impl.users.BulkActionUserResult.

Additionally, as part of the returned result object, you can specify what to do after a successful operation execution. Although you can specify that information for all result types, the Cyclos web application will process it only for 'Notification', 'URL' (with 'newWindow' in true) and 'External redirect' result types (except for 'autoRunAction'). The following properties can be specified in the result:

  • backTo: Contains the org.cyclos.model.system.operations.CustomOperationVO to go back. If the page containing the given operation is not found in the history then the Cyclos web application will stay in the current page;
  • backToRoot: A boolean value indicating if the application must go back to the page that originated the custom operation executions. If we already are in a 'root page' then the Cyclos web application will stay in the current page. For example, an operation with scope 'User' containing an action (action 1) and this in turn containing another action (action 1.1) could generate the following pages history: View user profile → Run user custom operation → Run Custom operation action1 → Run Custom operation action1.1, in this case the flag 'backToRoot' in true means go back to the 'View user profile' page;
  • reRun: A boolean value indicating if the page we went back to or the current one (if 'backTo' was not specified or 'backToRoot' is false) must be executed again before display it;
  • autoRunAction: Either the id or internal name of the action that should be executed automatically. If it is specified, the Cyclos web application don't show the result and run the action automatically, as it will if the user manually execute it by the corresponding action button.

The custom operation scripts also support 3 other script blocks. They both receive the following bind variables:

  • The custom operation;
  • Either the user, record, advertisement, contact, contactInfo or transfer (depending on scope). See Bound variables;
  • The form parameter: The map will contains only the custom fields passed as parameter to the operation. When running any custom operation through the Cyclos web application the parameters contained in this map will be those mapped in the action definition plus those defined by the script of the action container operation (i.e the operation containing the action). If used through the REST API then you could pass any extra operation custom field as parameter and it will be contained in the Map. In case of the script used to check for the availability of an operation, if the operation is not an 'Internal' one then this map will be binded (to avoid errors in the script if it references the variable) but empty.

These blocks are:

  • Code executed before the form is show, to fill the initial field values: This script will be executed before showing the form the user enters the script parameters (custom fields), so it can determine the default form parameters dynamically. It should return a java.util.Map<String, Object>, containing, per custom field internal name, the default value that should be presented for the user. Additionally, as part of the returned result object, you can specify an action to run automatically with the following property:
    • autoRunAction: Either the id or internal name of the action that should be executed automatically. The Cyclos web application ignore this property if it is specified, it will show the form with the actions or the result if the operation can run directly.
  • Code executed to determine whether the custom operation will be available: This script can decide to disable the custom operation from being shown as an option to be executed. For example, for scope Record, some custom operation could only make sense if the record has a particular custom field value. If this script returns false, the operation will not be shown as an option. Any other value will enable the operation. This script will also run for custom operations used as actions (see below) of other custom operations. In this case, the 'formParameters' bound variable will contain only the parameters that would be sent to the action, if it is executed, according to the mapped values in the action configuration. Also, for actions, an additional available variable for the script is containerCustomOperation, which is main custom operation which is currently being executed, and that will contain the action.
  • Script code executed when the external site redirects the user back to Cyclos: This script is executed only if the operation result type is 'External redirect'. It runs after the external service redirects the user back to Cyclos. The script will have access to the same 'storage' object that was available to the main script block, so using that object it is possible to pass data between both executions. The callback also runs with the same 'sessionData' as the original script, they will have the same logged user, permissions, etc. Additionally, it is possible to read the original request parameters using the 'request' variables.

4.3.11.1. Actions

Custom operations can have additional actions. Each action points to another custom operation with scope 'Internal'. The original custom operation can be configured for which actions are available, which parameters are passed to that action and the visibility (when are shown to the user). Each parameter of the action operation may be mapped to a parameter of the original custom operation or left for the original operation script to resolve the parameters that will be set to the action operation. The visibility is applied to know if an action should be shown to the user, it can be before, after or both (before and after) the operation is executed.

Actions can be configured on custom operations of result type 'Plain text', 'Rich text' or 'Result page'. The label defined for the custom operation pointed by an action will be used as the button label associated to that action, as the full name could be too large for buttons.

To control the actions, the corresponding original script has to set a property named 'actions' as part of the returned result. It should be a map keyed by the action operation internal name, and whose values contains the following properties:

  • parameters: Contains another map, keyed by parameter (form field of the action operation) internal name, with the value that should be used as that parameter. If there is a static mapping between an action parameter and an owner operation input field, and the script returns a parameter value, the script takes precedence;
  • enabled: Whether the action must be enabled or not. Default to true.

Actions showed before the execution of the original custom operation are customized by script executed before the form is show and those showed after are customized by the execution script.

Here is an example of a script for a custom operation of result type 'Rich text' with two actions:

return [
    content: "This is the content displayed after the operation is executed",
    actions: [
        action1: [
            // action1 is the internal name of the custom operation (with scope 'Internal') pointed by an action.
            parameters: [
                input1: "Value for input 1",
                input2: "1234" // Here input1 and input2 are internal names of
                // form fields in the action1 custom operation
            ]
            // action1 is enabled by default
        ],
        action2: [
            enabled: false // action2 is disabled for this execution
        ]
    ]
]

Here is an example of a script executed before show the form for a custom operation of result type 'Rich text' with two actions (with visibility before or both) :

return [
    field1: "Default value" // Here field1 is a form field in the custom operation
    actions: [
        action1: [
            // action1 is the internal name of the custom operation (with scope 'Internal') pointed by an action.
            parameters: [
                input1: "Value for input 1",
                input2: "1234" // Here input1 and input2 are internal names of
                // form fields in the action1 custom operation
            ]
            // action1 is enabled by default
        ],
        action2: [
            enabled: false // action2 is disabled before the execution
        ]
    ]
]

4.3.11.2. Examples

Contact us page

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

The custom operation needs form parameters with the following internal names: "from", "email", "subject" and "message".

Generating an account number for all accounts which doesn't have a number yet

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"
Returning a string (notification / rich / plain text) and external redirect

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.

Returning a file

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()
]

View users I've traded with

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 result page. 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.impl.banking.AccountServiceLocal
import org.cyclos.model.ValidationException
import org.springframework.jdbc.core.ColumnMapRowMapper
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate

AccountServiceLocal accountService = binding.accountService

List<Long> accountIds = accountService.list(user).collect {acc -> acc.id}
if (accountIds.empty) {
    throw new ValidationException("No accounts")
}

NamedParameterJdbcTemplate jdbc = binding.namedParameterJdbcTemplate

// First count the number of users / currencies that traded with the current user
Integer totalCount = null
if (!skipTotalCount) {
    totalCount = jdbc.queryForObject("""
        select count(*)
        from (
            select distinct user_id, currency_id
            from (
                select user_id, currency_id
                from (
                    select u.id as user_id, at.currency_id, max(t.date) as last_date, max(t.amount) as max_amount, count(*) as count
                    from transfers t inner join accounts a on t.to_id = a.id
                    inner join users u on a.user_id = u.id
                    inner join account_types at on a.account_type_id = at.id
                    where t.from_id in (:accountIds)
                    group by u.id, at.currency_id
                    union
                    select u.id as user_id, at.currency_id, max(t.date) as last_date, max(t.amount) as max_amount, count(*) as count
                    from transfers t inner join accounts a on t.from_id = a.id
                    inner join users u on a.user_id = u.id
                    inner join account_types at on a.account_type_id = at.id
                    where t.to_id in (:accountIds)
                    group by u.id, at.currency_id
                ) t1
                group by user_id, currency_id
            ) t2
        ) t3
    """, [accountIds: accountIds], Integer);
}

// Then get the data
int pageSize = binding.pageSize
int currentPage = binding.currentPage
def rows = jdbc.query("""
    select u.id, u.display_for_managers, t.currency_id as "currencyId", t.last_date as "lastDate", t.max_amount as "maxAmount", t.count
    from (
        select user_id, currency_id, max(last_date) as last_date, max(max_amount) as max_amount, sum(count) as count
        from (
            select u.id as user_id, at.currency_id, max(t.date) as last_date, max(t.amount) as max_amount, count(*) as count
            from transfers t inner join accounts a on t.to_id = a.id
            inner join users u on a.user_id = u.id
            inner join account_types at on a.account_type_id = at.id
            where t.from_id in (:accountIds)
            group by u.id, at.currency_id
            union
            select u.id as user_id, at.currency_id, max(t.date) as last_date, max(t.amount) as max_amount, count(*) as count
            from transfers t inner join accounts a on t.from_id = a.id
            inner join users u on a.user_id = u.id
            inner join account_types at on a.account_type_id = at.id
            where t.to_id in (:accountIds)
            group by u.id, at.currency_id
        ) t1
        group by user_id, currency_id
    ) t inner join users u on t.user_id = u.id
    order by t.count desc, u.display_for_managers
    limit :limit
    offset :offset
""", [accountIds: accountIds, limit: pageSize + 1, offset: pageSize * currentPage], new ColumnMapRowMapper());

// Build the result
return [
    columns: [
        [header: "User", property: "display_for_managers", width: "40%"],
        [header: "Last date", property: "lastDate", align: "center", type: "date", width: "20%"],
        [header: "Max amount", property: "maxAmount", currencyProperty: "currencyId", align: "right", width: "20%"],
        [header: "Transactions", property: "count", align: "right", type: "number", width: "20%"],
    ],
    rows: rows,
    totalCount: totalCount
]

Search currently running background tasks

In this example, an administrator can search for background tasks which are scheduled to run. In regular usage, there shouldn't have many background tasks running at a given time. However, some operations such as indexing entities on Elasticsearch or charging account fees will generate many tasks. The custom operation needs to have system scope and result type result page.

import org.apache.commons.lang3.StringUtils
import org.cyclos.entities.utils.QBackgroundTaskExecution

def e = QBackgroundTaskExecution.backgroundTaskExecution
def page = entityManagerHandler.from(e)
        .orderBy(e.id.asc())
        .page(currentPage, pageSize, skipTotalCount, e)
return [
    columns: [
        [header: "Execution started at", property: "submittedAt"],
        [header: "Task", property: "className"],
        [header: "Context", property: "context"],
    ],
    rows: page.pageItems.collect {
        [
            submittedAt: it.submittedAt,
            className: StringUtils.substringAfterLast(it.className, '.'),
            context: it.context
        ]
    },
    totalCount: page.totalCount,
    hasNextPage: page.hasNextPage
]

Get easy invoice link / QR-Code

This example presents users a link and QR-code which can be shared for other users to pay him / her (easy invoice). The QR-code can be scanned by the Cyclos mobile application from the payer. To use it, you will need the following content in the script parameters box:

## The message shown above
message=You can copy and share the following easy invoice link or QR-code, \
which can be scanned by the Cyclos mobile application:

## Currency to be appended to the URL.
## Not needed if users have a single currency.
#currency=unit

## Payment type to be appended to the URL.
## Not needed if users have a single payment type.
#paymentType=user.tradeTransfer

Then, use the following script code:

import org.apache.commons.text.StringEscapeUtils
import org.cyclos.utils.StringHelper

def rootUrl = sessionData.configuration.fullUrl

// Get the amount
def amount = formParameters.amount.toPlainString()

// Get the description
def description = formParameters.description

// Get the to user his username
def to = user.username

def parameters = "&amount=${amount}"
if (StringHelper.isNotBlank(description)) {
    description = StringHelper.encodeURIComponent(description)
    description = StringHelper.replace(description, "+", "%20")
    parameters += "&description=${description}"
}
if (StringHelper.isNotBlank(scriptParameters.currency)) {
    parameters += "&currency=${scriptParameters.currency}"
}
if (StringHelper.isNotBlank(scriptParameters.paymentType)) {
    parameters += "&type=${scriptParameters.paymentType}"
}

def url = "${rootUrl}/pay/?to=${to}${parameters}"
def qrCode = "${rootUrl}/api/tickets/easy-invoice-qr-code/*:${to}?size=medium${parameters}"

// Return the result
return """
<p>${scriptParameters.message}</p>
<p>
    <br>
    <a href="${url}" target="_blank">${StringEscapeUtils.escapeHtml4(url)}</a>
</p>
<p style="text-align:center">
    <br>
    <img src="${qrCode}">
</p>
"""

Then create the custom operation:

  • Name: Easy invoice
  • Script: Select the Get easy invoice link / QR-Code script
  • Scope: user
  • Result type: Rich text

Finally, after saving, add the following form parameters:

  • Amount
    • Internal name: amount
    • Data type: Decimal
    • Decimal digits: 2 (adjust according to the currency)
    • Required: Yes
  • Description
    • Internal name: description
    • Data type: Multi-line text
    • Required: No

Loan request (content page with action)

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:

  • Name: Send loan application
  • Internal name: submitLoanApplication
  • Label: Send
  • Script: Select the one with the code to send the application
  • Main menu: Banking
  • Scope: internal
  • Result type: notification

Then, after saving, add the following form parameters:

  • Total amount
    • Internal name: amount
    • Data type: Decimal
    • Decimal digits: 2
    • Required: Yes
  • Number of installments
    • Internal name: installments
    • Data type: Integer
    • Required: Yes
  • User
    • Internal name: user
    • Data type: Linked entity
    • Linked entity type: User
    • Required: Yes

Then create the custom operation with the loan application form:

  • Name: Loan application
  • Internal name: loanApplication
  • Script: Select the loan application request script
  • Scope: user
  • Result type: Rich text

Then, after saving, add the following form parameters:

  • Total amount
    • Internal name: amount
    • Data type: Decimal
    • Decimal digits: 2
    • Required: Yes
  • Number of installments
    • Internal name: installments
    • Data type: Integer
    • Required: Yes

And add another action in the actions tab:

  • Total amount: Map to this operation's Total amount
  • Number of installments: Map to this operation's Number of installments
  • User: Set it for the script to define the value

After granting permission to the Loan application custom operation, it should appear in the menu

4.3.11.3. Possibilities for custom operations that return a result page

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:

  • Navigate to an external URL: When clicking a row, the user is redirected to an external URL.
  • Navigate to a location in Cyclos: A list of common locations in Cyclos are presented.
  • Run an internal custom operation: Allows running a custom operation which has the scope = 'Internal'. This new operation will probably present some content to the user.

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}"
]

4.3.11.4. Running custom operations on bulk actions

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 there are two examples:

Perform payment

This 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=toUser
Then 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
Remove canceled tokens

In this example a bulk action can be used to remove canceled tokens optionally filtering by type. If a type is selected, only the canceled tokens of that type will be removed, otherwise all canceled tokens will be removed.

First create the script to load token principal types:

  • Script name: Load token types (Can be changed)
  • Run with all permissions: Yes
  • Parameters: (Leave empty)
  • Script code:

import org.cyclos.model.system.fields.DynamicFieldValueVO

def values = []
principalTypeService.listUserTokenPermissions(null).each{
   values.add(new DynamicFieldValueVO(it.getType().getInternalName(), it.getType().getName()))
}
return values

And the script for the custom operation:

  • Script name: Remove canceled tokens (Can be changed)
  • Run with all permissions: Yes
  • Parameters: (Leave empty)
  • Script code:

import org.cyclos.model.access.principaltypes.TokenPrincipalTypeVO
import org.cyclos.model.access.tokens.TokenQuery
import org.cyclos.model.access.tokens.TokenStatus
import org.cyclos.model.users.users.UserVO
import org.cyclos.model.utils.ModelHelper
import org.cyclos.utils.CollectionHelper

def query = new TokenQuery()
query.setUnlimited()
query.setUser(conversionHandler.convert(UserVO.class, user))
query.setStatuses(CollectionHelper.asSet(TokenStatus.CANCELED))
if (formParameters.tokenType) {
    query.setType(ModelHelper.voFromString(TokenPrincipalTypeVO.class, formParameters.tokenType.value))
}

tokenService.search(query).getPageItems().each{ tokenService.remove(it.getId()) }

return "Tokens removed successfully"

Then create the custom operation:

  • Name: Remove canceled tokens (Can be changed)
  • Scope: Bulk action
  • Script: Remove canceled tokens (The script created above)
  • Show form: Always

Finally, after saving, add the following form parameter:

  • Display name: Token type
  • Internal name: tokenType
  • Data type: Dynamic selection
  • Load values script: Load token types (The first script created)
  • Field type: Dropdown
  • Required: No (Set it in "Yes" if you don't want to let the user who runs the bulk action remove tokens of all types at once)

4.3.12. Custom wizards

These scripts are invoked when a user runs a custom wizard. A custom wizard can be of the following types:

  • Registration: Replaces the registration form. Gives the opportunity to present custom fields defined in the wizard itself, allowing more data to be collected and processed by script. Besides creating the script and the wizard, in the Configuration menu, the wizards should be set for registration on large screens (desktops), medium screens (tablets) and small screens (phones). When all 3 are set, the regular registration form / API is disabled;
  • User: The wizard is executed by users via a menu item. Can also be set for administrators and brokers to run over other users via the profile;
  • System: The wizard is executed by administrators via a menu item;
  • Guest: The wizard is executed by guests. Can be shown in a menu via the menu entries in content management.

A wizard is comprised of several steps, which are manually ordered. When the wizard starts, by default the first step is shown. On each transition, by default the next step is executed, until the last step. After finishing the wizard, a result is shown. The script can control which is the first step, and which are the possible transitions between steps. The transitions are determined before the step is shown, because each possible transition is displayed as a different button to users. When the script doesn't return any transitions, the default is to use a single transition to the next step in the defined order.

Bound variables:

  • wizard: The org.cyclos.entities.system.CustomWizard being executed.
  • execution: The org.cyclos.entities.system.CustomWizardExecution.
  • step: The current org.cyclos.entities.system.CustomWizardStep. On transition, is the next step.
  • previousStep: The previous org.cyclos.entities.system.CustomWizardStep. Only present on transitions.
  • transition: The transition id the user has selected. Only present on transitions.
  • user: The org.cyclos.entities.users.User. The meaning depends on the wizard type. For registration wizards is only present in the finish function, and is the newly registered user. For user wizards is the user over which it is being executed.
  • steps: The list of all steps, together with the possible transitions executed so far (including the current step. Is a list of org.cyclos.entities.system.CustomWizardStep.
  • storage: The org.cyclos.impl.system.CustomWizardExecutionStorage. Can be used by scripts to store / retrieve data at any moment of the execution.
  • registration: The org.cyclos.model.users.users.PublicRegistrationDTO which is filled-in on each step. Only present for registration wizards. To persist any modification, assign it back to the storage (storage.registration = registration).
  • customValues: A map keyed by custom field internal name, whose values are the values filled-in during the execution. Do not confuse it with custom profile fields, which are stored in the registration object. To persist any modification, assign it back to the storage (storage.customValues = customValues).
  • returnUrl: The URL to pass to the external system on external redirects. Is the URL to which the external system should redirect the user when returning to Cyclos.
  • request: The org.cyclos.model.utils.RequestInfo. Only present on the script that runs the callback after an external redirect. Contains the information about the current request, so the script function which handles the callback can identify the context to complete the process.

Code blocks:

There are several code blocks on custom wizard scripts. The expected return type depends on the code block:

  • Script code executed when the wizard finishes: This is the only required code block. The script is executed after finishing the last step. For registration wizards, the script is executed after the user has been registered, so additional actions can be performed on that user, and the result is ignored. For other wizards, this is the action executed on finish, and the script can return one of the following:
    • A plain string. The result content, which is handled either as plain text or HTML depending on the wizard configuration;
    • An object containing the properties title and result.
  • Script code executed when a new execution starts: This code is executed whenever a new execution starts. The result is handled as the initial step, and can be one of the following:
    • A plain string, interpreted as the internal name of the first step. The only transition will be the next step in the defined order;
    • A org.cyclos.entities.system.CustomWizardStep. The only transition will be the next step in the defined order;
    • An object (or map) containing the following properties:
      • step: The step, interpreted as described above;
      • transitions: A collection or single value interpreted as transitions. A transition can either be a string or step, interpreted as step, or an object with the following properties:
        • id: The transition id. Will be the one clients need to pass in to the transition operation.
        • label: The transition label. Is the text shown to users in the execution page for this transition.
        • step: A string or step to which the transition leads to.
    When no step is returned, the first step is assumed. When no transitions are returned, it is assumed a transition with id 'next' which goes to the following step after the first one.
  • Script code executed on transitions between steps: This code is executed whenever an execution is transitioned between steps. As the previous and next steps are known (in variables 'previousStep' and 'step', respectively), the script determines which are the possible transition from the next step to the subsequent steps. The result can be one or a collection of transitions, which are either strings (interpreted as step internal name) or objects / maps containing the following properties:
    • id: The transition id. Will be the one clients need to pass in to the transition operation.
    • label: The transition label. Is the text shown to users in the execution page for this transition.
    • step: A string or step to which the transition leads to.
    When nothing is returned, a single transition 'next' will be used, pointint to the next step.
  • Script code executed before the user is redirected to an external site: This code is executed when the user confirms a step which is configured as external redirect. It is used to interact with an external system during the wizard. For example, a top-up could be required during the registration process. The result is a URL to which the user will be redirected. Please, note the returnUrl bound variable, which is the URL to pass to the external system, indicating where the external system should redirect the user back to Cyclos.
  • Script code executed when the external site redirects the user back to Cyclos: This code is executed after the external redirect completes. The result can be one of the following:
    • null or true: The execution will automatically transition to the next step in the defined order;
    • false: Is interpreted as canceling the external redirect action. The execution will stay in the current step.
    • string: Is handled as the identifier of the transition for the next step.

Tips:

  • Use the 'storage' object to store and retrieve custom data on any step of the current execution. The storage also provides access to specialized data within the execution. See the class JavaDoc for more details.
  • The script can send notifications, which are displayed in the current step. For that, use either 'storage.info', 'storage.warn' or 'storage.error' with the message. Once a transition happens, any pending notifications are cleared.
  • If the wizard has a step that redirect to an external site, that step cannot be the last one. That is because after the redirect back to Cyclos, the wizard will transition automatically to the next step. The wizard execution page will have to query the current status. If the execution would have ended, all the context would have been lost for that execution.

4.3.12.1. Examples

Registration with a required top-up

This example requires a top-up via PayPal for the public user registration. It uses the same PayPal library from PayPal Integration. Make sure you have the library code updated.

The example uses 3 script blocks for the wizard script, plus script parameters. So, set the following in the script:

  • Parameters:
    # Settings for the access token record type
    auth.recordType = paypalAuth
    auth.clientId = clientId
    auth.clientSecret = clientSecret
    auth.token = token
    auth.tokenExpiration = tokenExpiration
    
    # Settings for PayPal
    mode = sandbox
    currency = EUR
    paymentDescription = Initial top-up
    
    # 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 top-up. Please, try again.
    error.notApproved = The top-up was not approved
    message.canceled = The top-up was canceled
    message.done = The top-up was approved
  • Script code executed when the wizard finishes:
    import org.cyclos.entities.users.User
    import org.cyclos.impl.system.CustomWizardExecutionStorage
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.model.ValidationException
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def performPayments() {
        def variables = binding.variables as Map<String, Object>
        def scriptParameters = variables.scriptParameters as Map<String, String>
        def service = new PayPalService(variables)
        def storage = variables.storage as CustomWizardExecutionStorage
        def scriptHelper = variables.scriptHelper as ScriptHelper
        def user = variables.user as User
        def orderId = storage.getString('payPalOrderId')
    
        // If no order id, return an error
        if (orderId == null) {
            throw new ValidationException('No PayPal payment data')
        }
    
        def order = service.getOrderFromPayPal(orderId)
        if(order.status == "APPROVED") {
            // Add a commit listener to perform the payments,
            // it will be executed after a successful registration
            scriptHelper.addOnCommitTransactional({
                // Execute the PayPal payment
                def capturedOrder = service.captureOrder(orderId)
                try {
                    // Try to perform the payment in Cyclos,
                    // if fails, refund the payment in PayPal
                    service.perform(capturedOrder, user)
                } catch (Exception ex) {
                    service.refundCapturedOrder(capturedOrder, null, user)
                }
            })
        } else {
            throw new ValidationException(scriptParameters.'error.notApproved'
            ?: "The payment was not approved")
        }
    }
    
    performPayments()
    
  • Script code executed before the user is redirected to an external site:
    import org.cyclos.impl.system.CustomWizardExecutionStorage
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def createOrder(){
        def variables = binding.variables
        def service = new PayPalService(variables)
        def storage = variables.storage as CustomWizardExecutionStorage
    
        def customValues = variables.customValues as Map<String, Object>
        def amount = customValues.amount as Number
        def returnUrl = variables.returnUrl as String
    
        def order = service.createOrder(amount, returnUrl)
        def link = (order.links as Map<String, Object>[])
                .find {it.rel == "approve"}
        if (link) {
            // Store the returned order id
            storage.setString('payPalOrderId', order.id as String)
            return link.href
        } else {
            throw new IllegalStateException("No approval url returned from PayPal")
        }
    }
    
    createOrder()
    
  • Script code executed when the external site redirects the user back to Cyclos:
    import org.cyclos.impl.system.CustomWizardExecutionStorage
    import org.cyclos.model.ValidationException
    import org.cyclos.model.utils.RequestInfo
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def payPalCallback() {
        def variables = binding.variables as Map<String, Object>
        def scriptParameters = variables.scriptParameters as Map<String, String>
        def storage = variables.storage as CustomWizardExecutionStorage
        def request = variables.request as RequestInfo
    
        if (request.getParameter('cancel')) {
            // The operation has been canceled. Don't transition
            storage.warn(scriptParameters.'message.canceled'
                    ?: 'The top-up was canceled')
            return false
        }
        // If no order id, return an error
        if (storage.getString('payPalOrderId') == null) {
            throw new ValidationException('Invalid request')
        }
    }
    
    payPalCallback()
    

Then, in your registration wizard, create a custom field with internal name 'amount', of type 'Decimal', and required. Assign that field to a wizard step, which cannot be the last one.

4.3.13. Custom web services

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:

  • The accepted HTTP methods: GET, POST or Both;
  • Whether the script will be executed as guest (optionally using a fixed HTTP username / password, with basic authorization) or as an authenticated user, like with other web services, using the same headers described in ???;
  • An IP address whitelist, to control which hosts can call the custom web service;
  • The URL mappings, which is a list of paths (one per line) to be matched after the <cyclos-root-url>[/network]/run root path. It is possible to specify the following types of paths:
    • Simple paths. For example, 'users', matches '<cyclos-root-url>[/network]/run/users'
    • Nested paths. For example, 'users/list', matches '<cyclos-root-url>[/network]/run/users/list'
    • Wildcards. For example, 'users/*', matches '<cyclos-root-url>[/network]/run/users/a', but not '<cyclos-root-url>[/network]/run/users/a/b'
    • Nested wildcards. For example, 'users/**', matches '<cyclos-root-url>[/network]/run/users/a/b/c'
    • Path variables. For example, 'users/{groupId}/{userId}', matches '<cyclos-root-url>[/network]/run/users/123/78', and a map with {groupId:123,userId:78} is available to the script

Bound variables:

Return value: The script may return one of the following data:

  • A org.cyclos.model.utils.ResponseInfo, allowing to totally customize the response
  • Null. In this case, the response will have status code 200 and no body.
  • A string. In this case, the response will have status code 200, Content-type: text/plain, and the returned string as body
  • An arbitrary object / collection. this case, the response will have status code 200, Content-type: application/json, and the body will contain a JSON representation of the returned object

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:

  • Make sure the script uses the security layer: Whenever using a service, use the security layer instead of the direct service implementation. For example, to use the UserService, instead of using the userService bound variable, use userServiceSecurity instead.
  • Ensure the custom web service has user authentication: On the custom web service details page, ensure it runs as user, not as guest.
  • On the script, make sure it runs with the user permisssions: On the details page of the script used by the custom web service, make sure the checkbox called Run with all permissions is unchecked. This guarantees the script will run with the exact permissions as the user

4.3.13.1. Examples

Perform a payment

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)

Single-sign-on (login users without their passwords)

With this example it is possible to login a user (create a session) without their password. This is useful when Cyclos works as a single-sign-on, with the user authenticated by some other system. Just be extra careful with the external security which will be employed, such as creating an IP address whitelist, a guest user / password, etc on the custom web service, otherwise, anyone could impersonate any user.

The script receives 2 query parameters: 'user', which is the login name (or some other identification, such as e-mail) of the user to be logged in, and 'remoteAddress', which is the remote IP address of the client accessing the third party software.

The script code is the following:

import org.cyclos.impl.access.SessionDataFactory
import org.cyclos.impl.access.SessionHandler
import org.cyclos.impl.access.SessionHandler.CreateSessionParameters
import org.cyclos.model.access.RequestData
import org.cyclos.model.access.channels.BuiltInChannel
import org.cyclos.model.users.users.UserLocatorVO
import org.cyclos.utils.StringHelper

def principal = request.parameters.user
def remoteAddress = request.parameters.remoteAddress
def user = userLocatorHandler.locate(new UserLocatorVO(principal: principal))
def requestData = new RequestData()
sessionData.requestData.copyPropertiesTo(requestData)
if (StringHelper.isNotBlank(remoteAddress)) {
    requestData.remoteAddress = remoteAddress
}
def runAs = SessionDataFactory.direct(user)
        .requestData(requestData)
        .channel(BuiltInChannel.MAIN)
        .build()
def session = sessionHandler.create(new CreateSessionParameters(runAs))
return session.sessionToken

Then create a custom web service, select that script and set the URL mapping to something like login. When performing a request to <cyclos-root>/run/login?user=consumer1&remoteAddress=183.165.12.7, a session will be created for that user, and the session token will be returned. It is then possible to redirect the client to <cyclos-root>/?Session-Token=<returned-session-token> and the user will be logged-in to Cyclos.

4.3.14. Service interceptors

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:

  • Which service(s) are intercepted;
  • Which operation(s) are intercepted;
  • Which script is executed;
  • Whether the interceptor is enabled or not.

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.

4.3.14.1. Recovering from errors in crucial services

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.

4.3.14.2. Examples

Modifying the general transfers overview default filters

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
}

Processing variables in the content of menu entries

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);
    }
}

Making mobile phone enabled for SMS by default on registrations by administrators or brokers

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
    }
}

4.3.15. Custom scheduled tasks

These scripts are called periodically by custom scheduled tasks. See System – Scheduled tasks for more details.

The bound variables are:

Return value:

  • The script should return a string, which is logged as message, and can be viewed on the application

4.3.15.1. Examples

Periodically importing a file

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}"

Periodically update a static HTML page

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 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

import groovy.xml.MarkupBuilder

def now = new Date()

QUser u = QUser.user
int users = entityManagerHandler
        .from(u)
        .where(u.status.notIn(UserStatus.REMOVED, UserStatus.PURGED),
        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"

4.3.16. Custom SMS operations

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.

4.3.16.1. Examples

Pay taxi with an SMS message

In this example SMS operation, users can pay taxi drivers via SMS. Make sure all the following are configured:

  • In the script details, the checkbox "Run with all permissions" is disabled;
  • There should be a single single transfer type enabled for the SMS operations channel, and the user performing the operation needs to have permission to perform that payment;
  • A custom profile field with internal name taxiId of type single line text, and marked as unique needs to be enabled for the product of taxi owners;
  • a user identification method of type custom field, called "Taxi id" with the taxiId field needs to be created. Make sure its internal name is also taxiId;
  • In the configuration details, in the channels tab, enable SMS operations. Then, in that channel, make sure "Taxi id" is allowed as user identification method to perform payments
  • Still in the same channel configuration page, create a new SMS operation of type Custom, selecting the alias "taxi" and the selected script.

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)
}

4.3.17. Inbound SMS handling

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.

4.3.17.1. Examples

Receiving a SMS in JSON format

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

Receiving a SMS with a custom format

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

4.3.18. Outbound SMS handling

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:

4.3.18.1. Examples

Sending SMS requests as JSON

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

Sending SMS requests as XML

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

4.3.19. Link generation

These scripts are used to generate links (URLs) which are used to point users to specific functionality. Some systems have a custom front-end for users, which means that when they receive e-mails with links, instead of pointing the links to the default Cyclos page, it should point to the custom front-end page.

Whenever the script returns null, the default link to Cyclos is generated, so the script may handle specific users / groups, and fallback to the default for other users by returning null.

The script code has the following variables bound (besides the default bindings):

  • type: The org.cyclos.impl.utils.LinkType instance, which is an enumeration defining which link type is being generated;
  • user: The org.cyclos.entities.users.User which will receive the link. May be null depending on the link type;
  • urlFilePart: The URL part which is used by the default link in Cyclos. Kept mostly for backwards compatibility, because if the default is desired, the script should return null.

For links used on notifications (type is NOTIFICATION), the following additional bound variables are used:

  • location: The notification location, as org.cyclos.model.utils.Location;
  • entityId: The identifier of the entity related to the notification;
  • entityIdParam: The parameter name to pass the entity identifier.

For links used to verify the e-mail, which are: registration validation (type is REGISTRATION_VALIDATION), e-mail change validation (type is EMAIL_CHANGE) and forgot password request (type is FORGOT_PASSWORD), the following additional bound variables are used:

  • validationKey: The key which is sent by e-mail to validate the action.

For generating the URL which is used as callback for custom operations of type external redirect (type is EXTERNAL_REDIRECT), the following additional bound variables are used:

  • execution, externalRedirectExecution: The context for the external redirect, as org.cyclos.entities.system.ExternalRedirectExecution. This class contains both the 'id' and 'verificationToken' which are used to resume the custom operation after the external redirect is performed, and hence, should be appended to the generated URL.

For generating the URL which is used as callback for custom wizards that perform an external redirect (type is WIZARD_EXTERNAL_REDIRECT), the following additional bound variables are used:

  • execution: The context for the wizard execution, as org.cyclos.entities.system.CustomWizardExecution. This class contains the 'key' to resume the wizard execution after the external redirect is performed, and hence, should be appended to the generated URL.
  • storage: The storage for wizard execution, as org.cyclos.impl.system.CustomWizardExecutionStorage. It can be used to lookup the custom fields which were filled so far, as well as the registration parameters, if the wizard is of type registration.

For generating the URL to pay a specific ticket (type is TICKET), the following additional bound variables are used:

  • ticket: The ticket to be paid, as org.cyclos.entities.banking.Ticket. This class contains the 'ticketNumber' which is used to pay the ticket, and hence, should be appended to the generated URL.

For generating the URL to pay an easy invoice (type is EASY_INVOICE), the following additional bound variables are used, besides the already mentioned 'user' which is the easy invoice destination (all additional bound variables are optional):

For generating a link to a redirect to a mobile application page (type is MOBILE_REDIRECT), or a URL with a custom schema (cyclos://) for the mobile application to open directly (type is MOBILE), the following additional bound variables are used:

  • mobileUrlFilePart: The URL part of the mobile page to open.

4.3.19.1. Examples

Link generation depending on the location

This examples returns distinct values according to location.

import org.cyclos.impl.utils.LinkType
import org.cyclos.model.utils.Location

def root = 'https://mydomain.com'

// This example only handles notification for a few locations
if (type != LinkType.NOTIFICATION) {
    return null
}

switch (location) {
    case Location.EXTERNAL_PAYMENT:
        return root + '/external-payment/' + entityId
    case Location.TRANSFER:
        return root + '/transfer/' + entityId
    default:
        return null
}

4.3.20. Export formats

These scripts are invoked when exporting data to a file in a custom format.

There are several contexts which can be exported:

  • Account history
  • Transfers overview
  • Transfer details
  • Transactions search (such as scheduled payments search);
  • Transactions overview (such as payment requests overview);
  • Transaction details (such as payment, scheduled payment or payment request);
  • Users search
  • User balances overview
  • Account limits overview
  • Records search (for system or specific user, of a given type)
  • Records overview (as administrator or broker, of a given type)
  • Shared fields records search
  • Tokens search (such as cards)
  • Vouchers search
  • Voucher details
  • Custom operation results (when returning a result page)

The following variables are bound on the script execution:

The script must return one of these types, containing the file content:

  • java.io.InputStream;
  • byte[];
  • java.io.Reader;
  • java.io.File;
  • If not any of the above, will be handled as string (calling toString()).

4.3.20.1. Examples

Exporting the account history as Swift MT940 format

This script allows exporting the account history entries in the MT940, which is used by some accounting software for importing / exporting transactions. To accomplish this, create a script of type Export format with the following code:

import java.text.SimpleDateFormat

import org.cyclos.entities.banking.Account
import org.cyclos.entities.users.User
import org.cyclos.impl.banking.AccountHistoryEntry
import org.cyclos.model.banking.accounts.AccountHistoryQuery
import org.cyclos.utils.StringHelper

def timeZone = sessionData.configuration.timeZone
def dateFormat = new SimpleDateFormat("yyMMdd")
dateFormat.timeZone = timeZone
def entryDateFormat = new SimpleDateFormat("yyMMdd")
entryDateFormat.timeZone = timeZone

def formatAmount(BigDecimal amount) {
    return amount.abs().toPlainString().replace('.', ',')
}

def formatSignal(BigDecimal amount) {
    return amount.compareTo(BigDecimal.ZERO) > 0 ? 'C' : 'D'
}

def formatOwner(Account account) {
    String text
    if (account.owner instanceof User) {
        text = account.owner.username
    } else {
        text = account.type.internalName ?: account.type.name
    }
    return formatText(text)
}

def formatDescription(AccountHistoryEntry entry) {
    def description = entry.transaction?.description ?: entry.type.valueForEmptyDescription
    return formatText(description)
}

def formatText(text) {
    // First replace line breaks or multiple spaces by a single space, trimming to 60 chars
    text = (text ?: '').replaceAll("[\n|\r]+", " ")
    text = text.replaceAll("\\s+", " ")
    text = StringHelper.trim(StringHelper.truncate(text, 60))
    // Second make sure that no special characters are used
    text = StringHelper.asciiOnly(StringHelper.unaccent(text))
    // Finally make sure that no colon character is used, this might mess up the mt940 file
    return text.replaceAll('\\:', ' ')
}

// Get the account
AccountHistoryQuery query = binding.query
Account account = conversionHandler.convert(Account, query.account)

// Get the begin date
Date begin = conversionHandler.toDate(query.period?.begin) ?: account.creationDate

// Get the end date
Date now = new Date()
Date end = conversionHandler.toDate(query.period?.end) ?: now
if (end.after(now)) {
    end = now
}

// Get the balance at begin / end
def balanceBegin = accountService.getBalance(account, begin)
def balanceEnd = accountService.getBalance(account, end)
def currency = scriptParameters.currencyCode

// Write the header
StringBuilder out = new StringBuilder(""":20:CN${dateFormat.format(end)}
:25:${scriptParameters.iban}
:28:000
:60F:${formatSignal(balanceBegin)}${dateFormat.format(begin)}${currency}${formatAmount(balanceBegin)}
""")

// Process each entry
scriptHelper.processBatch(data) { AccountHistoryEntry entry ->
    def date = entryDateFormat.format(entry.date)
    def amount = formatAmount(entry.amount)
    def signal = formatSignal(entry.amount)
    def fromTo = formatOwner(entry.relatedAccount)
    def description = formatDescription(entry)
    out << ":61:${date}${signal}${amount}NOV NONREF\n"
    out << ":86:${fromTo} > ${description}\n"
}

// Write the footer
out << ":62F:${formatSignal(balanceEnd)}${dateFormat.format(end)}${currency}${formatAmount(balanceEnd)}"

// Return the output content
return out

Also set the following in the script parameters box:

# The currency code that will be exported on the file
currencyCode = EUR
# The IBAN account number that will be exported in the file
iban = NL70TRIO0123456789

Then, create a new export format in System > System configuration > Export formats, with the following fields:

  • Name: MT940 (change as desired)
  • Internal name: mt940
  • Content type: application/octet-stream
  • Binary: No
  • Character encoding: UTF-8
  • File extension: mt940
  • Contexts: Account history
  • Script: Select the previously created script.

4.3.21. Notifications

These scripts are invoked before generating the notification to be stored in the database. Later, the notifications will be sent through a polling task.

The following variables are bound on the script execution:

The script can return null or an empty map if no customizations must be applied for the given input parameters, otherwise the returned map can contain any of the following:

  • title: (String) the title of the notification
  • body: (String) the body of the notification
  • sms: (String) the sms message to be send
  • fcm: (Map) the customizations for the push notifications (sent through Firebase Cloud Messaging) with any of the following:
    • title: (String) the title of the push notification. If not given then the title above will be used (if any)
    • body: (String) the body of the push notification. If not given then the body above will be used (if any)
    • imageUrl: (String) the url of the image associated to the push notification. If not given, the user's profile image is used (only for users, not operators)
    • iosBadge: (Boolean) flag indicating if a badge must be shown for iOS notifications. Default is true;
    • androidIconColor: the color in #rrggbb format (e.g: #23AB34) used to colorize the small notification icon shown in Android devices. By default no color is sent, it depends on the Android version;
    • data: (Map), any value in this map will be set to the message as a data field (key-value pair). The key or value may not be null.
      Those fields only have sense for the mobile application, i.e they are useful only if there is a compatible application that can process it (added here only for completeness).

4.3.21.1. Examples

Include the balance for a "Payment received" notification

This script will add the user balance to the body of the notification generated for the type: PAYMENT_RECEIVED
To accomplish this, create a script of type Notification with the following code:

import org.cyclos.model.messaging.notifications.AccountNotificationType
import org.cyclos.utils.StringHelper

def received = type == AccountNotificationType.PAYMENT_RECEIVED
def performed = type == AccountNotificationType.ALL_NON_SMS_PERFORMED_PAYMENTS
if (received || performed) {
    def account = received ? entity.to: entity.from
    def balance = formatter.format(entity.currency,
            accountService.getBalance(account, null))
    def amount = formatter.format(entity.currencyAmount)
    def owner = formatter.format(received ? entity.fromOwner: entity.toOwner)
    def ownerShort = StringHelper.truncate(owner, 30)
    if (received) {
        return [
            body: "You have received a payment of $amount from $owner."
            + " Your new balance is: $balance.",
            sms: "Payment of $amount received from $ownerShort."
            + " New balance: $balance."
        ]
    } else {
        return [
            body: "You have performed a payment of $amount to $owner."
            + " Your new balance is: $balance.",
            sms: "Payment of $amount performed to $ownerShort."
            + " New balance: $balance."
        ]
    }
}

// default values for the rest of the notification types
return null

Finally, you must set the script in the corresponding configuration (Notifications section).