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

3.3.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, 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:

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

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

3.3.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
  • Editable: yes

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
  • Token expiration
    • Internal name: tokenExpiration
    • Data type: Date

3.3.1.4. Create an 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
  • Editable: checked

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

3.3.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 groovyx.net.http.HTTPBuilder
    
    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.system.ScriptHelper
    import org.cyclos.impl.users.RecordServiceLocal
    import org.cyclos.impl.utils.IdMask
    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.users.records.RecordDataParams
    import org.cyclos.model.users.records.UserRecordDTO
    
    /**
     * 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)
     */
    class PayPalAuth {
        String recordTypeName
        String clientIdName
        String clientSecretName
        String tokenName
        String tokenExpirationName
    
        SystemRecordType recordType
        SystemRecord record
        Map<String, Object> wrapped
    
        public PayPalAuth(Object binding) {
            def params = binding.scriptParameters
            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 = binding.entityManagerHandler
                    .find(SystemRecordType, recordTypeName)
    
            // Should return the existing instance, of a single form type.
            // Otherwise it would be an error
            record = binding.recordService.newEntity(
                    new RecordDataParams(recordTypeId: recordType.id))
            if (!record.persistent) throw new IllegalStateException(
                "No instance of system record ${recordType.name} was found")
            wrapped = binding.scriptHelper.wrap(record, recordType.fields)
        }
    
        public String getClientId() {
            wrapped[clientIdName]
        }
        public void setClientId(String clientId) {
            wrapped[clientIdName] = clientId
        }
        public String getClientSecret() {
            wrapped[clientSecretName]
        }
        public void setClientSecret(String clientSecret) {
            wrapped[clientSecretName] = clientSecret
        }
        public String getToken() {
            wrapped[tokenName]
        }
        public void setToken(String token) {
            wrapped[tokenName] = token
        }
        public Date getTokenExpiration() {
            wrapped[tokenExpirationName]
        }
        public void setTokenExpiration(Date tokenExpiration) {
            wrapped[tokenExpirationName] = tokenExpiration
        }
    }
    
    /**
     * Class used to store / retrieve PayPal payments as user records in Cyclos
     */
    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(Object binding) {
            def params = binding.scriptParameters
            recordTypeName = params.'payment.recordType' ?: 'paypalPayment'
            paymentIdName = params.'payment.paymentId' ?: 'paymentId'
            amountName = params.'payment.amount' ?: 'amount'
            transactionName = params.'payment.transaction' ?: 'transaction'
    
            entityManagerHandler = binding.entityManagerHandler
            recordService = binding.recordService
            scriptHelper = binding.scriptHelper
            recordType = binding.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(
                    [userId: user.id, recordTypeId: recordType.id])
            UserRecordDTO dto = recordService.getDataForNew(newParams).getDTO()
            Map<String, Object> 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
     */
    class PayPalService {
        String mode
        String baseUrl
        String currency
        String paymentDescription
    
        String accountTypeName
        String paymentTypeName
        double multiplier
    
        SystemAccountType accountType
        PaymentTransferType paymentType
    
        private ScriptHelper scriptHelper
        private PaymentServiceLocal paymentService
        private IdMask idMask
        private PayPalAuth auth
        private PayPalRecord record
    
        public PayPalService(
        Object binding, PayPalAuth auth, PayPalRecord record) {
    
            this.auth = auth
            this.record = record
    
            scriptHelper = binding.scriptHelper
            paymentService = binding.paymentService
            idMask = binding.applicationHandler.idMask
    
            def params = binding.scriptParameters
    
            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'")
            }
    
            EntityManagerHandler emh = binding.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 ?: "1")
            paymentDescription = params.paymentDescription ?: ""
        }
    
        /**
         * Creates a payment in PayPal and the corresponding user record
         */
        public Object createPayment(User user, Number amount, String callbackUrl) {
    
            // Create the UserRecord for this payment
            UserRecord userRecord = record.create(user, amount)
    
            Long maskedId = idMask.apply(userRecord.id)
            String returnUrl = "${callbackUrl}?succes=true&recordId=${maskedId}"
            String cancelUrl = "${callbackUrl}?cancel=true&recordId=${maskedId}"
    
            def jsonBody = [
                intent: "sale",
                redirect_urls: [
                    return_url: returnUrl,
                    cancel_url: cancelUrl
                ],
                payer: [
                    payment_method: "paypal"
                ],
                transactions: [
                    [
                        description: paymentDescription,
                        amount: [
                            total: amount,
                            currency: currency
                        ]
                    ]
                ]
            ]
            // Create the payment in PayPal
            Object json = postJson("${baseUrl}/v1/payments/payment", jsonBody)
    
            // Update the payment id
            def wrapped = scriptHelper.wrap(userRecord)
            wrapped[record.paymentIdName] = json.id
    
            return json
        }
    
        /**
         * Executes a PayPal payment, and creates the payment in Cyclos
         */
        public Object execute(String payerId, UserRecord userRecord) {
            Object wrapped = scriptHelper.wrap(userRecord)
            String paymentId = wrapped[record.paymentIdName]
            BigDecimal amount = wrapped[record.amountName]
            BigDecimal finalAmount = amount * multiplier
    
            // Execute the payment in PayPal
            Object json = postJson(
                    "${baseUrl}/v1/payments/payment/${paymentId}/execute",
                    [payer_id: payerId])
    
            if (json.state == 'approved') {
                // Perform the payment in Cyclos
                PerformPaymentDTO dto = new PerformPaymentDTO()
                dto.from = SystemAccountOwner.instance()
                dto.to = userRecord.user
                dto.amount = finalAmount
                dto.type = new TransferTypeVO(paymentType.id)
                PaymentVO vo = paymentService.perform(dto)
    
                // Update the record, setting the linked transaction
                wrapped[record.transactionName] = vo
                userRecord.lastModifiedDate = new Date()
            }
            return json
        }
    
        /**
         * Performs a synchronous request, posting and accepting JSON
         */
        private postJson(url, jsonBody) {
            def http = new HTTPBuilder(url)
            CountDownLatch latch = new CountDownLatch(1)
            def 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(POST, JSON) {
                headers.'Authorization' = "Bearer ${auth.token}"
    
                body = jsonBody
    
                response.success = { resp, json ->
                    responseJson = json
                    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
         */
        private void refreshToken() {
            def http = new HTTPBuilder("${baseUrl}/v1/oauth2/token")
    
            CountDownLatch latch = new CountDownLatch(1)
            def 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
                    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 - 30) * 1000))
        }
    }
    
    // Instantiate the objects
    PayPalAuth auth = new PayPalAuth(binding)
    PayPalRecord record = new PayPalRecord(binding)
    PayPalService paypal = new PayPalService(binding, auth, record)
    

3.3.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
  • Included libraries: PayPal
  • Parameters: leave empty
  • Script code executed when the custom operation is executed:
    def result = paypal.createPayment(user, formParameters.amount, returnUrl)
    
    def link = result.links.find {it.rel == "approval_url"}
    if (link) {
    	return link.href + "&useraction=commit"
    } else {
        throw new IllegalStateException("No approval url returned from PayPal")
    }
  • Script code executed when the external site redirects the user back to Cyclos:
    import org.cyclos.entities.users.UserRecord
    
    def recordId = request.parameters.recordId as Long
    def payerId = request.parameters.PayerID
    
    // No record?
    if (recordId == null) {
        return "[ERROR] " +
        (scriptParameters.'error.invalidRequest' ?: "Invalid request")
    }
    
    // Find the corresponding record
    UserRecord userRecord = record.find(applicationHandler.idMask.remove(recordId))
    if (userRecord == null) {
        return "[ERROR] " +
        (scriptParameters.'error.transactionNotFound' ?: "Transaction not found")
    }
    def wrapped = scriptHelper.wrap(userRecord)
    
    if (request.parameters.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 json = paypal.execute(payerId, userRecord)
            if (json.state == 'approved') {
                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."
        }
    }
    

3.3.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 field:

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

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

3.3.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)
  • Default description: Units bought using PayPal (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
  • Enabled: yes

3.3.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, make the Paypal authentication record visible and editable
  • In User data > User records, make the Paypal payment visible only (not editable, as it is not meant to be manually edited)
  • Save the permissions

3.3.1.11. Setup the PayPal credentials

Click Reports & data > 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.

3.3.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. From this moment, the operation will show up for users in the banking menu. Also on the Records enable the PayPal payment record.

3.3.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.
  • 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.

3.3.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, there could be an inconsistency between the Cyclos account an the PayPal payment. Improvements could be done to the script, to handle the case where the Cyclos payment failed. To do this, the ScriptHelper.addOnRollbackTransactional method can be used, for example, to notify some specific administrator or to refund the PayPal payment. But this handling is outside the scope of this example.

3.3.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 an 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.
  • 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.

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

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

3.3.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 (can be changed as desired, but this name is used in the example configuration)

After saving, create the following statuses:

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

3.3.2.3. Create the payment custom fields

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

  • 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: yes
  • 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

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

3.3.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 custom field named Repayment.

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

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

3.3.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 = installments
    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
  • 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.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.PaymentVO
    import org.cyclos.model.banking.transactions.PerformPaymentDTO
    import org.cyclos.model.banking.transactions.PerformScheduledPaymentDTO
    import org.cyclos.model.banking.transactions.ScheduledPaymentInstallmentDTO
    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.server.utils.DateHelper
    import org.cyclos.utils.BigDecimalHelper
    
    
    class Loan {
        Map<String, Object> config
        EntityManagerHandler entityManagerHandler
        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) {
            config = [:]
            def params = binding.scriptParameters
            [
                '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'
            ].each { k, v ->
                def value = params[k] ?: v
                config[k] = value
            }
            entityManagerHandler = binding.entityManagerHandler
            paymentService = binding.paymentService
            scheduledPaymentService = binding.scheduledPaymentService
            transferStatusService = binding.transferStatusService
            scriptHelper = binding.scriptHelper
            configuration = binding.sessionData.configuration
    
            systemAccount = entityManagerHandler.find(
                    SystemAccountType, config.'loan.account')
            if (systemAccount.currency.transactionNumber == null
            || !systemAccount.currency.transactionNumber.used) {
                throw new IllegalStateException(
                "The currency ${systemAccount.currency.name} doesn't "
                + "have transaction number enabled")
            }
            loanType = entityManagerHandler.find(
                    PaymentTransferType, config.'loan.type', systemAccount)
            userAccount = entityManagerHandler.find(
                    UserAccountType, config.'repayment.account')
            repaymentType = entityManagerHandler.find(
                    PaymentTransferType, config.'repayment.type', userAccount)
            if (!repaymentType.allowsScheduledPayments) {
                throw new IllegalStateException("The repayment type " +
                "${repaymentType.name} doesn't allows scheduled payment")
            }
            loanField = entityManagerHandler.find(
                    TransactionCustomField, config.'field.loan')
            repaymentField = entityManagerHandler.find(
                    TransactionCustomField, config.'field.repayment')
            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 = entityManagerHandler.find(
                    TransferStatusFlow, config.'status.flow')
            open = entityManagerHandler.find(
                    TransferStatus, config.'status.open', flow)
            closed = entityManagerHandler.find(
                    TransferStatus, config.'status.closed', flow)
            monthlyInterestRate = config.monthlyInterestRate?.toDouble() ?: 0
        }
    
        def BigDecimal calculateInstallmentAmount(BigDecimal amount,
                int installments, Date grantDate, Date firstInstallmentDate) {
    
            // Calculate the delay
            Date shouldBeFirstExpiration = grantDate + 30
            int delay = 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)
        }
    
        def grant(User user, formParameters) {
            BigDecimal loanAmount = formParameters[config.'operation.amount']
            int installments = formParameters[config.'operation.installments']
            Date firstDueDate = formParameters[config.'operation.firstDueDate']
            Date minDate = DateHelper.shiftToNextDay(
                    new Date(), configuration.timeZone)
            if (installments < 1 || installments > repaymentType.maxInstallments)
                throw new ValidationException(config.'message.invalidInstallments')
            if (loanAmount < 1)
                throw new ValidationException(config.'message.invalidLoanAmount')
            if (firstDueDate < minDate)
                throw new ValidationException(config.'message.invalidFirstDueDate')
    
            // Grant the loan
            PaymentVO loanVO = paymentService.perform(new PerformPaymentDTO([
                from: SystemAccountOwner.instance(),
                to: user,
                type: new TransferTypeVO(loanType.id),
                amount: loanAmount,
                description: config.'loan.description'
            ]))
            Payment loan = entityManagerHandler.find(Payment, loanVO.id)
    
            // Ensure the initial status is correct
            Transfer loanTransfer = loan.transfer
            if (loanTransfer == null) {
                throw new IllegalStateException(
                "The loan was not processed (probably pending authorization)")
            }
            TransferStatus currentStatus = loanTransfer.getStatus(flow)
            if (currentStatus != open) {
                throw new IllegalStateException(
                "The initial status for flow ${flow.name} in ${loanType.name} "
                + "is not the expected one: ${open.name}, "
                + "but ${currentStatus} instead")
            }
    
            // Perform the repayment scheduled payment
            PerformScheduledPaymentDTO dto = new PerformScheduledPaymentDTO()
            def bean = scriptHelper.wrap(dto, [loanField])
            bean.from = user
            bean.to = SystemAccountOwner.instance()
            bean.type = repaymentType
            bean.amount = loanAmount
            bean.description = config.'repayment.description'
            bean.installmentsCount = installments
            bean.firstInstallmentDate = firstDueDate
            bean[loanField.internalName] = loan
    
            // 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 ScheduledPaymentInstallmentDTO()
                    def instBean = scriptHelper.wrap(installment)
                    instBean.dueDate = dueDate
                    instBean.amount = installmentAmount
                    dto.installments << installment
                    dueDate += 30
                }
                bean.amount = installmentAmount * installments
            }
    
            ScheduledPaymentVO repaymentVO = scheduledPaymentService.perform(dto)
            ScheduledPayment repayment = entityManagerHandler.find(
                    ScheduledPayment, repaymentVO.id)
    
            // Update the loan with the repayment link
            bean = scriptHelper.wrap(loan, [repaymentField])
            bean[repaymentField.internalName] = repayment
        }
    
        def close(ScheduledPayment scheduledPayment) {
            Payment loan = scriptHelper.wrap(scheduledPayment)
            [loanField.internalName]
            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)
                ]))
            }
        }
    }
    
    Loan loan = new Loan(binding)

3.3.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
  • Parameters: leave empty
  • Script code executed when the custom operation is executed:
    loan.grant(user, formParameters)
    return loan.config.'message.loanGranted'

3.3.2.10. Create the extension point script

Create a new script for the transaction extension point, with the following characteristics:

  • Name: Loan closing
  • Type: Extension point
  • Included libraries: Loan
  • Parameters: leave empty
  • Script code executed when the data is saved:
    import org.cyclos.model.ValidationException
    import org.cyclos.model.banking.transactions.ScheduledPaymentStatus
    
    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
        loan.close(transaction)
    }
    

3.3.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: installments
    • Data type: Integer
    • Required: yes
  • First due date
    • Internal name: firstDueDate
    • Data type: Date
    • Required: yes

3.3.2.12. Create the extension point

Under System > Tools > Extension points, create a new of type Transaction, with the following characteristics:

  • 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

3.3.2.13. Grant the administrator permissions

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

  • Under User management > Run custom operations over users, check the Grant loan operation and save
  • Under Accounts > Transfer status flows, make Loan visible, but not editable.

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