4.4. Solutions using scripts

Examples of solutions that require a single script can be found directly in the specific script description page (links directly above). Solutions that need several scripts and configurations can be found in this section.

4.4.1. PayPal Integration

It is possible to integrate Cyclos with PayPal, allowing users to buy units with their PayPal account. This is done with a custom operation which allows users to confirm the payment in PayPal and then, once the payment is confirmed, a payment from a system account is performed to the corresponding user account, automating the process of buying units. However, keep in mind the rates charged by PayPal, which vary according to some conditions.

To do so, first you'll need a PayPal premium or business account (for testing – using PayPal sandbox – any account is enough). You'll need to go to the PayPal Developer page to create an application on >REST API apps>, and get the client id and secret.

Then several configurations are required in Cyclos. Scripts can only be created as global administrators switched to a network, so it is advised to use a global admin to perform the configuration. Carefully follow each of the following steps:

4.4.1.1. Check the root URL

Make sure that the configuration for users use a correct root url. In System > System configuration > Configurations, select the configuration set for users and make sure the Main URL field points to the correct external URL. It will be used to generate the links which will be sent to PayPal redirect users back to Cyclos after confirming / canceling the operation.

4.4.1.2. Enable transaction number in currency

This can be checked under System > Currencies select the currency used for this operation, mark the Enable transfer number option and fill in the required parameters.

4.4.1.3. Create a system record type to store the client id and secret

Under System > System configuration > Record types, create a new system record type, with the following characteristics:

  • Name: PayPal Authentication
  • Internal name: paypalAuth
  • Display style: Single form
  • Main menu: System

For this record type, create the following fields:

  • Client ID
    • Internal name: clientId
    • Data type: Single line text
    • Required: yes
  • Client Secret
    • Internal name: clientSecret
    • Data type: Single line text
    • Required: yes
  • Token
    • Internal name: token
    • Data type: Single line text
    • Required: no
  • Token expiration
    • Internal name: tokenExpiration
    • Data type: Date
    • Required: no

4.4.1.4. Create a user record type to store each payment information

Under System > System configuration > Record types, create a new user record type, with the following characteristics:

  • Name: PayPal payment
  • Internal name: paypalPayment
  • Display style: List
  • Main menu: Banking
  • User management section: Banking

For this record type, create the following fields:

  • Payment ID
    • Internal name: paymentId
    • Data type: Single line text
    • Required: no
  • Amount
    • Internal name: amount
    • Data type: Decimal
    • Required: no
  • Transaction
    • Internal name: transaction
    • Data type: Linked entity
    • Linked entity type: Transaction
    • Required: no

4.4.1.5. Create the library script

Under System > Tools > Scripts, create a new library script, with the following characteristics:

  • Name: PayPal
  • Type: Library
  • Included libraries: none
  • 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 the payment record type
    payment.recordType = paypalPayment
    payment.paymentId = paymentId
    payment.amount = amount
    payment.transaction = transaction
    
    # Settings for PayPal
    mode = sandbox
    currency = EUR
    paymentDescription = Buy Cyclos units
    
    # Settings for the Cyclos payment
    amountMultiplier = 1
    accountType = debitUnits
    paymentType = paypalCredits
    
    # Messages
    error.invalidRequest = Invalid request
    error.transactionNotFound = Transaction not found
    error.transactionAlreadyApproved = The transaction was already approved
    error.payment = There was an error while processing the payment. Please, try again.
    error.notApproved = The payment was not approved
    message.canceled = You have cancelled the operation.\nFeel free to start again if needed.
    message.done = You have successfully completed the payment. Thank you.
  • Script code:
    import static groovyx.net.http.ContentType.*
    import static groovyx.net.http.Method.*
    
    import java.util.concurrent.CountDownLatch
    
    import org.apache.commons.codec.binary.Base64
    import org.cyclos.entities.banking.PaymentTransferType
    import org.cyclos.entities.banking.SystemAccountType
    import org.cyclos.entities.users.RecordCustomField
    import org.cyclos.entities.users.SystemRecord
    import org.cyclos.entities.users.SystemRecordType
    import org.cyclos.entities.users.User
    import org.cyclos.entities.users.UserRecord
    import org.cyclos.entities.users.UserRecordType
    import org.cyclos.impl.banking.PaymentServiceLocal
    import org.cyclos.impl.messaging.AlertServiceLocal
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.impl.utils.persistence.EntityManagerHandler
    import org.cyclos.model.EntityNotFoundException
    import org.cyclos.model.banking.accounts.SystemAccountOwner
    import org.cyclos.model.banking.transactions.PaymentVO
    import org.cyclos.model.banking.transactions.PerformPaymentDTO
    import org.cyclos.model.banking.transfertypes.TransferTypeVO
    import org.cyclos.model.messaging.alerts.SystemAlertType
    import org.cyclos.model.users.records.RecordDataParams
    import org.cyclos.model.users.records.UserRecordDTO
    import org.cyclos.model.users.recordtypes.RecordTypeVO
    import org.cyclos.model.users.users.UserLocatorVO
    import org.cyclos.utils.ParameterStorage
    
    import groovy.transform.TypeChecked
    import groovy.transform.TypeCheckingMode
    import groovyx.net.http.HTTPBuilder
    import groovyx.net.http.Method
    
    /**
     * Class used to store / retrieve the authentication information for PayPal
     * A system record type is used, with the following fields: client id (string),
     * client secret (string), access token (string) and token expiration (date)
     */
    @TypeChecked
    class PayPalAuth {
        String recordTypeName
        String clientIdName
        String clientSecretName
        String tokenName
        String tokenExpirationName
    
        SystemRecordType recordType
        SystemRecord record
        Map<String, Object> wrapped
    
        public PayPalAuth(Map<String, Object> variables) {
            def params = variables.scriptParameters as Map<String, Object>
            recordTypeName = params.'auth.recordType' ?: 'paypalAuth'
            clientIdName = params.'auth.clientId' ?: 'clientId'
            clientSecretName = params.'auth.clientSecret' ?: 'clientSecret'
            tokenName = params.'auth.token' ?: 'token'
            tokenExpirationName = params.'auth.tokenExpiration' ?: 'tokenExpiration'
    
            // Read the record type and the parameters for field internal names
            recordType = (variables.entityManagerHandler as EntityManagerHandler)
                    .find(SystemRecordType, recordTypeName)
    
            // Should return the existing instance, of a single form type.
            // Otherwise it would be an error
            def dataParams =
                    new RecordDataParams(recordType: new RecordTypeVO(id: recordType.id))
            record = (variables.recordService as RecordServiceLocal)
                    .newEntity(dataParams) as SystemRecord
            if (!record.persistent) throw new IllegalStateException(
                "No instance of system record ${recordType.name} was found")
    
            wrapped = (variables.scriptHelper as ScriptHelper).wrap(record, recordType.fields)
        }
    
        public String getClientId() {
            wrapped[clientIdName]
        }
        public String getClientSecret() {
            wrapped[clientSecretName]
        }
        public String getToken() {
            wrapped[tokenName]
        }
        public Date getTokenExpiration() {
            wrapped[tokenExpirationName] as Date
        }
        public void setClientId(String clientId) {
            wrapped[clientIdName] = clientId
        }
        public void setClientSecret(String clientSecret) {
            wrapped[clientSecretName] = clientSecret
        }
        public void setToken(String token) {
            wrapped[tokenName] = token
        }
        public void setTokenExpiration(Date tokenExpiration) {
            wrapped[tokenExpirationName] = tokenExpiration
        }
    }
    
    /**
     * Class used to store / retrieve PayPal payments as user records in Cyclos
     */
    @TypeChecked
    class PayPalRecord {
        String recordTypeName
        String paymentIdName
        String amountName
        String transactionName
    
        UserRecordType recordType
        Map<String, RecordCustomField> fields
    
        private EntityManagerHandler entityManagerHandler
        private RecordServiceLocal recordService
        private ScriptHelper scriptHelper
    
        public PayPalRecord(Map<String, Object> variables) {
    
            def params = variables.scriptParameters as Map<String, Object>
            recordTypeName = params.'payment.recordType' ?: 'paypalPayment'
            paymentIdName = params.'payment.paymentId' ?: 'paymentId'
            amountName = params.'payment.amount' ?: 'amount'
            transactionName = params.'payment.transaction' ?: 'transaction'
    
            entityManagerHandler = variables.entityManagerHandler as EntityManagerHandler
            recordService = variables.recordService as RecordServiceLocal
            scriptHelper = variables.scriptHelper as ScriptHelper
            recordType = entityManagerHandler.find(UserRecordType, recordTypeName)
            fields = [:]
            recordType.fields.each {f -> fields[f.internalName] = f}
        }
    
        /**
         * Creates a payment record, for the given user and JSON,
         * as returned from PayPal's create payment REST method
         */
        public UserRecord create(User user, Number amount) {
            RecordDataParams newParams = new RecordDataParams(
                    [user: new UserLocatorVO(id: user.id),
                        recordType: new RecordTypeVO(id: recordType.id)])
            def dto = recordService.getDataForNew(newParams).getDto() as UserRecordDTO
            def wrapped = scriptHelper.wrap(dto, recordType.fields)
            wrapped[amountName] = amount
    
            // Save the record DTO and return the entity
            Long id = recordService.save(dto)
            return entityManagerHandler.find(UserRecord, id)
        }
    
        /**
         * Finds the record by id
         */
        public UserRecord find(Long id) {
            try {
                UserRecord userRecord = entityManagerHandler.find(UserRecord, id)
                if (userRecord.type != recordType) {
                    return null
                }
                return userRecord
            } catch (EntityNotFoundException e) {
                return null
            }
        }
    
        /**
         * Removes the given record, but only if it is of the
         * expected type and hasn't been confirmed
         */
        public void remove(UserRecord userRecord) {
            if (userRecord.type != recordType) {
                return
            }
            Map<String, Object> wrapped = scriptHelper
                    .wrap(userRecord, recordType.fields)
            if (wrapped[transactionName] != null) return
                entityManagerHandler.remove(userRecord)
        }
    }
    
    /**
     * Class used to interact with PayPal services
     */
    @TypeChecked
    class PayPalService {
        String mode
        String baseUrl
        String currency
        String paymentDescription
    
        String accountTypeName
        String paymentTypeName
        double multiplier
    
        SystemAccountType accountType
        PaymentTransferType paymentType
        PayPalAuth auth
        PayPalRecord record
    
        private ScriptHelper scriptHelper
        private PaymentServiceLocal paymentService
        private AlertServiceLocal alertService
        private ParameterStorage storage
        private Map<String, Object> params
    
        PayPalService(Map<String, Object> variables) {
            this.auth = new PayPalAuth(variables)
            this.record = new PayPalRecord(variables)
            scriptHelper = variables.scriptHelper as ScriptHelper
            paymentService = variables.paymentService as PaymentServiceLocal
            alertService = variables.alertService as AlertServiceLocal
            storage = variables.parameterStorage as ParameterStorage
            params = variables.scriptParameters as Map<String, Object>
    
            mode = params.mode ?: 'sandbox'
            if (mode != 'sandbox' && mode != 'live') {
                throw new IllegalArgumentException("Invalid PayPal parameter " +
                "'mode': ${mode}. Should be either sandbox or live")
            }
            baseUrl = mode == 'sandbox'
                    ? 'https://api.sandbox.paypal.com' : 'https://api.paypal.com'
    
            currency = params.currency
            if (currency == null || currency.empty) {
                throw new IllegalArgumentException("Missing PayPal parameter 'currency'")
            }
    
            def emh = variables.entityManagerHandler as EntityManagerHandler
            accountTypeName = params.accountType
            if (accountTypeName == null || accountTypeName.empty)
                throw new IllegalArgumentException("Missing PayPal parameter 'accountType'")
            paymentTypeName = params.paymentType
            if (paymentTypeName == null || paymentTypeName.empty)
                throw new IllegalArgumentException("Missing PayPal parameter 'paymentType'")
            accountType = emh.find(SystemAccountType, accountTypeName)
            if (!accountType.currency.transactionNumber?.used) {
                throw new IllegalStateException("Currency " + accountType.currency
                + " doesn't have transaction number enabled")
            }
            paymentType = emh.find(PaymentTransferType, paymentTypeName, accountType)
            multiplier = Double.parseDouble((params.amountMultiplier as String) ?: "1")
            paymentDescription = params.paymentDescription ?: ""
        }
    
        /**
         * Creates an order in PayPal and the corresponding user record
         */
        Map<String, Object> createOrder(User user, Number amount, String callbackUrl) {
            // Create the UserRecord for this payment
            UserRecord userRecord = record.create(user, amount)
            //store the record's id to retrieve it after the payment was confirmed in PayPal
            storage['recordId'] = userRecord.id
    
            // Create the payment in PayPal
            def json = createOrder(amount, callbackUrl)
            //store the PayPal order id to retrieve it after the payment was confirmed in PayPal
            storage['orderId'] = json.id
    
            return json
        }
    
        /**
         * Creates an order in PayPal with a given amount, without updating any record
         */
        Map<String, Object> createOrder(Number amount, String callbackUrl) {
            callbackUrl += callbackUrl.contains("?") ? "&" : "?"
            String returnUrl = "${callbackUrl}success=true"
            String cancelUrl = "${callbackUrl}cancel=true"
    
            def jsonBody = [
                intent: "CAPTURE",
                application_context: [
                    return_url: returnUrl,
                    cancel_url: cancelUrl,
                    user_action: "PAY_NOW"
                ],
                purchase_units: [
                    [
                        description: paymentDescription,
                        amount: [
                            value: amount,
                            currency_code: currency
                        ]
                    ]
                ]
            ]
            // Create the payment in PayPal
            return performRequest("${baseUrl}/v2/checkout/orders", jsonBody, POST)
        }
    
        /**
         * Capture the order (execute the payment in PayPal)
         */
        Map<String, Object> captureOrder(String orderId) {
            return performRequest("${baseUrl}/v2/checkout/orders/${orderId}/capture", null, POST)
        }
    
        /**
         * Get the order information from PayPal
         */
        Map<String, Object> getOrderFromPayPal(String orderId) {
            return performRequest("${baseUrl}/v2/checkout/orders/${orderId}", null, GET)
        }
    
        /**
         * Executes a PayPal payment, and creates the payment in Cyclos
         */
        Map<String, Object> execute(UserRecord userRecord) {
            def wrapped = scriptHelper.wrap(userRecord)
            def orderId = storage['orderId'] as String
            // Execute the payment in PayPal
            def capturedOrder = captureOrder(orderId) as Map<String, Object>
            // Update the payment id
            wrapped[record.paymentIdName] = getPaymentIdFromCapturedOrder(capturedOrder)
            def vo
            try {
                // Try to perform the payment in Cyclos, if it fails, refund the payment in PayPal
                vo = perform(capturedOrder, userRecord.user)
            } catch (Exception ex) {
                refundCapturedOrder(capturedOrder, userRecord, userRecord.user)
                throw ex
            }
            if (vo != null) {
                // Update the record, setting the linked transaction
                wrapped[record.transactionName] = vo
                userRecord.lastModifiedDate = new Date()
    
            }
            return capturedOrder
        }
        /**
         * Refund the completed order using the refund link returned when the
         * order was captured and remove the user record if given
         */
        void refundCapturedOrder(Map<String, Object> capturedOrder, UserRecord userRecord, User user) {
            def refundLink = getRefundLinkFromCapturedOrder(capturedOrder)
            if (refundLink) {
                def refundedOrder
                try {
                    // Make the refund
                    refundedOrder = performRequest(refundLink, null, POST)
                } catch (Exception ex) {
                    //Do nothing because an alert is going to be created
                }
    
                if (!refundedOrder || refundedOrder.status != "COMPLETED") {
                    createRefundFailAlert(capturedOrder, user)
                } else if (userRecord) {
                    record.remove(userRecord)
                }
            }
        }
        /**
         * Create the system alert for a failed refund
         */
        private void createRefundFailAlert(Map<String, Object> capturedOrder, User user) {
            def errorMessage = """User: ${user.username}. The PayPal payment
                (${getPaymentIdFromCapturedOrder(capturedOrder)}) was completed,
                but there was an error in Cyclos and the attempt to refund in PayPal failed."""
            alertService.create(SystemAlertType.CUSTOM, errorMessage)
        }
    
        /**
         * Performs the payment in Cyclos
         */
        PaymentVO perform(Map<String, Object> capturedOrder, User subject) {
            if (getPaymentStatusFromCapturedOrder(capturedOrder) == 'COMPLETED') {
                def amount = new BigDecimal(getAmountFromCapturedOrder(capturedOrder) as String)
                BigDecimal finalAmount = amount * multiplier
                // Perform the payment in Cyclos
                PerformPaymentDTO dto = new PerformPaymentDTO()
                dto.owner = SystemAccountOwner.instance()
                dto.subject = subject
                dto.amount = finalAmount
                dto.type = new TransferTypeVO(paymentType.id)
                return paymentService.perform(dto)
            } else {
                return null
            }
        }
    
        /**
         * Get the payment id of a captured order result
         */
        @TypeChecked(TypeCheckingMode.SKIP)
        String getPaymentIdFromCapturedOrder(capturedOrder) {
            return capturedOrder.purchase_units[0].payments.captures[0].id
        }
    
        /**
         * Get the payment status of a captured order result.
         * We must get the status from the captured payment
         * because it will be NOT completed even when the order status is completed
         */
        @TypeChecked(TypeCheckingMode.SKIP)
        String getPaymentStatusFromCapturedOrder(capturedOrder) {
            return capturedOrder.purchase_units[0].payments.captures[0].status
        }
    
        /**
         * Get the amount of a captured order result
         */
        @TypeChecked(TypeCheckingMode.SKIP)
        String getAmountFromCapturedOrder(capturedOrder) {
            return capturedOrder.purchase_units[0].payments.captures[0].amount.value
        }
    
        /**
         * Get the refund link of a captured order result
         */
        @TypeChecked(TypeCheckingMode.SKIP)
        String getRefundLinkFromCapturedOrder(capturedOrder) {
            return capturedOrder.purchase_units[0].payments.captures[0].links.find {it.rel == "refund"}?.href
        }
    
        /**
         * Performs a synchronous request, posting and accepting JSON
         */
        @TypeChecked(TypeCheckingMode.SKIP)
        Map<String, Object> performRequest(url, jsonBody, Method method) {
            def http = new HTTPBuilder(url)
            CountDownLatch latch = new CountDownLatch(1)
            Map<String, Object> responseJson = null
            def responseError = []
    
            // Check if we need a new token
            if (auth.token == null || auth.tokenExpiration < new Date()) {
                refreshToken()
            }
    
            // Perform the request
            http.request(method, JSON) {
                headers.'Authorization' = "Bearer ${auth.token}"
                headers.'Content-Type' = "application/json"
    
                body = jsonBody
    
                response.success = { resp, json ->
                    responseJson = json as Map<String, Object>
                    latch.countDown()
                }
    
                response.failure = { resp ->
                    responseError << resp.statusLine.statusCode
                    responseError << resp.statusLine.reasonPhrase
                    latch.countDown()
                }
            }
    
            latch.await()
            if (!responseError.empty) {
                throw new RuntimeException("Error making PayPal request to ${url}"
                + ", got error code ${responseError[0]}: ${responseError[1]}")
            }
            return responseJson
        }
    
        /**
         * Refreshes the access token
         */
        @TypeChecked(TypeCheckingMode.SKIP)
        private void refreshToken() {
            def http = new HTTPBuilder("${baseUrl}/v1/oauth2/token")
    
            CountDownLatch latch = new CountDownLatch(1)
            Map<String, Object> responseJson = null
            def responseError = []
    
            http.request(POST, JSON) {
                String auth = Base64.encodeBase64String((auth.clientId + ":"
                        + auth.clientSecret).getBytes("UTF-8"))
                headers.'Accept-Language' = 'en_US'
                headers.'Authorization' = "Basic ${auth}"
    
                send URLENC, [
                    grant_type: "client_credentials"
                ]
    
                response.success = { resp, json ->
                    responseJson = json as Map<String, Object>
                    latch.countDown()
                }
    
                response.failure = { resp ->
                    responseError << resp.statusLine.statusCode
                    responseError << resp.statusLine.reasonPhrase
                    latch.countDown()
                }
            }
    
            latch.await()
            if (!responseError.empty) {
                throw new RuntimeException("Error getting PayPal token, " +
                "got error code ${responseError[0]}: ${responseError[1]}")
            }
    
            // Update the authentication data
            auth.token = responseJson.access_token
            auth.tokenExpiration = new Date(System.currentTimeMillis() +
                    (((responseJson.expires_in as Integer) - 30) * 1000))
        }
    
    }

4.4.1.6. Create the custom operation script

Under System > Tools > Scripts, create a new custom operation script, with the following characteristics:

  • Name: Buy units with PayPal
  • Type: Custom operation
  • Run with all permissions: yes
  • Included libraries: PayPal
  • Parameters: leave empty
  • Script code executed when the custom operation is executed:
    import org.cyclos.entities.users.User
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def createPayment(){
        def variables = binding.variables
        def formParameters = variables.formParameters as Map<String, Object>
        def service = new PayPalService(variables)
    
        def user = variables.user as User
        def amount = formParameters.amount as Number
        def returnUrl = variables.returnUrl as String
        def result = service.createOrder(user, amount, returnUrl)
    
        def links = result.links as Map<String, Object>[]
        def link = links.find {it.rel == "approve"}
        if (link) {
            return link.href
        } else {
            throw new IllegalStateException("No approval url returned from PayPal")
        }
    }
    
    createPayment()
  • Script code executed when the external site redirects the user back to Cyclos:
    import org.cyclos.entities.users.UserRecord
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.model.utils.RequestInfo
    import org.cyclos.server.utils.ObjectParameterStorage
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def payPalCallback() {
        def variables = binding.variables as Map<String, Object>
        def scriptParameters = variables.scriptParameters as Map<String, Object>
        def service = new PayPalService(variables)
        def storage = variables.storage as ObjectParameterStorage
        def recordId = storage['recordId'] as Long
        def request = variables.request as RequestInfo
        def record = service.record
    
        // No record?
        if (recordId == null) {
            return "[ERROR] " +
                    (scriptParameters.'error.invalidRequest' ?: "Invalid request")
        }
    
        // Find the corresponding record
        UserRecord userRecord = record.find(recordId)
        if (userRecord == null) {
            return "[ERROR] " +
                    (scriptParameters.'error.transactionNotFound'
                    ?: "Transaction not found")
        }
        def wrapped = (variables.scriptHelper as ScriptHelper).wrap(userRecord)
    
        if (request.getParameter("cancel")) {
            // The operation has been canceled.
            // Remove the record and send a message.
            record.remove(userRecord)
            return "[WARN]" + scriptParameters.'message.canceled'
                    ?: "You have cancelled the operation.\nFeel free to start again if needed."
        } else {
            // Execute the payment
            try {
                def order = service.execute(userRecord)
                if (service.getPaymentStatusFromCapturedOrder(order) == 'COMPLETED') {
                    return scriptParameters.'message.done'
                            ?: "You have successfully completed the payment. Thank you."
                } else {
                    return "[ERROR] " + scriptParameters.'error.notApproved'
                            ?: "The payment was not approved"
                }
            } catch (Exception e) {
                return "[ERROR] " + scriptParameters.'error.payment'
                        ?: "There was an error while processing the payment. Please, try again."
            }
        }
    }
    
    payPalCallback()
    

4.4.1.7. Create the custom operation

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: Buy units with PayPal (can be changed – will be the label displayed on the menu)
  • Enabled: yes
  • Scope: user
  • Script: Buy units with PayPal
  • Script parameters: leave empty
  • Result type: External redirect
  • Has file upload: no
  • Main menu: Banking
  • User management section: Banking
  • Information text: you can add here some text explaining the process – it will be displayed in the operation page
  • Confirmation text: leave empty (can be used to show a dialog asking the user to confirm before submitting, but in this case is not needed)

For this custom operation create the following form field:

  • Name: Amount
  • Internal name: amount
  • Data type: Decimal
  • Required: yes

4.4.1.8. Configure the system account from which payments will be performed to users

Under System > Accounts configuration > Account types, choose the (normally unlimited) account from which payments will be performed to users. Then set its internal name to some meaningful name. The example configuration uses debitUnits as internal name, but it can be changed. Save the form.

4.4.1.9. Configure the payment type which will be used on payments

Still in the details page for the account type, on the Transfer types tab, create a new Payment transfer type with the following characteristics:

  • Name: Units bought with PayPal (can be changed as desired)
  • Internal name: paypalCredits (can be changed as desired, but this name is used in the example configuration)
  • To: select the user account which will receive the payment
  • Enabled: yes

4.4.1.10. Grant the administrator permissions

Under System > User configuration > Groups, select the Network administrators group. Then, in the Permissions tab:

  • In System > System records, set the permissions view, create and edit for the Paypal authentication record
  • In User data > User records, make the Paypal payment visible only (make sure the create, edit and remove are unchecked, as this record is not meant to be manually edited)
  • Save the permissions

4.4.1.11. Setup the PayPal credentials

Click System > System records > Paypal authentication. If this menu entry is not showing up, refresh the browser page (by pressing F5) and try again. Update the Client ID and Client Secret fields exactly with the ones you got in the application you registered in the PayPal Developer page. Remember that PayPal has a sandbox, which can be used to test the application, and a live environment. For now, use the sandbox credentials. The other 2 fields can be left blank. Save the record.

Once the record is properly set, if you want to remove it from the menu, you can just remove the permission to view this system record in the adminitrator group page.

4.4.1.12. Grant the user permissions / enable the operation

In System > User configuration > Products (permissions), select the member product for users which will run the operation.

  • In the Custom operations field, make the Buy units with PayPal both enabled and allowed to run.
  • In Records, enable the PayPal payment record. It can be made visible to the users themselves. If not, only admins will be able to see the records.
  • Save the product. From this moment, the operation will show up for users in the banking menu.

4.4.1.13. Configuring the script parameters

In the PayPal library script, in parameters, there are several configurations which can be done. All those settings can be overridden in the custom operation's script parameters, allowing using distinct configurations for distinct operations. For example, it is possible to have distinct operations to perform payments in distinct currencies. In that case, the script parameters for each operation would define the currency again.

Here are some elements which can be configured:

  • Internal names for the records used to store the credentials and payments.
  • Paypal mode: the 'mode' settings can be either sandbox or live, indicating that operations are performed either in a test or in the real environment. To go live, you'll need a premium or business account in PayPal, and you need to use the live credentials (client ID and client secret) in Cyclos.
  • Payment currency: the 'currency' defines the 3-letter, ISO 4217 code for the currency in PayPal. Sometimes, according to country-specific laws, the currency used for payments may be limited. For example, Brazilians can only pay other Brazilians in Reais. Make sure the PayPal destination account can receive payments for the specified currency, otherwise payments or refunds will fail.
  • Description for payments in PayPal: using the 'paymentDescription' setting.
  • Amount multiplier: Sometimes it may be desired that the payment performed in Cyclos isn't of the exact amount of the payment in PayPal. This can normally be resolved using transfer fees, but it could also be handy to use this multiplier. If left in 1, the payment in Cyclos will have the same amount as the one in PayPal. If greater / less than 1, the payment in Cyclos will be greater / less than the one in PayPal. For example, if the multiplier is 1.05, and the PayPal payment was 100 USD, the payment in Cyclos will have the amount 105. Or, if the multiplier is 0.95 and the PayPal payment was 200 EUR, the payment in Cyclos will be of 190.
  • System account from which the payment will be performed to users: the 'accountType' setting is the internal name of the system account type from which payments will be performed, as explained previously. Make sure it is exactly the same as set in the account type.
  • Payment type: the 'paymentType' setting is the internal name of the payment transfer type used. Make sure it is exactly the same internal name set in the payment type that was created in previous steps.
  • Messages: several messages (displayed to the user) can be set / translated here.

4.4.1.14. Other considerations

Make sure the payment type is from an unlimited account, so payments in Cyclos won't fail because of funds. The way the example script is done, first the payment is executed in PayPal and, if authorized, a payment is made in Cyclos. If this payment fails, to avoid inconsistency between the Cyclos account and the PayPal payment, a refund payment is performed in PayPal. If that refund fails, it is created a Custom system alert so it is advisable to have admins receving that type of alert.

4.4.2. Loan module

Loan features in Cyclos 4 can be implemented using scripting. As loans tend to be very specific for each project, having it implemented with scripts brings the possibility to tailor the behavior to each project.

The example provided works as follows:

  • An administrator has a custom operation to grant the loan, setting the amount, number of installments and first installment date.
  • The loan is a payment from a system account to a user. It has a status, which can be either open or closed.
  • The same custom operation also performs a scheduled payment from the user to system, with each installment amount and due date corresponding to the loan installments. This scheduled payment has (with a custom field) a link to the original loan. Also, the loan payment has a link to the scheduled payment, making it easy to navigate between them. However, if the loan is pending authorization, the scheduled payment won't be created.
  • Each installment will be processed at the respective due date, allowing users to repay the loan with internal units. The administrator can, however, mark individual installments as settled, which means the installment won't be repaid internally, but with some other way (for example, with money or using other Cyclos payments).
  • Once the scheduled payment is closed, an extension point updates the status of the original payment to closed.
  • If the original loan was submitted to authorization, an extension point is triggered when it is authorized, and then creates the scheduled payment. IMPORTANT: If the administrator performing a payment also has the permission to authorize it, the payment will be immediately processed. So, be careful when testing with a single administrator group when authorization is desired, as in that case the loan would never get to authorization.

In order to configure the loan script, follow carefully each of the following steps:

4.4.2.1. Enable transaction number in currency

This can be checked under System > Currencies select the currency used for this operation, mark the Enable transfer number option and fill in the required parameters.

4.4.2.2. Create the transfer status flow

Under System > Accounts configuration > Transfer status flows, create a new one, with the following characteristics:

  • Name: Loan status (can be changed as desired)
  • Internal name: loan (this name is used in the example configuration)

After saving, create the following statuses:

  • Closed (can be changed as desired)
    • Internal name: closed
    • Possible next statuses: <None>
  • Open (can be changed as desired)
    • Internal name: open
    • Possible next statuses: Closed

4.4.2.3. Create the payment custom fields

Under System > Accounts configuration > Payment fields, create a new one, with the following fields:

  • Installments count
    • Name: Installments count (can be changed as desired)
    • Internal name: numberOfInstallments (can be changed as desired, but this name is used in the example configuration)
    • Data type: integer
    • Required: yes
  • First due date
    • Name: First due date (can be changed as desired)
    • Internal name: firstDueDate (can be changed as desired, but this name is used in the example configuration)
    • Data type: date
    • Required: yes
  • Loan
    • Name: Loan (can be changed as desired)
    • Internal name: loan (can be changed as desired, but this name is used in the example configuration)
    • Data type: Linked entity
    • Linked entity type: Transaction
    • Required: no
  • Repayment
    • Name: Repayment (can be changed as desired)
    • Internal name: repayment (can be changed as desired, but this name is used in the example configuration)
    • Data type: Linked entity
    • Linked entity type: Transaction
    • Required: no

4.4.2.4. Configure the system account from which payments will be performed to users

Under System > Accounts configuration > Account types, choose the (normally unlimited) account from which payments will be performed to users. Then set its internal name to some meaningful name. The example configuration uses debitUnits as internal name, but it can be changed later. Save the form.

4.4.2.5. Create the payment type which will be used to grant the loan

Still in the system account type details page for the account type, on the Transfer types tab, create a new Payment transfer type with the following characteristics:

  • Name: Loan (can be changed as desired)
  • Internal name: loanGrant (can be changed as desired, but this name is used in the example configuration)
  • Default description: Loan grant (can be changed as desired, is the description for payments, visible in the account history)
  • To: select the user account which will receive the payment
  • Transfer status flows: Loan status
  • Initial status for Loan status: Open
  • Enabled: yes

After saving, on the "Payment fields" tab, add the following custom fields:

  • Installments count
  • First due date
  • Repayment

If the loan can go through authorization, then create an authorization role in System > Account configuration > Authorization roles. Then, in the payment type details check the "Requires authorization" field. After saving, in the "Authorization levels" tab, add a new authorization level with that role. Afterwards, grant some administrator group the permission to manage that authorization role.

4.4.2.6. Configure the user account which will receive loans

Under System > Accounts configuration > Account types, choose the user account which will receive payments. Then set its internal name to some meaningful name. The example configuration uses userUnits as internal name, but it can be changed later. Save the form.

4.4.2.7. Create the payment type which will be used to repay the loan

Still in the user account type details page, on the Transfer types tab, create a new Payment transfer type with the following characteristics:

  • Name: Loan repayment (can be changed as desired)
  • Internal name: loanRepayment (can be changed as desired, but this name is used in the example configuration)
  • Default description: Loan repayment (can be changed as desired, is the description for payments, visible in the account history)
  • To: select the system account which granted the loan
  • Enabled: yes
  • Allows scheduled payment: yes
  • Max installments on scheduled payments: 36 (any value greater than zero is fine)
  • Show scheduled payments to receiver: yes
  • Reserve total amount on scheduled payments: no

After saving, on the Payment fields tab, add the custom field named "Loan".

4.4.2.8. Create the library script

Under System > Tools > Scripts, create a new library script, with the following characteristics:

  • Name: Loan
  • Type: Library
  • Included libraries: none
  • Parameters:
    # Loan configuration
    loan.account = debitUnits
    loan.type = loanGrant
    #loan.description = 
    
    # Repayment configuration
    repayment.account = userUnits
    repayment.type = loanRepayment
    #repayment.description = 
    
    # Payment custom fields
    field.loan = loan
    field.repayment = repayment
    
    # Monthly compound interest rate (zero for none)
    monthlyInterestRate = 0
    
    # Transfer status configuration
    status.flow = loan
    status.open = open
    status.closed = closed
    
    # Custom operation configuration
    operation.amount = amount
    operation.installments = numberOfInstallments
    operation.firstDueDate = firstDueDate
    
    # Messages
    message.invalidInstallments = The number of installments is invalid
    message.invalidLoanAmount = Invalid loan amount
    message.invalidFirstDueDate = The first due date cannot be lower than tomorrow
    message.loanGranted = The loan was successfully granted
    message.loanGranted.pending = The loan was granted and is now pending authorization
    message.authorization.expired = The loan cannot be authorized as the first due date is over
  • Script code:
    import org.cyclos.entities.banking.Payment
    import org.cyclos.entities.banking.PaymentTransferType
    import org.cyclos.entities.banking.ScheduledPayment
    import org.cyclos.entities.banking.SystemAccountType
    import org.cyclos.entities.banking.TransactionCustomField
    import org.cyclos.entities.banking.Transfer
    import org.cyclos.entities.banking.TransferStatus
    import org.cyclos.entities.banking.TransferStatusFlow
    import org.cyclos.entities.banking.UserAccountType
    import org.cyclos.entities.users.User
    import org.cyclos.impl.access.SessionData
    import org.cyclos.impl.banking.PaymentServiceLocal
    import org.cyclos.impl.banking.ScheduledPaymentServiceLocal
    import org.cyclos.impl.banking.TransferStatusServiceLocal
    import org.cyclos.impl.system.ConfigurationAccessor
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.impl.utils.persistence.EntityManagerHandler
    import org.cyclos.model.ValidationException
    import org.cyclos.model.banking.accounts.SystemAccountOwner
    import org.cyclos.model.banking.transactions.InstallmentDTO
    import org.cyclos.model.banking.transactions.PaymentVO
    import org.cyclos.model.banking.transactions.PerformPaymentDTO
    import org.cyclos.model.banking.transactions.PerformScheduledPaymentDTO
    import org.cyclos.model.banking.transactions.ScheduledPaymentVO
    import org.cyclos.model.banking.transfers.TransferVO
    import org.cyclos.model.banking.transferstatus.ChangeTransferStatusDTO
    import org.cyclos.model.banking.transferstatus.TransferStatusVO
    import org.cyclos.model.banking.transfertypes.TransferTypeVO
    import org.cyclos.model.utils.TimeField
    import org.cyclos.server.utils.DateHelper
    import org.cyclos.utils.BigDecimalHelper
    
    import groovy.transform.TypeChecked
    
    
    
    @TypeChecked
    class Loan {
        Map<String, Object> config
        EntityManagerHandler emh
        PaymentServiceLocal paymentService
        ScheduledPaymentServiceLocal scheduledPaymentService
        TransferStatusServiceLocal transferStatusService
        ScriptHelper scriptHelper
        ConfigurationAccessor configuration
    
        double monthlyInterestRate
        SystemAccountType systemAccount
        UserAccountType userAccount
        PaymentTransferType loanType
        PaymentTransferType repaymentType
        TransactionCustomField loanField
        TransactionCustomField repaymentField
        TransferStatusFlow flow
        TransferStatus open
        TransferStatus closed
    
        Loan(Binding binding) {
            def variables = binding.variables
            config = [:]
            def params = variables.scriptParameters as Map<String, Object>
            [
                'loan.account': 'systemAccount',
                'loan.type': 'loanGrant',
                'loan.description': null,
                'repayment.account': 'userUnits',
                'repayment.type': 'loanRepayment',
                'repayment.description': null,
                'field.loan': 'loan',
                'field.repayment': 'repayment',
                'monthlyInterestRate' : null,
                'status.flow': 'loan',
                'status.open': 'open',
                'status.closed': 'closed',
                'operation.amount': 'amount',
                'operation.installments': 'installments',
                'operation.firstDueDate': 'firstDueDate',
                'message.invalidInstallments':
                'The number of installments is invalid',
                'message.invalidLoanAmount': 'Invalid loan amount',
                'message.invalidFirstDueDate':
                'The first due date cannot be lower than tomorrow',
                'message.loanGranted':
                'The loan was successfully granted to the user',
                'message.loanGranted.pending':
                'The loan was granted and is now pending authorization',
                'message.authorization.expired':
                'The loan cannot be authorized as the first due date is over'
            ].each { k, v ->
                config[k] = params[k] ?: v
            }
            emh = variables.entityManagerHandler as EntityManagerHandler
            paymentService = variables.paymentService as PaymentServiceLocal
            scriptHelper = variables.scriptHelper as ScriptHelper
            scheduledPaymentService =
                    variables.scheduledPaymentService as ScheduledPaymentServiceLocal
            transferStatusService =
                    variables.transferStatusService as TransferStatusServiceLocal
            configuration =
                    (variables.sessionData as SessionData).configuration as ConfigurationAccessor
    
            systemAccount = emh.find(SystemAccountType, config.'loan.account' as String)
            if (systemAccount.currency.transactionNumber == null
            || !systemAccount.currency.transactionNumber.used) {
                throw new IllegalStateException("The currency ${systemAccount.currency.name}"
                + " doesn't have transaction number enabled")
            }
            userAccount = emh.find(UserAccountType, config.'repayment.account' as String)
            loanType =
                    emh.find(PaymentTransferType, config.'loan.type' as String, systemAccount)
            repaymentType =
                    emh.find(PaymentTransferType, config.'repayment.type' as String, userAccount)
            if (!repaymentType.allowsScheduledPayments) {
                throw new IllegalStateException(
                "The repayment type ${repaymentType.name} doesn't allows scheduled payment")
            }
    
            loanField = emh.find(TransactionCustomField, config.'field.loan' as String)
            repaymentField =
                    emh.find(TransactionCustomField, config.'field.repayment' as String)
            if (!loanType.customFields.contains(repaymentField)) {
                throw new IllegalStateException("The loan type ${loanType.name}"
                + " doesn't contain the custom field ${repaymentField.name}")
            }
            if (!repaymentType.customFields.contains(loanField)) {
                throw new IllegalStateException("The repayment type ${repaymentType.name}"
                + " doesn't contain the custom field ${loanField.name}")
            }
            flow = emh.find(TransferStatusFlow, config.'status.flow' as String)
            open = emh.find(TransferStatus,config.'status.open' as String, flow)
            closed = emh.find(TransferStatus, config.'status.closed' as String, flow)
            monthlyInterestRate = (config.'monthlyInterestRate' as String)?.toDouble() ?: 0
        }
    
        BigDecimal calculateInstallmentAmount(BigDecimal amount, int installments,
                Date grantDate, Date firstInstallmentDate) {
    
            // Calculate the delay
            Date shouldBeFirstExpiration = DateHelper.add(grantDate, TimeField.DAYS, 30)
            int delay = (int) DateHelper.daysBetween(firstInstallmentDate, shouldBeFirstExpiration)
            if (delay < 0) {
                delay = 0
            }
            double interest = monthlyInterestRate / 100.0
            double numerator = ((1 + interest) ** (installments + delay / 30.0)) * interest
            double denominator = ((1 + interest) ** installments) - 1
            BigDecimal result = amount * numerator / denominator
            return BigDecimalHelper.round(result, systemAccount.currency.precision)
        }
    
        void close(ScheduledPayment scheduledPayment) {
            def map = scriptHelper.wrap(scheduledPayment)
            Payment loan = map.get(loanField.internalName) as Payment
            Transfer loanTransfer = loan.transfer
            TransferStatus status = loanTransfer.getStatus(flow)
            if (status != closed) {
                // The loan was not closed: close it
                transferStatusService.changeStatus(new ChangeTransferStatusDTO([
                    transfer: new TransferVO(loanTransfer.id),
                    newStatus: new TransferStatusVO(closed.id)
                ]))
            }
        }
    
        Payment grant(User user, Map<String, Object> formParameters) {
            BigDecimal loanAmount = formParameters[config.'operation.amount'] as BigDecimal
            int installments = formParameters[config.'operation.installments'] as int
            Date firstDueDate = formParameters[config.'operation.firstDueDate'] as Date
            Date minDate = DateHelper.shiftToNextDay(new Date(), configuration.timeZone)
            if (installments < 1 || installments > repaymentType.maxInstallments)
                throw new ValidationException(config.'message.invalidInstallments' as String)
            if (loanAmount < 1)
                throw new ValidationException(config.'message.invalidLoanAmount' as String)
            if (firstDueDate < minDate)
                throw new ValidationException(config.'message.invalidFirstDueDate' as String)
    
            // Grant the loan, copying the installments count and first due date
            PerformPaymentDTO perform = new PerformPaymentDTO([
                from: SystemAccountOwner.instance(),
                to: user,
                type: new TransferTypeVO(loanType.id),
                amount: loanAmount,
                description: config.'loan.description' as String
            ])
            def performBean = scriptHelper.wrap(perform)
            performBean[config.'operation.installments' as String] = installments
            performBean[config.'operation.firstDueDate' as String] = firstDueDate
            PaymentVO loanVO = paymentService.perform(perform)
            Payment loan = emh.find(Payment, loanVO.id)
            if (loan.transfer != null) {
                // The loan is processed. Create the repayment
                createRepayment(loan)
            }
            return loan
        }
    
        ScheduledPayment createRepayment(Payment payment) {
            Transfer loanTransfer = payment.transfer
            if (loanTransfer == null) {
                return null
            }
            TransferStatus currentStatus = loanTransfer.getStatus(flow)
            if (currentStatus != open) {
                throw new ValidationException(
                "The initial status for flow ${flow.name} in ${loanType.name} "
                + "is not the expected one: ${open.name}, but ${currentStatus?.name} instead")
            }
    
            // Read the scheduling information from the loan
            def loanBean = scriptHelper.wrap(payment)
            def existingRepayment = loanBean[repaymentField.internalName]
            if (existingRepayment != null) {
                return existingRepayment as ScheduledPayment
            }
            BigDecimal loanAmount = payment.amount
            Integer installments = loanBean[config.'operation.installments'] as Integer
            Date firstDueDate = loanBean[config.'operation.firstDueDate'] as Date
    
            // Make sure the first due date is not expired
            Date now = new Date()
            if (firstDueDate.before(now)) {
                throw new ValidationException(config.'message.authorization.expired' as String)
            }
    
            // Perform the repayment scheduled payment
            PerformScheduledPaymentDTO dto = new PerformScheduledPaymentDTO([
                from: payment.toOwner,
                to: payment.fromOwner,
                type: new TransferTypeVO(repaymentType.id),
                amount: payment.amount,
                description: config.'repayment.description' as String
            ])
            def dtoBean = scriptHelper.wrap(dto)
            dtoBean.installmentsCount = installments
            dtoBean.firstInstallmentDate = firstDueDate
            dtoBean[loanField.internalName] = payment
    
            // Interest
            if (monthlyInterestRate > 0.00001) {
                BigDecimal installmentAmount = calculateInstallmentAmount(
                        loanAmount, installments, new Date(), firstDueDate)
    
                dto.installments = []
                Date dueDate = firstDueDate
                for (int i = 0; i < installments; i++) {
                    def installment = new InstallmentDTO()
                    def instBean = scriptHelper.wrap(installment)
                    instBean.dueDate = dueDate
                    instBean.amount = installmentAmount
                    dto.installments << installment
                    dueDate = DateHelper.add(dueDate, TimeField.DAYS, 30)
                }
                dtoBean.amount = installmentAmount * installments
            }
    
            ScheduledPaymentVO repaymentVO = scheduledPaymentService.perform(dto)
            ScheduledPayment repayment = emh.find(ScheduledPayment, repaymentVO.id)
    
            // Update the loan with the repayment link
            loanBean[repaymentField.internalName] = repayment
            return repayment
        }
    }
    
    binding.variables.loan = new Loan(binding)
    

4.4.2.9. Create the custom operation script

Create a new script for the custom operation, with the following characteristics:

  • Name: Grant loan
  • Type: Custom operation
  • Included libraries: Loan
  • Run with all permissions: No
  • Parameters: leave empty
  • Script code executed when the custom operation is executed:
    import org.cyclos.entities.banking.Payment
    import org.cyclos.entities.users.User
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def grantLoan() {
        def variables = binding.variables
        Loan loan = variables.loan as Loan
        Payment payment = loan.grant(variables.user as User,
            variables.formParameters as Map<String, Object>)
        if (payment.transfer == null) {
            return loan.config['message.loanGranted.pending']
        } else {
            return loan.config['message.loanGranted']
        }
    }
    
    grantLoan()

4.4.2.10. Create two extension point scripts

Create a new script for the transaction extension point, which will close the loan when all installments are processed:

  • Name: Loan closing
  • Type: Extension point
  • Included libraries: Loan
  • Parameters: leave empty
  • Script code executed when the data is saved:
    import org.cyclos.entities.banking.ScheduledPayment
    import org.cyclos.model.ValidationException
    import org.cyclos.model.banking.transactions.ScheduledPaymentStatus
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def closeLoan() {
        ScheduledPayment transaction = binding.variables.transaction as ScheduledPayment
        if (transaction.status == ScheduledPaymentStatus.CANCELED) {
            // Should never cancel a loan scheduled payment
            throw new ValidationException("Cannot cancel a loan")
        } else if (transaction.status == ScheduledPaymentStatus.CLOSED) {
            // Close the loan
            (binding.variables.loan as Loan).close(transaction)
        }
    }
    
    closeLoan()

Also, create another script for the authorization extension point, which will create the repayment scheduled payment once the loan is authorized:

  • Name: Loan authorization
  • Type: Extension point
  • Included libraries: Loan
  • Parameters: leave empty
  • Script code executed when the data is saved:
    import org.cyclos.entities.banking.Payment
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def createRepayment() {
        Payment transaction = binding.variables.transaction as Payment
        if (transaction.getTransfer() != null) {
            // The transaction was authorized, create the repayment
            (binding.variables.loan as Loan).createRepayment(transaction)
        }
    }
    
    createRepayment()

4.4.2.11. Create the custom operation

Under System > Tools > Custom operations, create a new one, with the following characteristics:

  • Name: Grant loan (can be changed, is the label displayed to users)
  • Enabled: yes
  • Scope: User
  • Script: Grant loan
  • Script parameters: leave empty
  • Result type: Notification
  • Has file upload: no
  • Main menu: Banking
  • User management section: Banking
  • Information text: you can add here some text explaining the process – it will be displayed in the operation page
  • Confirmation text: add here some text which will be displayed in a confirmation dialog before granting the loan

After saving, create the following fields:

  • Amount
    • Internal name: amount
    • Data type: Decimal
    • Required: yes
  • Installment count
    • Internal name: numberOfInstallments
    • Data type: Integer
    • Required: yes
  • First due date
    • Internal name: firstDueDate
    • Data type: Date
    • Required: yes

4.4.2.12. Create the extension points

Under System > Tools > Extension points, create a two new extension points, each with the following characteristics:

  • A transaction extension point (will close the loan when all installments are processed):
    • Name: Close loan
    • Type: Transaction
    • Enabled: yes
    • Transfer types: Units account – Loan repayment (choose the loan repayment type)
    • Events: Change status
    • Script: Loan closing
    • Script parameters: leave empty
  • An authorization extension point (will create the repayment once the loan is authorized):
    • Name: Loan authorization
    • Type: Authorization
    • Enabled: yes
    • Transfer types: Debit account – Loan grant (choose the loan grant type)
    • Events: Authorize
    • Script: Loan authorization
    • Script parameters: leave empty

4.4.2.13. Grant the administrator permissions

Under System > User configuration > Groups, select the Network administrators group (or the ones that will grant loans). Then, in the Permissions tab:

  • Under User management > Run custom operations over users, check the Grant loan operation
  • Under Accounts > Transfer status flows, make Loan visible, but not editable.
  • Under Accounts > Visible transaction fields, select all related custom fields.
  • Under User accounts > Scheduled payments, select View (and maybe process installment and settle too).

4.4.2.14. Enable the custom operation for users which will be able to receive loans

In System > User configuration > Products (permissions), select the member product for users which will be able to receive loans. In the Custom operations field, make the Grant loan operation enabled. Leave the run checkbox unchecked (or users would be able to grant loans to themselves!).

You can permit users to to repay loan installments anticipated in Units. For this you have to check in the member product 'process installment' and the user need to have permissions to make a payment of the transaction type used for the loan repayments.

4.4.3. Integrating with Global USSD

This example allows enabling operations to be performed via USSD. As each USSD gateway has a different protocol, a generic solution is not available. This script assumes the USSD integration is provided by Global USSD.

The provided examples allows getting the account information and performing direct payments. The mobile phone number used in the USSD interaction must exist as a mobile phone in Cyclos for an active user.

It is recommended that a channel named USSD is created in Cyclos, so its settings won't affect other web service clients. For example, the script assumes there is no confirmation password. Also, having a separated channel allows a finer control for users if they want to enable or disable the channel. The steps below assume a specific channel is used.

4.4.3.1. Create the USSD channel

On the System > System configuration > Channels menu, create a new channel, with the following fields:

  • Name: USSD (can be changed as desired)
  • Internal name: ussd

4.4.3.2. Enable the USSD channel for users

On the System > System configuration > Configurations menu, select either the default or a specific configuration. On the channels tab, click USSD. Then fill in the fields as following:

  • Enabled: checked
  • User access: If 'Enabled by default' is set, all users will be able to use the USSD operations initially. This can be changed as desired.
  • User identification method: Mobile phone
  • Default user identification method: No default.
  • Access password: Login password (can be changed to a PIN if desired).
  • Confirmation password: None (this is important, as the script will only ask for the access password).
  • Session timeout: Leave blank (no sessions will be used from the Cyclos point-of-view).
  • Perform payments - user identification methods: Login name (this is how the payee will be interpreted when performing a payment).

4.4.3.3. Create a payment type for USSD

Under System > Account configuration > Account types, select the user account. Then on the 'Transfer types' tab, create a new payment type with the following fields:

  • Name: USSD payment (can be changed as desired).
  • To: (select a user account type which will be the destination, normally the same as From).
  • Enabled: checked.
  • Channels: USSD.
  • User identification methods: Mobile phone (or leave empty, meaning All).

4.4.3.4. Grant permissions for users to perform this payment type

In the System > User configurations > Products (permissions), select a product which contains the account (or create a new one), adding the USSD payment type in 'User payments'.

4.4.3.5. Create the library script

Under System > Tools > Scripts, create a new library script, with the following characteristics:

  • Name: USSD library
  • Type: Library
  • Included libraries: none
  • Parameters:
    ### Settings
    
    # The session timeout, in seconds
    sessionTimeout=60
    
    # The channel internal name which will be used for the operations
    channel=ussd
    
    ### Translations
    mainMenu.title=Main menu
    mainMenu.accountInfo=Account information
    mainMenu.payment=Perform payment
    accountInfo.type=Account  
    accountInfo.balance=Balance: {0}
    accountInfo.reservedAmount=Reserved: {0}
    accountInfo.creditLimit=Negative limit: {0}
    accountInfo.availableBalance=Available: {0}
    accountInfo.noAccount=You don't have any account
    accountInfo.error.type=The account {0} is invalid
    payment.payee=Pay to user
    payment.error.payee=The user {0} is invalid
    payment.noPaymentType=No possible payment type to pay to {0} using this channel
    payment.type=Payment type
    payment.error.type=The payment type {0} is invalid
    payment.amount=Amount
    payment.error.amount=The amount is invalid: {0}
    payment.confirmation=Are you sure to pay {0} to {1}, with type {2}?
    payment.performed=You have successfully paid {0} to {1}, with type {2}
    payment.error.general=There was an unknown error when performing the payment
    payment.error.balance=There is no available balance to perform this payment
    payment.error.maxAmount=The maximum amount has been exceeded for this period
    payment.error.maxPayments=The maximum number of payments has been exceeded for this period
    payment.error.minTime=The minimum time between the last payment has not yet passed
    password.error.invalid={0} is invalid
    password.error.blocked={0} has been blocked
    general.submit=Submit
    general.unregisteredPhone=Your phone number, {0}, is not registered in Cyclos
    general.sessionExpired=Your session has expired. Please, restart the operation.
    general.returnToMainMenu=(Input 0 to return to Main Menu)"
    general.actionAborted=The action {0} was aborted
  • Script code:
    import java.text.MessageFormat
    
    import org.cyclos.entities.banking.PaymentTransferType
    import org.cyclos.entities.users.MobilePhone
    import org.cyclos.entities.utils.CurrencyAmount
    import org.cyclos.impl.access.SessionData
    import org.cyclos.model.EntityNotFoundException
    import org.cyclos.model.ValidationException
    import org.cyclos.model.access.IndefinitelyBlockedPasswordException
    import org.cyclos.model.access.TemporarilyBlockedPasswordException
    import org.cyclos.model.banking.InsufficientBalanceException
    import org.cyclos.model.banking.MaxAmountExceededException
    import org.cyclos.model.banking.MaxPaymentsExceededException
    import org.cyclos.model.banking.MinTimeBetweenPaymentsException
    import org.cyclos.model.banking.accounts.AccountOwner
    import org.cyclos.model.banking.accounts.AccountVO
    import org.cyclos.model.banking.accounts.AccountWithStatusVO
    import org.cyclos.model.banking.transactions.PaymentVO
    import org.cyclos.model.banking.transactions.PerformPaymentDTO
    import org.cyclos.model.banking.transfertypes.TransferTypeVO
    import org.cyclos.model.users.users.UserLocatorVO
    import org.cyclos.model.utils.ModelHelper
    import org.cyclos.server.utils.ObjectParameterStorage
    import org.cyclos.utils.BigDecimalHelper
    import org.cyclos.utils.StringHelper
    
    class Pages {
        static String MAIN_MENU = "mainMenu"
        static String ACBALANCE_ASKACCOUNT = "acBalanceAskAccount"
        static String ACBALANCE_ASKPASSWORD = "acBalanceAskPassword"
        static String ACBALANCE_DISPLAY = "acBalanceDisplay"
        static String PAYMENT_ASKPAYEE = "payAskPayee"
        static String PAYMENT_ASKAMOUNT = "payAskAmount"
        static String PAYMENT_ASKPAYMENTTYPE = "payAskPaymentType"
        static String PAYMENT_ASKPASSWORD = "payAskPassword"
        static String PAYMENT_PERFORM = "payPerform"
    }
    
    class UssdHandler {
        MobilePhone phone
        SessionData userSessionData
        ObjectParameterStorage session
        boolean newSession
        def binding
    
        static void newXmlMessage(def xml, String message) {
            if (StringHelper.isBlank(message)) {
                return
            }
            xml.div(message)
            xml.div("")
        }
    
        UssdHandler(MobilePhone phone, SessionData userSessionData, Object binding) {
            this.phone = phone
            this.userSessionData = userSessionData
            this.binding = binding
            def sessionKey = "ussd_" + phone.normalizedNumber
            newSession = !binding.scriptStorageHandler.exists(sessionKey)
            session = binding.scriptStorageHandler.get(sessionKey,
                    binding.scriptParameters.sessionTimeout as int)
        }
    
        boolean isNewSession() {
            newSession
        }
    
        Object propertyMissing(String name) {
            binding[name]
        }
    
        Object methodMissing(String name, args) {
            throw new EntityNotFoundException(entityType: "UssdOperation", key: name)
        }
    
        /** Ask for the confirmation password */
        private void askPassword(def xml, String pageToSend,
                String title, String message) {
            newXmlMessage(xml, message)
            xml.div() {
                xml.input(
                        navigationId: "form",
                        title: title,
                        name: "PASSWORD",
                        type: "number")
            }
            xml.div(scriptParameters["general.returnToMainMenu"])
            xml.navigation(id: "form"){
                xml.link(
                        pageId : pageToSend,
                        scriptParameters["general.submit"])
            }
        }
    
        /** Ask for the payment receiver */
        private void askPayee(def xml, String message) {
            newXmlMessage(xml, message)
    
            xml.div() {
                xml.input(
                        navigationId: "form",
                        title: scriptParameters["payment.payee"],
                        name: "PAYEE",
                        type: "Text")
            }
            xml.div(scriptParameters["general.returnToMainMenu"])
            xml.navigation(id: "form") {
                xml.link(
                        pageId : Pages.PAYMENT_ASKPAYMENTTYPE,
                        scriptParameters["general.submit"])
            }
        }
    
        /** Ask for the payment type */
        private void askPaymentType(def xml, String message) {
    
            def paymentTypes = (session.paymentTypes ?: [:]).collectEntries({ k, v ->
                [
                    k,
                    entityManagerHandler.find(PaymentTransferType, v)
                ]
            })
    
            if (paymentTypes.size() == 1) {
                // There is a single payment type - store it and ask the amount
                request.parameters.PAYMENT_TYPE = "1"
                payAskAmount(xml, "")
                return
            }
    
            newXmlMessage(xml, message)
            // Generate the option list
            paymentTypes.each {
                xml.div("${it.key}: ${it.value.name}")
            }
            // Generate the form to allow user choose
            xml.div() {
                xml.input(navigationId: "form",
                title: scriptParameters["payment.type"],
                name: "PAYMENT_TYPE",
                type: "number")
            }
            xml.navigation(id: "form"){
                xml.link(pageId : Pages.PAYMENT_ASKAMOUNT,
                scriptParameters["general.submit"])
            }
        }
    
        /** Ask for the payment amount */
        private void askAmount(def xml, String message) {
            newXmlMessage(xml, message)
    
            xml.div() {
                xml.input(navigationId: "form",
                title: scriptParameters["payment.amount"],
                name: "AMOUNT",
                type: "number")
            }
            xml.div(scriptParameters["general.returnToMainMenu"])
            xml.navigation(id: "form") {
                xml.link(pageId : Pages.PAYMENT_ASKPASSWORD,
                scriptParameters["general.submit"])
            }
        }
    
        /** Check for a password, either returning true and don't touching
         * the XML or returning false and sending an error in the XML */
        private boolean checkPassword(def xml, String password, String nextPage) {
            def accessPassword = userSessionData.channelAccessAccessor.accessPassword
            try {
                passwordHandler.checkPassword(false,
                        accessPassword,
                        userSessionData.loggedUser,
                        password)
                return true
            } catch (TemporarilyBlockedPasswordException |
            IndefinitelyBlockedPasswordException e) {
                askPassword(xml, nextPage,
                        accessPassword.name,
                        MessageFormat.format(scriptParameters["password.error.blocked"], accessPassword.name))
                return false
            } catch (Exception e) {
                askPassword(xml, nextPage,
                        accessPassword.name,
                        MessageFormat.format(scriptParameters["password.error.invalid"], accessPassword.name))
                return false
            }
        }
    
        /** Performs the payment, returning the result if succeed or sending the XML
         * error if not  */
        private PaymentVO performPayment(def xml, PerformPaymentDTO dto) {
            try {
                return paymentService.perform(dto)
            } catch (ValidationException e) {
                mainMenu(xml, e.validation?.firstError)
            } catch (InsufficientBalanceException e) {
                mainMenu(xml, scriptParameters["payment.error.balance"])
            } catch (MaxAmountExceededException e) {
                mainMenu(xml, scriptParameters["payment.error.maxAmount"])
            } catch (MaxPaymentsExceededException e) {
                mainMenu(xml, scriptParameters["payment.error.maxPayments"])
            } catch (MinTimeBetweenPaymentsException e) {
                mainMenu(xml, scriptParameters["payment.error.minTime"])
            } catch (Exception e) {
                mainMenu(xml, scriptParameters["payment.error.general"])
            }
            // there was some error
            return null
        }
    
        /** Removes all payment-related attributes from the session */
        private void clearSessionPayment() {
            [
                "payee",
                "paymentTypes",
                "paymentType",
                "amount"
            ].forEach(session.&remove)
        }
    
        /** Handler for Pages.MAIN_MENU */
        String mainMenu(def xml, String message) {
            newXmlMessage(xml, message)
            xml.navigation() {
                xml.link(
                        accesskey: "1",
                        pageId: Pages.ACBALANCE_ASKACCOUNT,
                        scriptParameters["mainMenu.accountInfo"])
                xml.link(
                        accesskey: "2",
                        pageId: Pages.PAYMENT_ASKPAYEE,
                        scriptParameters["mainMenu.payment"])
            }
    
            // Clear the session attributes for specific actions
            clearSessionPayment()
        }
    
        /** Handler for Pages.ACBALANCE_ASKACCOUNT */
        void acBalanceAskAccount(def xml, String message) {
            List<AccountWithStatusVO> accountSummaries =
                    binding.accountService.getAccountsSummary(userSessionData.loggedUser,
                    null)
    
            if (accountSummaries.isEmpty()) {
                mainMenu(xml, scriptParameters["accountInfo.noAccount"])
                return
            }
    
            // create a map with visible accounts and add this to context
            def accounts = [:]
            def option = 1
            accountSummaries.each { a ->
                accounts."${option}" = a
                option++
            }
            session.accounts = accounts
    
    
            askAccount(xml, null)
        }
    
        /** Handler for Pages.ASKACCOUNT */
        void askAccount(def xml, String message) {
            def accounts = session.accounts
            if (accounts.size() == 1) {
                request.parameters.ACCOUNT = "1"
                acBalanceAskPassword(xml, null)
                return
            }
    
            newXmlMessage(xml, message)
    
            // Generate the option list
            def key = 1
            accounts.each {
                xml.div("${it.key}: ${it.value.type.name}")
            }
    
            // Generate the form to allow user choose
            xml.div() {
                xml.input(navigationId: "form",
                title: scriptParameters["accountInfo.type"],
                name: "ACCOUNT",
                type: "number")
            }
            xml.navigation(id: "form"){
                xml.link(pageId : Pages.ACBALANCE_ASKPASSWORD,
                scriptParameters["general.submit"])
            }
        }
    
        /** Handler for Pages.ACBALANCE_ASKPASSWORD */
        void acBalanceAskPassword(def xml, String message) {
            def acc = request.parameters.ACCOUNT
            // Check whether to return to the main menu
            if (acc == "0") {
                mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
                        scriptParameters["mainMenu.accountInfo"]))
                return
            }
            // Validate the Account
            def accounts = session.accounts
            if (!accounts.containsKey(acc)) {
                askAccount(xml,
                        MessageFormat.format(scriptParameters["accountInfo.error.type"], acc))
                return
            }
            // Store the account summary type in the session
            session.accountId = accounts[acc].id
    
            askPassword(xml, Pages.ACBALANCE_DISPLAY,
                    userSessionData.channelAccessAccessor.accessPassword.name,
                    null)
        }
    
        /** Handler for Pages.ACBALANCE_DISPLAY */
        void acBalanceDisplay(def xml, String message) {
            def password = request.parameters.PASSWORD
            // Check whether to return to the main menu
            if (password == "0") {
                mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
                        scriptParameters["mainMenu.accountInfo"]))
                return
            }
            // Check PASSWORD
            if (!checkPassword(xml, password, Pages.ACBALANCE_DISPLAY)) {
                return
            }
    
            // Generate the Balance result string
            def account = accountService.getAccountWithStatus(
                    new AccountVO(session.accountId as long), null)
    
            xml.div(account.type.name)
            def status = account.status
    
            def balance = formatter.format(
                    ModelHelper.currencyAmount(account.currency,
                    status.balance))
            xml.div(MessageFormat.format(scriptParameters["accountInfo.balance"],
                    balance))
            if (BigDecimalHelper.isPositive(status.reservedAmount)) {
                def reservedAmount = formatter.format(
                        ModelHelper.currencyAmount(account.currency,
                        status.reservedAmount))
                xml.div(MessageFormat.format(
                        scriptParameters["accountInfo.reservedAmount"],
                        reservedAmount))
            }
            if (BigDecimalHelper.isPositive(status.creditLimit)) {
                def creditLimit = formatter.format(
                        ModelHelper.currencyAmount(
                        account.currency,
                        status.creditLimit))
                xml.div(MessageFormat.format(
                        scriptParameters["accountInfo.creditLimit"],
                        creditLimit))
            }
            if (!BigDecimalHelper.areEquals(status.balance,
            status.availableBalance)) {
                def availableBalance = formatter.format(
                        ModelHelper.currencyAmount(account.currency,
                        status.availableBalance))
                xml.div(MessageFormat.format(
                        scriptParameters["accountInfo.availableBalance"],
                        availableBalance))
            }
    
            xml.navigation() {
                xml.link(
                        accesskey : "0",
                        pageId : Pages.MAIN_MENU,
                        scriptParameters["mainMenu.title"])
            }
        }
    
        /** Handler for Pages.PAYMENT_ASKPAYEE */
        void payAskPayee(def xml, String message) {
            askPayee(xml, null)
        }
    
        /** Handler for Pages.PAY_ASK_PAYMENT_TYPE */
        void payAskPaymentType(def xml, String message) {
            def payee = request.parameters.PAYEE
            // Check whether to return to the main menu
            if (payee == "0") {
                mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
                        scriptParameters["mainMenu.payment"]))
                return
            }
    
            // Validate the payee
            AccountOwner accOwnerPayee
            def locator = new UserLocatorVO(principal: payee)
            try {
                accOwnerPayee = transactionService.locateForPayment(locator).accountOwner
            } catch (Exception e) {
                askPayee(xml,
                        MessageFormat.format(scriptParameters["payment.error.payee"], payee))
                return
            }
    
            // Add the payee to session context
            session.payee = accOwnerPayee
    
            // Get the allowed TT between payer and payee and generate options
            def payer = userSessionData.loggedUser
            def paymentData = transactionService.getPaymentToOwnerData(payer,
                    accOwnerPayee, null)
            if (paymentData.paymentTypes.size == 0) {
                askPayee(xml,
                        MessageFormat.format(scriptParameters["payment.noPaymentType"],
                        payee))
                return
            } else {
                // create a map with allowed paymentTypes and add this to context
                def paymentTypes = [:]
                def option = 1
                paymentData.paymentTypes.each { tt ->
                    paymentTypes."${option}" = conversionHandler.convert(PaymentTransferType,
                            tt)
                    option++
                }
                session.paymentTypes = paymentTypes
    
                // Ask the payment type
                askPaymentType(xml, null)
            }
        }
    
        /** Handler for Pages.PAY_ASKAMOUNT */
        void payAskAmount(def xml, String message) {
            def tt = request.parameters.PAYMENT_TYPE
            // Check whether to return to the main menu
            if (tt == "0") {
                mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
                        scriptParameters["mainMenu.payment"]))
                return
            }
            // Validate the TT
            def paymentTypes = session.paymentTypes
            if (!paymentTypes.containsKey(tt)) {
                askPaymentType(xml,
                        MessageFormat.format(scriptParameters["payment.error.type"], tt))
                return
            }
            // Store the payment type in the session
            session.paymentType = entityManagerHandler.find(PaymentTransferType,
                    paymentTypes[tt])
    
            // Ask the amount
            askAmount(xml, null)
        }
    
        /** Handler for Pages.PAYMENT_ASKPASSWORD */
        void payAskPassword(def xml, String message) {
            def amt = request.parameters.AMOUNT
    
            // Check whether to return to the main menu
            if (amt == "0") {
                mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
                        scriptParameters["mainMenu.payment"]))
                return
            }
    
            // Validate the AMOUNT
            BigDecimal amount
            try {
                amount = new BigDecimal(amt)
            } catch (Exception e) {
                askAmount(xml, MessageFormat.format(scriptParameters["payment.error.amount"],
                        amt))
                return
            }
    
            // Add the amount to the session
            session.amount = amount
    
            // Now ask the password
            askPassword(xml, Pages.PAYMENT_PERFORM,
                    userSessionData.channelAccessAccessor.accessPassword.name,
                    getPaymentMessage(scriptParameters["payment.confirmation"]))
        }
    
        /** Handler for Pages.PAYMENT_PERFORM */
        void payPerform(def xml, String message) {
            def password = request.parameters.PASSWORD
    
            // Check whether to return to the main menu
            if (password == "0") {
                mainMenu(xml, MessageFormat.format(scriptParameters["general.actionAborted"],
                        scriptParameters["mainMenu.payment"]))
                return
            }
    
            // first validate PASSWORD
            checkPassword(xml, password, Pages.PAYMENT_PERFORM)
    
            // Build the PerformPaymentDTO
            def dto = new PerformPaymentDTO()
            dto.from = userSessionData.loggedUser
            dto.to = session.payee
            dto.amount = session.amount
            dto.type = new TransferTypeVO(session.paymentType.id)
    
            // perform the payment
            def result = performPayment(xml, dto)
            if (result) {
                // Only handle the success, because on failure the XML is already sent
                mainMenu(xml, getPaymentMessage(scriptParameters["payment.performed"]))
            }
        }
    
        String getPaymentMessage(String template) {
            def paymentType = session.paymentType
            if (paymentType == null || session.amount == null) return null
            def amount = new CurrencyAmount(paymentType.currency, session.amount)
            return MessageFormat.format(template,
                    formatter.format(amount),
                    formatter.format(session.payee),
                    formatter.format(paymentType))
        }
    }
    

4.4.3.6. Create the custom web service script

Create a new script for the custom web service, with the following characteristics:

  • Name: USSD web service
  • Type: Custom web service
  • Included libraries: USSD library
  • Parameters: leave empty
  • Script code executed when the custom operation is executed:
    import java.text.MessageFormat
    
    import org.apache.commons.lang3.StringUtils
    import org.cyclos.entities.users.MobilePhone
    import org.cyclos.entities.users.QMobilePhone
    import org.cyclos.impl.access.SessionDataFactory
    import org.cyclos.model.users.users.UserStatus
    import org.cyclos.model.utils.ResponseInfo
    
    import groovy.xml.MarkupBuilder
    
    // The XML builder will write to a StringWriter
    def stringWriter = new StringWriter()
    def xml = new MarkupBuilder(stringWriter)
    xml.doubleQuotes = true
    xml.omitNullAttributes = true
    xml.mkp.xmlDeclaration(version:"1.0", encoding: "UTF-8")
    
    // Resolve the normalized international phone number via the subscriber parameter
    String phoneNumber = StringUtils.trimToNull(request.parameters.subscriber)
    MobilePhone mobilePhone = null;
    if (phoneNumber == null) {
        return new ResponseInfo(422, "The subscriber parameter is missing")
    } else {
        // Find the mobile phone in Cyclos
        phoneNumber = "+" + StringUtils.removeStart(phoneNumber, "+");
        def mp = QMobilePhone.mobilePhone;
        mobilePhone = entityManagerHandler.from(mp)
                .where(mp.normalizedNumber.eq(phoneNumber),
                mp.user().status.eq(UserStatus.ACTIVE))
                .singleResult(mp)
    }
    if (mobilePhone == null) {
        // The mobile phone is not found in Cyclos
        xml.page(version: "2.0") {
            div(MessageFormat.format(scriptParameters['general.unregisteredPhone'],
                    phoneNumber))
        }
    } else {
        // Get session or create a new one
        def runAs = SessionDataFactory.direct(mobilePhone)
                .channel(scriptParameters.channel)
                .requestData(sessionData.requestData)
                .build()
        def ussdHandler = new UssdHandler(mobilePhone, runAs, binding)
    
        def page = binding.pathVariables.path ?: Pages.MAIN_MENU
        def message = ""
        if (page != Pages.MAIN_MENU && ussdHandler.newSession) {
            // When there is a new session in a page that is not the main menu,
            // assume the session has expired
            message = scriptParameters['general.sessionExpired']
            page = Pages.MAIN_MENU
        }
    
        // Invoke the UssdHandler method
        invokerHandler.runAs(runAs) {
            xml.page(version: "2.0") {
                ussdHandler."${page}"(delegate, message)
            }
        }
    }
    
    // Now the output stringWriter should contain the XML output. Build the response.
    def response = new ResponseInfo(status: 200, stringBody: stringWriter.toString())
    response.setHeader("Content-Type","application/xml;charset=UTF-8")
    return response

4.4.3.7. Create the custom web service

Under System > Tools > Custom web services, create a new one, with the following characteristics:

  • Name: USSD
  • Http method: GET
  • Run as: Guest
  • Script: USSD web service
  • Script parameters: leave empty
  • Url mappings: ussd/{path}

In order to provide security in production environment, you need to set a IP Whilelist checking the IP address whitelist box.

4.4.3.8. Enable a Global USSD account

You need an account at Global USSD with credits to be able to process USSD requests. To do so:

  • Go to https://account.globalussd.com/#register and create an account.
  • Login with your Global USSD account, go to the "Balance" option and add money to your wallet. Only accounts with credits will be able to operate via USSD.

Then you will need to configure a service, which can be found in the "Services" option. Create one with the following fields:

  • Name: (fill in a name)
  • Service URl: http(s) <cyclos_network_url>/run/ussd/mainMenu
  • Content request HTTP-method: GET
  • Take note of the "Push URL" value. It will be used to push a new USSD session (it would be of type http://prod.globalussd.mobi/push?service=<BotID>&subscriber=<MSISDN>).

4.4.3.9. Start an USSD session

Finally, assuming there is a user with a given mobile phone number, you can use the "push URL" shown in the Global USSD bot service page to start a session. Just perform a request to that URL, replacing the MSISDN text by the international mobile phone number, and BotID text with Bot service ID. Assuming the mobile phone's provider is supported by Global USSD, the user should see the USSD menu in his mobile phone.

4.4.4. Record edition

This solution lets you edit records using custom operations so you can do it via Web services or from the Mobile app where this functionality is not supported. However, it could be used as a guide for other entities.

The logged user is able to search its own records, update or delete them and create new ones (i.e CRUD operations).

It is also possible to search records of other users (you can not do it directly in Cyclos) and run actions over them, in this example we created an action that simulates send an email to the record's owner (you can implement anything you like).

To configure this, follow carefully each of the following steps:

4.4.4.1. Create the user record type to work with and give permissions

Under System > System configuration > Record types, create a new User record type, with the following characteristics:

  • Name: Daily note
  • Internal name: dailyNote (must be exactly like this)
  • Display style: List
  • Show in Menu: yes

For this record type, create the following fields:

Title

  • Internal name: title (must be exactly like this)
  • Data type: Single line text
  • Required: no
  • Show in results: yes

Description

  • Internal name: description (must be exactly like this)
  • Data type: Multiple line text
  • Required: no
  • Show in results: yes

Now give permissions:

to admin group (Groups > “Your admin group” > Permissions): in Records, check Enable, View, Create, Edit and Remove over “Daily note”.

to user group (Groups > “The corresponding product”): in Records, check Enable, View, Create, Edit and Remove over “Daily note”.

4.4.4.2. Create the library script

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: Record search library
  • Type: Library
  • Script code executed when the custom operation is executed:
    import org.cyclos.model.system.fields.CustomFieldType
    import org.cyclos.model.system.fields.ICustomFieldValue
    import org.cyclos.model.system.scripts.CustomScriptException
    import org.cyclos.model.users.records.UserRecordVO
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    class Styles {
        static String infoBox = "white-space: normal; text-overflow: ellipsis;"
        static String fieldContainerLabel = "white-space: normal; text-overflow: ellipsis; font-weight: 400; font-size: 15px; color: #1865a3;"
        static String fieldContainerValue = "white-space: normal; text-overflow: ellipsis;  margin: 2px 0 9px 0; font-size: 16px; line-height: 18px;"
        static String infoBoxInline = "white-space: normal; text-overflow: ellipsis;"
        static String fieldContainerLabelInline = "white-space: normal; text-overflow: ellipsis; font-weight: 400; font-size: 15px; color: #1865a3;"
        static String fieldContainerValueInline = "white-space: normal; text-overflow: ellipsis;  margin: 2px 0 9px 0; font-size: 16px; line-height: 18px;"
    }
    
    @TypeChecked
    class Helper {
    
        static Object getValue(ICustomFieldValue fieldValue) {
            if (!fieldValue) {
                return null;
            }
            switch (fieldValue.getField().getType()) {
                case CustomFieldType.BOOLEAN:
                    return fieldValue.getBooleanValue()
                case CustomFieldType.DATE:
                    return fieldValue.getDateValue()
                case CustomFieldType.DECIMAL:
                    return fieldValue.getDecimalValue()
                case CustomFieldType.INTEGER:
                    return fieldValue.getIntegerValue()
                case CustomFieldType.RICH_TEXT:
                    return fieldValue.getRichTextValue()
                case CustomFieldType.STRING:
                    return fieldValue.getStringValue()
                case CustomFieldType.TEXT:
                    return fieldValue.getTextValue()
                case CustomFieldType.URL:
                    return fieldValue.getStringValue()
                case CustomFieldType.IMAGE:
                case CustomFieldType.FILE:
                case CustomFieldType.LINKED_ENTITY:
                case CustomFieldType.MULTI_SELECTION:
                case CustomFieldType.SINGLE_SELECTION:
                case CustomFieldType.DYNAMIC_SELECTION:
                    throw new CustomScriptException("""There was an error searching records.
                        Please, contact the administration""")
                default:
                    throw new CustomScriptException("""Error searching records:
                        Unimplemented custom field type. Please, contact the administration""")
            }
        }
    
        static Object getCustomValueByInternalName(UserRecordVO record, String internalName){
            def value = record.getCustomValues().stream()
                    .filter{ it.getField().getInternalName().equals(internalName) }
                    .findAny()
            return getValue(value.orElse(null))
        }
    }

4.4.4.3. Create the custom operation script to send the record email

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: Send record email
  • Type: Custom operation
  • Script code executed when the custom operation is executed:
    "The email was sent"

4.4.4.4. Create the custom operation script to search records

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: Search records
  • Type: Custom operation
  • Run with all permissions: yes
  • Included libraries: Record search library
  • Parameters:
    recordType=dailyNote
    titleInternalName=title
    descInternalName=description
  • Script code executed when the custom operation is executed:
    import org.cyclos.entities.users.User
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.impl.utils.formatting.FormatterImpl
    import org.cyclos.model.users.records.UserRecordQuery
    import org.cyclos.model.users.records.UserRecordVO
    import org.cyclos.model.users.recordtypes.RecordTypeVO
    import org.cyclos.model.users.users.UserVO
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    class RecordBean {
        Long recordId
        String title
        String description
        String user
    
        RecordBean(Long id, String title, String description, String user) {
            this.recordId = id
            this.title = title;
            this.description = description
            this.user = user
        }
    }
    
    @TypeChecked
    RecordBean toRecordBean(UserRecordVO recordVO, String title, String description) {
        def titleRow = Helper.getCustomValueByInternalName(recordVO, title) as String
        def descRow = Helper.getCustomValueByInternalName(recordVO, description) as String
        def userRow = (binding.variables.formatter as FormatterImpl).format(recordVO.getUser())
        return new RecordBean(recordVO.id, titleRow, descRow, userRow)
    }
    
    @TypeChecked
    def searchRecords() {
        def variables = binding.variables
        def scriptParameters = variables.scriptParameters as Map<String, Object>
        def formParameters = variables.formParameters as Map<String, Object>
        User user = variables.user as User
        RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
        def type = scriptParameters.recordType as String
        def title = scriptParameters.titleInternalName as String
        def description = scriptParameters.descInternalName as String
        def query = new UserRecordQuery()
        if (formParameters.searchOnlyInMyRecords) {
            query.user = new UserVO(user.id)
        }
        query.currentPage = variables.currentPage as Integer
        query.pageSize = variables.pageSize as Integer
        query.skipTotalCount = true == variables.skipTotalCount
        query.type = new RecordTypeVO(RecordTypeVO.INTERNAL_NAME, type)
        query.keywords = formParameters.keywords as String
    
        def page = recordService.search(query)
        def rows = page.pageItems.stream().collect {
            toRecordBean(it as UserRecordVO, title, description)
        }
    
        return [
            columns: [
                [header: "Owner", property: "user",width:"15%"],
                [header: "Title", property: "title", width:"35%"],
                [header: "Description", property: "description",width:"50%"]
            ],
            rows: rows,
            totalCount: page.totalCount,
            hasNextPage: page.hasNextPage
        ]
    }
    
    return searchRecords()
    

4.4.4.5. Create the custom operation script to create records

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: Create record
  • Type: Custom operation
  • Run with all permissions: yes
  • Parameters:
    recordType=dailyNote
    titleInternalName=title
    descInternalName=description
  • Script code executed when the custom operation is executed:
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.model.system.fields.CustomFieldValueDTO
    import org.cyclos.model.users.records.RecordDataParams
    import org.cyclos.model.users.recordtypes.RecordTypeVO
    import org.cyclos.model.utils.NotificationLevel
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def createRecord() {
        def msg = "The record was created successfully."
        def error = false
    
        try {
            def variables = binding.variables
            def scriptParameters = variables.scriptParameters as Map<String, Object>
            RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
            def formParameters = variables.formParameters as Map<String, Object>
            def titleField = scriptParameters.titleInternalName
            def descField = scriptParameters.descInternalName
            def type = scriptParameters.recordType
            def dataParams = new RecordDataParams()
            dataParams.setRecordType(new RecordTypeVO(RecordTypeVO.INTERNAL_NAME, type))
            def data = recordService.getDataForNew(dataParams)
            def newRecord = data.getDto()
            def customFieldValues = new ArrayList<CustomFieldValueDTO>()
    
            data.getFields().stream()
                    .filter {
                        data.getEditableFieldIds().contains(it.id) &&
                                (it.getInternalName().equals(titleField) ||
                                it.getInternalName().equals(descField))
                    }
                    .forEach {
                        def valueDTO = new CustomFieldValueDTO();
                        valueDTO.setField(it)
                        if (it.getInternalName().equals(titleField)) {
                            valueDTO.setStringValue(formParameters.title as String)
                        } else {
                            valueDTO.setTextValue(formParameters.description as String)
                        }
                        customFieldValues.add(valueDTO)
                    }
            newRecord.setCustomValues(customFieldValues)
            recordService.save(newRecord)
        } catch (Exception ex) {
            error = true
            msg = """There was an error trying to create the record.
                Please, contact the administration."""
        }
    
        return [
            notification: msg,
            notificationLevel: error ?  NotificationLevel.ERROR : NotificationLevel.INFORMATION,
            backTo: error ? null : "searchRecords",
            reRun: !error
        ]
    }
    
    return createRecord()
    

4.4.4.6. Create the custom operation script to view records

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: View record
  • Type: Custom operation
  • Run with all permissions: yes
  • Included libraries: Record search library
  • Script code executed when the custom operation is executed:
    
    import org.cyclos.entities.users.RecordCustomFieldValue
    import org.cyclos.entities.users.User
    import org.cyclos.entities.users.UserRecord
    import org.cyclos.entities.users.UserRecordType
    import org.cyclos.impl.access.SessionData
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.impl.users.RecordFieldHandler
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.impl.utils.formatting.FormatterImpl
    
    import groovy.transform.Field
    import groovy.transform.TypeChecked
    import groovy.xml.MarkupBuilder
    
    @Field Map<String, Object> variables = binding.variables
    
    @TypeChecked
    class RecordView{
        UserRecordType type
        User user
        String formattedCreationDate
        String formattedLastModifiedDate
        User modifiedBy
        User createdBy
        Map<String, String> customValues
    
        RecordView(UserRecordType type, User user, String formattedCreationDate,
        String formattedLastModifiedDate, User  modifiedBy, User createdBy,
        Map<String, String> customValues) {
            this.type = type
            this.user = user
            this.formattedCreationDate = formattedCreationDate
            this.formattedLastModifiedDate  = formattedLastModifiedDate
            this.createdBy = createdBy
            this.modifiedBy = modifiedBy
            this.customValues = customValues
        }
    }
    
    @TypeChecked
    def RecordView toRecordView(UserRecord record) {
        FormatterImpl formatter = variables.formatter as FormatterImpl
        Map<String, String> customValues = [:]
        def recordFieldValues = new ArrayList<RecordCustomFieldValue>(record.customValues)
        def recordFieldHandler = variables.recordFieldHandler as RecordFieldHandler
        recordFieldHandler.sortFieldValues(record.type, recordFieldValues)
        recordFieldValues.each {
            def value = Helper.getValue(it)
            if (value != null) {
                customValues[it.field.name] = formatter.format(value)
            }
        }
        String formattedCreationDate = formatter.format(record.creationDate)
        String formattedLastModifiedDate = formatter.format(record.lastModifiedDate)
        return new RecordView(record.type as UserRecordType, record.user,
                formattedCreationDate, formattedLastModifiedDate, record.createdBy,
                record.modifiedBy, customValues)
    }
    
    def createContent(StringWriter out, RecordView record) {
        def html = new MarkupBuilder(out)
        html.div(style:"${Styles.infoBox}") {
            div {
                div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Owner:" }
                div(style:"${Styles.fieldContainerValue}") {
                    mkp.yield formatter.format(record.user)
                }
            }
            if (record.type.isShowUpdateToUsers()) {
                div {
                    div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Created by:" }
                    div(style:"${Styles.fieldContainerValue}") {
                        mkp.yield record.createdBy?.display
                    }
                }
                div {
                    div(style:"${Styles.fieldContainerLabel}") { mkp.yield "Creation date:" }
                    div(style:"${Styles.fieldContainerValue}") {
                        mkp.yield record.formattedCreationDate
                    }
                }
                if (!record.formattedLastModifiedDate.isEmpty()) {
                    div {
                        div(style:"${Styles.fieldContainerLabel}") {
                            mkp.yield "Last modification date:"
                        }
                        div(style:"${Styles.fieldContainerValue}") {
                            mkp.yield record.formattedLastModifiedDate
                        }
                    }
                    div {
                        div(style:"${Styles.fieldContainerLabel}") {
                            mkp.yield "Last modification by:"
                        }
                        div(style:"${Styles.fieldContainerValue}") {
                            mkp.yield record.modifiedBy?.display
                        }
                    }
                }
            }
            record.customValues.each{ name, val ->
                div {
                    div(style:"${Styles.fieldContainerLabel}") { mkp.yield name }
                    div(style:"${Styles.fieldContainerValue}") { mkp.yield val }
                }
            }
        }
    }
    
    @TypeChecked
    def viewRecord() {
        def sessionData = variables.sessionData as SessionData
        def formParameters = variables.formParameters as Map<String, Object>
        RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
        ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper
        def id = scriptHelper.unmaskId(formParameters.recordId)
        def record = recordService.find(id) as UserRecord
        def out = new StringWriter()
        createContent(out, toRecordView(record))
    
        if (record.user == sessionData.loggedBasicUser) {
            return [
                content: out.toString(),
                actions: [
                    sendRecordEmail: [
                        enabled: false
                    ]
                ]
            ]
        } else {
            return [
                content: out.toString(),
                actions: [
                    removeRecord: [
                        enabled: false
                    ],
                    updateRecord: [
                        enabled: false
                    ]
                ]
            ]
        }
    }
    
    return viewRecord()
    

4.4.4.7. Create the custom operation script to update records

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: Update record
  • Type: Custom operation
  • Run with all permissions: yes
  • Parameters:
    titleInternalName=title
    descInternalName=description
  • Script code executed when the custom operation is executed:
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.model.system.fields.CustomFieldValueDTO
    import org.cyclos.model.users.records.RecordData
    import org.cyclos.model.users.recordtypes.RecordCustomFieldDetailedVO
    import org.cyclos.model.utils.NotificationLevel
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def updateRecord() {
    
        def variables = binding.variables
        def scriptParameters = variables.scriptParameters as Map<String, Object>
        def formParameters = variables.formParameters as Map<String, Object>
        RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
        ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper
        def msg = "The record was updated successfully."
        def error = false
    
        try{
            def titleField = scriptParameters.titleInternalName as String
            def descField = scriptParameters.descInternalName as String
            def newDesc = formParameters.newDescription as String
            def newTitle = formParameters.newTitle as String
            def id = scriptHelper.unmaskId(formParameters.recordId)
            def data = recordService.getData(id) as RecordData
            def newRecord = data.dto
            def fieldValues = [
                titleField: null,
                descField: null
            ] as Map<String, CustomFieldValueDTO>
    
            newRecord.getCustomValues().each{
                if (it.field.internalName == titleField) {
                    fieldValues.put(titleField, it)
                } else if (it.field.internalName == descField) {
                    fieldValues.put(descField, it)
                }
            }
    
            fieldValues.each { k, v ->
                def isTitle = titleField == k
                def value = isTitle ? newTitle : newDesc
                if (v) {
                    if (isTitle) {
                        v.stringValue = value
                    } else {
                        v.textValue = value
                    }
                } else {
                    data.fields.findAll { RecordCustomFieldDetailedVO it ->
                        it.internalName == k &&
                                data.editableFieldIds.contains(it.id)
                    }.each { RecordCustomFieldDetailedVO it ->
                        def valueDTO = new CustomFieldValueDTO();
                        valueDTO.field = it
                        if (isTitle) {
                            valueDTO.stringValue = value
                        } else {
                            valueDTO.textValue = value
                        }
                        newRecord.customValues << valueDTO
                    }
                }
            }
            recordService.save(newRecord)
        } catch (Exception ex) {
            error = true
            msg = """There was an error trying to update the record.
                Please, contact the administration."""
        }
    
        return [
            notification: msg,
            notificationLevel: error ?  NotificationLevel.ERROR : NotificationLevel.INFORMATION,
            backTo: error ? null : "recordDetails",
            reRun: !error
        ]
    }
    
    return updateRecord()
    
  • Script code executed before the form is show, to fill the initial field values:
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.utils.StringHelper
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def loadRecordFields() {
        def variables = binding.variables
        def scriptParameters = variables.scriptParameters as Map<String, Object>
        def formParameters = variables.formParameters as Map<String, Object>
        RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
        ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper
        def titleField = scriptParameters.titleInternalName as String
        def descField = scriptParameters.descInternalName as String
        def id = scriptHelper.unmaskId(formParameters.recordId)
        def newRecord = recordService.getData(id).dto
    
        def titleFieldValue = newRecord.customValues.find {
            it.field.internalName.equals(titleField)
        }
        def descFieldValue = newRecord.customValues.find {
            it.field.internalName.equals(descField)
        }
    
        return [
            newTitle: StringHelper.emptyIfNull(titleFieldValue?.stringValue),
            newDescription: StringHelper.emptyIfNull(descFieldValue?.textValue)
        ]
    }
    
    return loadRecordFields()
    

4.4.4.8. Create the custom operation script to remove records

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: Remove record
  • Type: Custom operation
  • Run with all permissions: yes
  • Script code executed when the custom operation is executed:
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.model.utils.NotificationLevel
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def removeRecord() {
        def msg = "The record was removed successfully."
        def error = false
    
        def variables = binding.variables
        RecordServiceLocal recordService = variables.recordService as RecordServiceLocal
        ScriptHelper scriptHelper = variables.scriptHelper as ScriptHelper
        def formParameters = variables.formParameters as Map<String, Object>
        try {
            recordService.remove(scriptHelper.unmaskId(formParameters.recordId))
        } catch(Exception ex) {
            error = true
            msg = """There was an error trying to remove the record.
                Please, contact the administration."""
        }
    
        return [
            notification: msg,
            notificationLevel: error
            ? NotificationLevel.ERROR : NotificationLevel.INFORMATION,
            backTo: error ? null : "searchRecords",
            reRun: !error
        ]
    }
    
    return removeRecord()
    

4.4.4.9. Create the custom operation to remove records

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: Remove record
  • Internal name: removeRecord (must be exactly like this)
  • Enable for channels: Main, Web services, Mobile app
  • Custom submit label: Remove (can be changed - will be the label displayed in the action button)
  • Scope: Internal
  • Script: Remove record
  • Result type: Notification
  • Custom script execute message: This record will be removed. Do you want to continue? (can be edited - it is necessary to alert the user that the record is going to be removed)

Once saved, on the Form fields tab, create a new field, with the following characteristics:

Record id:

  • Display name: Record id
  • Internal name: recordId (must be exactly like this)
  • Data type: Single line text

4.4.4.10. Create the custom operation to update records

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: Update record
  • Internal name: updateRecord (must be exactly like this)
  • Enable for channels: Main, Web services, Mobile app
  • Custom submit label: Update (can be changed - will be the label displayed in the action button)
  • Scope: Internal
  • Script: Update record
  • Result type: Notification
  • Show form: Always

Once saved, on the Form fields tab, create three new fields, with the following characteristics:

Record id:

  • Display name: Record id
  • Internal name: recordId (must be exactly like this)
  • Data type: Single line text

Title:

  • Display name: Title
  • Internal name: newTitle (must be exactly like this)
  • Data type: Single line text

Description:

  • Display name: Description
  • Internal name: newDescription (must be exactly like this)
  • Data type: Multiple line text

4.4.4.11. Create the custom operation to send the record email

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: Send record email
  • Internal name: sendRecordEmail (must be exactly like this)
  • Enable for channels: Main, Web services, Mobile app
  • Custom submit label: Send email (can be changed - will be the label displayed in the action button)
  • Scope: Internal
  • Script: Send record email
  • Result type: Notification

4.4.4.12. Create the custom operation to view records details

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: Record details
  • Internal name: recordDetails (must be exactly like this)
  • Enable for channels: Main, Web services, Mobile app
  • Scope: Internal
  • Script: View record
  • Result type: Rich text

Once saved, on the Form fields tab, create a new field, with the following characteristics:

Record id:

  • Display name: Record id
  • Internal name: recordId (must be exactly like this)
  • Data type: Single line text

Once saved, on the Actions tab, add three new actions, with the following characteristics:

  • Operation: Remove record
  • Parameters:
    • Record id: "Record id"
  • Operation: Update record
  • Parameters:
    • Record id: "Record id"
    • Title: Not used
    • Description: Not used
  • Operation: Send record email

4.4.4.13. Create the custom operation to create records

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: Create record
  • Internal name: createRecord (must be exactly like this)
  • Enable for channels: Main, Web services, Mobile app
  • Label: New (can be changed - will be the label displayed in the action button)
  • Scope: Internal
  • Script: Create record
  • Result type: Notification
  • Show form: Always

Once saved, on the Form fields tab, create two new fields, with the following characteristics:

Title:

  • Display name: Title
  • Internal name: title (must be exactly like this)
  • Data type: Single line text

Description:

  • Display name: Description
  • Internal name: description (must be exactly like this)
  • Data type: Multiple line text

4.4.4.14. Create the custom operation to search records

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: Daily notes (can be changed - will be the label displayed on the menu)
  • Internal name: searchRecords (must be exactly like this)
  • Enable for channels: Main, Web services, Mobile app
  • Scope: User
  • Script: Search records
  • Result type: Result page
  • Allow printing results: yes
  • Allow exporting results to CSV: yes
  • Action when clicking a row: Run an internal custom operation
  • Custom operation: Record details
  • Parameters to be passed (comma-separated names): recordId
  • Main menu: Personal (can be changed - will be the menu where the label is going to be displayed)
  • User management section: User management (can be changed - will be the section where the label is going to be displayed)
  • Enable for active users: yes

Once saved, on the Form fields tab, create two new fields, with the following characteristics:

Keywords:

  • Display name: Keywords
  • Internal name: keywords (must be exactly like this)
  • Data type: Single line text

Search only in my records:

  • Display name: Search only in my records
  • Internal name: searchOnlyInMyRecords (must be exactly like this)
  • Data type: Boolean

Once saved, on the Actions tab, add a new action, with the following characteristics:

  • Operation: Create record
  • Visibility: Before and after run the custom operation
  • Parameters:
    • Title: Not used
    • Description: Not used

4.4.4.15. Enable the custom operation for users

In System > User configuration > Products (permissions), select the member product for users which will be able to work with this operation. In the Custom operations field, make sure the Daily notes is both enabled and allowed to run over self.

4.4.5. User balances

This solution provides a custom operator for administrators to search the users' balances with 2 advantages over the regular user balances overview in Cyclos:

  • It is possible to select a date, so all presented balances will be for that date;
  • The available balances are also shown, but only if no date is set (as credit limit could have changed over time).

These options are not available in the regular balances search because they need to be calculated per user, whereas the current balance is stored in the database. This makes the script inviable when there are too many users. Still, some systems require this functionality.

However, as a drawback, the filters for users are also more limited in this script: it is only possible to filter by a specific group. So, if only the current balance is desired, it is advised to use the built-in functionality instead.

To configure this functionality, follow carefully each of the following steps:

4.4.5.1. Create the script to load user account types

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: User account types loader
  • Type: Load custom field values
  • Script code that returns the possible values when either creating or editing an entity:
    import java.util.stream.Collectors
    
    import org.cyclos.impl.banking.AccountTypeServiceLocal
    import org.cyclos.model.banking.accounttypes.AccountTypeNature
    import org.cyclos.model.system.fields.DynamicFieldValueVO
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def loadUserAccountTypes() {
        def variables = binding.variables
        def accountTypeService = variables.accountTypeService as AccountTypeServiceLocal
        return accountTypeService
                .listAllAccessible()
                .stream()
                .filter { it.getNature() == AccountTypeNature.USER }
                .map { new DynamicFieldValueVO(String.valueOf(it.id), it.name) }
                .collect(Collectors.toList())
    }
    
    loadUserAccountTypes()
    

4.4.5.2. Create the script to load user groups

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: User groups loader
  • Type: Load custom field values
  • Script code that returns the possible values when either creating or editing an entity:
    import java.util.stream.Collectors
    
    import org.cyclos.impl.access.SessionData
    import org.cyclos.impl.users.GroupsHandler
    import org.cyclos.model.system.fields.DynamicFieldValueVO
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def loadUserGroups() {
        def variables = binding.variables
        def groupsHandler = variables.groupsHandler as GroupsHandler
        def sessionData = variables.sessionData as SessionData
    
        return groupsHandler
                .getAccessibleUserGroups(sessionData.getLoggedUser())
                .stream()
                .map { new DynamicFieldValueVO(String.valueOf(it.id), it.name) }
                .collect(Collectors.toList())
    }
    
    loadUserGroups()
    

4.4.5.3. Create the custom operation script to search users balances

Under System > Tools > Scripts, create the next script, with the following characteristics:

  • Name: User balances
  • Type: Custom operation
  • Run with all permissions: yes
  • Script code executed when the custom operation is executed:
    import java.util.stream.Collectors
    
    import org.cyclos.entities.banking.Account
    import org.cyclos.entities.banking.QAccount
    import org.cyclos.entities.system.ExportFormat
    import org.cyclos.entities.users.QGroup
    import org.cyclos.entities.users.QUser
    import org.cyclos.entities.users.UserCustomField
    import org.cyclos.impl.InvocationContext
    import org.cyclos.impl.access.SessionData
    import org.cyclos.impl.banking.AccountServiceLocal
    import org.cyclos.impl.contentmanagement.TranslationHandler
    import org.cyclos.impl.system.ScriptHelper
    import org.cyclos.impl.users.UserCustomFieldServiceLocal
    import org.cyclos.impl.utils.formatting.FormatterImpl
    import org.cyclos.impl.utils.persistence.EntityManagerHandler
    import org.cyclos.model.banking.BankingKeys
    import org.cyclos.model.system.fields.DynamicFieldValueVO
    import org.cyclos.model.users.UsersKeys
    import org.cyclos.server.utils.DateHelper
    
    import com.querydsl.core.types.Expression
    import com.querydsl.core.types.dsl.Expressions
    
    import groovy.transform.TypeChecked
    
    @TypeChecked
    def searchBalances(){
        def variables = binding.variables
        def entityManagerHandler = variables.entityManagerHandler as EntityManagerHandler
        def translationHandler = variables.translationHandler as TranslationHandler
        def accountService = variables.accountService as AccountServiceLocal
        def formatter = variables.formatter as FormatterImpl
        def exportFormat = variables.exportFormat as ExportFormat
        def userCustomFieldService = variables.userCustomFieldService as UserCustomFieldServiceLocal
        def scriptHelper = variables.scriptHelper as ScriptHelper
    
        def formParameters = variables.formParameters as Map<String, Object>
        def sessionData = variables.sessionData as SessionData
    
        def a = QAccount.account
        def u = QUser.user
        def g = QGroup.group
    
        def query = entityManagerHandler
                .from(a)
                .innerJoin(u).on(a.user.eq(u))
                .innerJoin(g).on(u.group.eq(g))
    
        def group = (formParameters.group as DynamicFieldValueVO)?.value
        if (group) {
            query.where(g.id.eq(Long.valueOf(group)))
        }
    
        def accountType = (formParameters.accountType as DynamicFieldValueVO)?.value
        if (accountType) {
            query.where(a.type().id.eq(Long.valueOf(accountType)))
        }
    
        def currentPage = variables.currentPage as int
        def pageSize = variables.pageSize as int
        query
                .limit(pageSize)
                .offset(pageSize * currentPage)
                .orderBy(u.name.asc(), a.type().name.asc())
    
        def totalCount = variables.skipTotalCount ? null : query.fetchCount()
    
        def balanceExpression
        def expressions = [
            a.id,
            a.user().displayForManagers,
            a.type().name,
            a.type().currencyId
        ] as List<Expression>
        def date = formParameters.date as Date
        if (date) {
            query.where(a.creationDate.before(date))
            def timeZone = sessionData.getConfiguration().getTimeZone()
            date = DateHelper.shiftToEnd(date, timeZone)
            balanceExpression = a.balance(Expressions.constant(date))
            expressions << balanceExpression
        }
    
        List<UserCustomField> customFields = []
        if (exportFormat && exportFormat.internalName != 'pdf') {
            // When exporting tabular data, include the profile fields
            customFields = userCustomFieldService.listAll().findAll() { it.includeInExport }
        }
    
        def cacheFlusher = InvocationContext.newCacheFlusher()
        def rows = query.stream(expressions as Expression[]).map {
            def result = [
                id: it.get(a.id),
                display: it.get(a.user().displayForManagers),
                accountType: it.get(a.type().name),
                currency: it.get(a.type().currencyId)
            ] as Map<String, Object>
            if (date == null) {
                // We need to fetch the available balance
                def account = entityManagerHandler.find(Account, it.get(a.id))
                def status = accountService.getAccountStatus(account, null, null)
                result.balance = status.balance
                result.availableBalance = status.availableBalance
            } else {
                result.balance = it.get(balanceExpression)
            }
            if (!customFields.empty) {
                def account = entityManagerHandler.find(Account, it.get(a.id))
                def fields = scriptHelper.wrap(account.owner, customFields)
                customFields.each {
                    def value = fields[it.internalName]
                    result[it.internalName] = value instanceof Date ?
                            formatter.formatAsDate(value) : formatter.format(value)
                }
            }
            cacheFlusher.flush()
            return result
        }.collect(Collectors.toList())
    
        def columns = [
            [
                header: translationHandler.message(UsersKeys.Users.USER),
                property: "display"
            ],
            [
                header: translationHandler.message(BankingKeys.Accounts.TYPE),
                property: "accountType"
            ],
            [
                header: translationHandler.message(BankingKeys.Accounts.BALANCE),
                property: "balance",
                currencyProperty: "currency",
                align: "right"
            ]
        ]
        if (date == null) {
            columns << [
                header: translationHandler.message(BankingKeys.Accounts.AVAILABLE_BALANCE),
                property: "availableBalance",
                currencyProperty: "currency",
                align: "right"
            ]
        }
        customFields.each {
            columns << [ header: it.name, property: it.internalName ]
        }
        return [
            columns: columns,
            rows: rows,
            totalCount: totalCount,
            currentPage: currentPage
        ]
    }
    
    searchBalances()
    

4.4.5.4. Create the custom operation to search users balances

Under System > Tools > Custom operations, create a new one with the following characteristics:

  • Name: User balances (can be changed - will be the label displayed on the menu)
  • Internal name: userBalances
  • Enable for channels: Main, Web services, Mobile app
  • Scope: System
  • Script: User balances
  • Result type: Result page
  • Search automatically on page load: no
  • Allow printing results: yes
  • Allow exporting results to CSV: yes
  • Action when clicking a row: Navigate to a Cyclos location
  • Location: user_profile
  • Parameters to be passed (comma-separated names): id
  • Main menu: Banking (can be changed - will be the menu where the label is going to be displayed)

Once saved, on the Form fields tab, create three new fields, with the following characteristics:

Account type:

  • Display name: Account type (can be changed as desired)
  • Internal name: accountType (must be exactly like this)
  • Data type: Dynamic selection
  • All selected label: All (can be changed as desired)
  • Load values script: User account types loader

Group:

  • Display name: Group (can be changed as desired)
  • Internal name: group (must be exactly like this)
  • Data type: Dynamic selection
  • All selected label: All (can be changed as desired)
  • Load values script: User groups loader

Date:

  • Display name: Date (can be changed as desired)
  • Internal name: date (must be exactly like this)
  • Data type: Date

4.4.5.5. Enable the custom operation

Enable this operation in the administrators group, in the Permissions tab, under 'Run system custom operations'.