License
Copyright © 2015-2026 Apache Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Preface
Apache Fineract
Website: fineract.apache.org
Email: dev@fineract.apache.org
Version: 1.15.0-SNAPSHOT
Built on: Tue Jun 02 14:42:42 IST 2026
For document authors and changelog, view code history for the fineract-doc directory in github.com/apache/fineract/.
Deployment
Plugins
Apache Fineract is extensible through plugin JARs (FINERACT-1177; based on
Spring Boot’s support). To launch Fineract with plugin JARs in libs/*.jar, use:
java -Dloader.path=libs/ -jar fineract-provider.jar
The Fineract "Docker" container image’s ENTRYPOINT uses this, see our Dockerfile. You could therefore build your customized Fineract distribution container image with your own Dockerfile using e.g. FROM apache/fineract:latest and then drop some plugin JARs into /app/libs/.
The WAR distribution does not directly support such plugins, but one could "explode" the WAR and drop JARs into WEB-INF/lib; if you know what you are doing, and feel nostalgic of the 1990s still using WARs, instead of the recommended modern Spring Boot distribution.
Here is a list of known 3rd-party plugin projects which can be dropped into libs/:
| The reporting module became our first module experiment out of necessity. We are currently developing a strategy to split up even more internals of Fineract into proper modules. Those that have an incompatible license will be hosted in a separate Git repository (probably on Github under the Mifos organisation). We’ll send out an announcement as soon as we have more to say on this topic. |
HTTPS
Because Apache Fineract deals with customer sensitive personally identifiable information (PII), it very strongly encourages all developers, implementors and end-users to always only use HTTPS. This is why it does not run on HTTP even for local development and enforces use of HTTPS by default.
For this purpose, Fineract includes a built-in default SSL certificate. This cert is intended for development on localhost, only. It is not trusted by your browser (because it’s self signed).
For production deployments, we recommend running Fineract behind a modern managed cloud native web proxy which includes SSL termination with automatically rotating SSL certificates, either using your favourite cloud provider’s respective solution, or locally setting up the equivalent using e.g. something like NGINX combined with Let’s Encrypt.
Such products, when correctly configured, add the conventional X-Forwarded-For and X-Forwarded-Proto HTTP headers, which Fineract (or rather the Spring Framework really) correctly respects since FINERACT-914 was fixed.
Alternatively, you could replace the built-in default SSL certificate with one you obtained from a Certificate Authority. We currently do not document how to do this, because we do not recommend this approach, as it’s cumbersome to configure and support and less secure than a managed auto rotating solution.
The Fineract API client supports an insecure mode (FineractClient.Builder#insecure()), and API users such as mobile apps may expose Settings to let end-users accept the self signed certificate. This should always be used for testing only, never in production.
All SSL-related properties are tunable. SSL can be turned off by setting the environment variable FINERACT_SERVER_SSL_ENABLED to false. If you do that then please make sure to also change the server port to 8080 via the variable FINERACT_SERVER_PORT, just for the sake of keeping the conventions.
To use a different SSL keystore, set FINERACT_SERVER_SSL_KEY_STORE to a path to a different (not embedded) keystore. The password can be set via FINERACT_SERVER_SSL_KEY_STORE_PASSWORD. See the application.properties file and the latest Spring Boot documentation for details.
Docker Compose
TBD
Fineract Instance types
In cases where Fineract has to deal with high load, it can cause a performance problem for a single Fineract instance.
To overcome this problem, Fineract instances can be started in different instance types for better scalability and performance in a multi-instance environment:
-
Read instance
-
Write instance
-
Batch instance
Each instance type comes with different restrictions. The specifics can be found in the table below.
| Read instance | Write instance | Batch instance | |
|---|---|---|---|
Using only read-only DB connection |
Yes |
No |
No |
Batch jobs are automatically scheduled or startable via API |
No |
No |
Yes |
Can receive events (business events, hook template events) |
No |
Yes |
No |
Can send events (business events, hook template events) |
No |
Yes |
Yes |
Read APIs supported |
Yes |
Yes |
No |
Write APIs supported |
No |
Yes |
No |
Batch job APIs supported |
No |
No |
Yes |
Liquibase migration initiated upon startup |
No |
Yes |
No |
Configuring instance types in single instance setup
If Fineract is running as a single instance, then all of the 3 instance types should be enabled. In this case, there is no need to worry about the configuration, because this is the default behavior.
Configuring instance types in multi-instance setup
A common solution to dealing with the high load is to deploy 1 write and 1 batch instances and deploy multiple read instances with read replicas of the Fineract database.
In this case, the write instance and the database will be freed from part of the load, because read request will use the separated read instance and its read replica database.
Also a common scenario when Close of Business jobs are running and Fineract has to deal with a high amount of processes.
(In a future release) Fineract (will be) is able to run this CoB jobs in batches.
In multi-instances environment these CoB jobs can run on multiple batch instances and they don’t have any impact on the performance of the read and write processes.
The best practice is to deploy 1 master batch instance and multiple worker batch instances.
These solutions can be mixed with each other, based on the load of the Fineract deployment.
Configuring instance type via environment variables
The Fineract instance type is configurable via environment variables for the following 3 values:
| Instance type | Environment variable |
|---|---|
Read instance |
FINERACT_READ_MODE_ENABLED |
Write instance |
FINERACT_WRITE_MODE_ENABLED |
Batch instance |
FINERACT_BATCH_MODE_ENABLED |
The environment variable values are booleans (true/false). The Fineract instance can be configured in any combination of these instance types, although if all 3 configurations are false, startup will fail. The default value for all 3 values is true.
The configured Fineract instance types are easily accessible via a single Spring bean, named FineractProperties.FineractModeProperties that has 4 methods: isReadMode(), isWriteMode(), isBatchMode(), isReadOnlyMode()
Liquibase Database Migration
Liquibase data migration is allowed only for write instances
APIs
Read APIs are allowed only for read and write instances
A Fineract instance is ONLY able to serve read API calls when it’s configured as a read or write instance. In batch instance mode, it won’t serve read API calls.
If it’s a read or write instance, the read APIs will be served.
If it’s a batch instance, the read APIs won’t be served and a proper HTTP status code will be returned.
The distinction whether something is a read API can be decided based on the HTTP request method. If it’s a GET, we can assume it’s a read call.
Write APIs are allowed only for write instances
A Fineract instance is ONLY able to serve write API calls when it’s configured as a write instance. In read or batch instance mode, it won’t serve write API calls.
If the write APIs won’t be served and a proper HTTP status code will be returned.
If it’s a write instance, the write APIs will be served except the ones related to batch jobs.
The distinction whether something is a write API can be decided based on the HTTP request method. If it’s non-GET, we can assume it’s a write call. Also, the write APIs related to batch jobs (starting/stopping jobs) will not be served either.
Batch job APIs are allowed only for batch instances
A Fineract instance is ONLY able to serve batch API calls when it’s configured as a batch instance. In read or write instance mode, it won’t serve batch API calls.
If the batch APIs won’t be served and a proper HTTP status code will be returned.
If it’s a batch instance, the batch APIs will be served.
Batch jobs
Batch job scheduling is allowed only for batch instances
Batch jobs are scheduled only if the Fineract instance running as a batch instance
Read-only instance type restrictions
If the read mode is enabled, but the write mode and batch mode are disabled, Fineract instance runs in read-only mode.
Events are disabled for read-only instances
When a Fineract instance is running in read-only mode, all event receiving/sending will be disabled.
Read-only tenant connection support
With read separation, there’s a possibility to use read-only database connections for read-only instances.
If the instance is read-only , the DataSource connection used for the tenant will be read-only.
If the instance is read-only and the configuration for the read-only datasource is not set, the application startup will fail.
Batch-only instance type restrictions
If the batch mode is enabled, but the read mode and write mode are disabled, Fineract instance runs in batch-only mode.
Receiving events is disabled for batch-only instances
When a Fineract instance is running as batch, event receiving will be disabled while sending events will be still possible since the batch jobs are potentially generating business events.
Kubernetes
In a scaled Kubernetes environment where multiple Fineract instances are deployed, doing the database migrations properly is essential.
Fineract provides a way to run only the Liquibase migrations instead of starting up the whole application server so that you can easily do the migrations before actually upgrading a Fineract instance.
The FINERACT_LIQUIBASE_ENABLED flag controls whether Liquibase is enabled or not. For regular read/write/batch manager/batch worker instances this should be disabled.
There’s a special Spring profile that should be enabled for running Liquibase only. In can be done via SPRING_PROFILES_ACTIVE environment variable. The profile name is liquibase-only. At the end of the migration process, the application will exit.
For the instance running the Liquibase migrations, the profile should be activated.
AWS
TBD
Google Cloud
The www.fineract.dev demo server runs on Google Cloud.
The Running Fineract.dev, SRE style presentation given at ApacheCon 2020 has some related background.
Apache Software Foundation Infrastructure
We can order a server from Apache’s infrastructure team and deploy a demo instance…
TBD
Architecture
This document captures the major architectural decisions in platform. The purpose of the document is to provide a guide to the overall structure of the platform; where it fits in the overall context of an MIS solution and its internals so that contributors can more effectively understand how changes that they are considering can be made, and the consequences of those changes.
The target audience for this report is both system integrators (who will use the document to gain an understanding of the structure of the platform and its design rationale) and platform contributors who will use the document to reason about future changes and who will update the document as the system evolves.
History
The Idea
Fineract was an idea born out of a wish to create and deploy technology that allows the microfinance industry to scale. The goal is to:
-
Produce a gold standard management information system suitable for microfinance operations
-
Acts as the basis of a platform for microfinance
-
Open source, owned and driven by member organisations in the community
-
Enabling potential for eco-system of providers located near to MFIs
Timeline
-
2006: Project initiated by Grameen Foundation
-
Late 2011: Grameen Foundation handed over full responsibility to open source community.
-
2012: Mifos X platform started. Previous members of project come together under the name of Community for Open Source Microfinance (COSM / OpenMF)
-
2013: COSM / OpenMF officially rebranded to Mifos Initiative and receive US 501c3 status.
-
2016: Fineract 1.x began incubation at Apache
Resources
-
Project URL is github.com/apache/fineract
-
Issue tracker is issues.apache.org/jira/projects/FINERACT/summary
-
Download from fineract.apache.org/
System Overview
Financial institutions deliver their services to customers through a variety of means today.
-
Customers can call direct into branches (teller model)
-
Customers can organise into groups (or centers) and agree to meetup at a location and time with FI staff (traditional microfinance).
-
An FI might have a public facing information portal that customers can use for variety of reasons including account management (online banking).
-
An FI might be integrated into a ATM/POS/Card services network that the customer can use.
-
An FI might be integrated with a mobile money operator and support mobile money services for customer (present/future microfinance).
-
An FI might use third party agents to sell on products/services from other banks/FIs.
As illustrated in the above diagram, the various stakeholders leverage business apps to perform specific customer or FI related actions. The functionality contained in these business apps can be bundled up and packaged in any way. In the diagram, several of the apps may be combined into one app or any one of the blocks representing an app could be further broken up as needed.
The platform is the core engine of the MIS. It hides a lot of the complexity that exists in the business and technical domains needed for an MIS in FIs behind a relatively simple API. It is this API that frees up app developers to innovate and produce apps that can be as general or as bespoke as FIs need them to be.
Functional Overview
As ALL capabilities of the platform are exposed through an API, The API docs are the best place to view a detailed breakdown of what the platform does. See online API Documentation.
At a higher level though we see the capabilities fall into the following categories:
-
Infrastructure
-
Codes
-
Extensible Data Tables
-
Reporting
-
-
User Administration
-
Users
-
Roles
-
Permissions
-
-
Organisation Modelling
-
Offices
-
Staff
-
Currency
-
-
Product Configuration
-
Charges
-
Loan Products
-
Deposit Products
-
-
Client Data
-
Know Your Client (KYC)
-
-
Portfolio Management
-
Loan Accounts
-
Deposit Accounts
-
Client/Groups
-
-
GL Account Management
-
Chart of Accounts
-
General Ledger
-
Principles
RESTful API
The platform exposes all its functionality via a practically-RESTful API, that communicates using JSON.
We use the term practically-RESTful in order to make it clear we are not trying to be fully REST compliant but still maintain important RESTful attributes like:
-
Stateless: platform maintains no conversational or session-based state. The result of this is ability to scale horizontally with ease.
-
Resource-oriented: API is focussed around set of resources using HTTP vocabulary and conventions e.g GET, PUT, POST, DELETE, HTTP status codes. This results in a simple and consistent API for clients.
See online API Documentation for more detail.
Multi-tenanted
The Fineract platform has been developed with support for multi-tenancy at the core of its design. This means that it is just as easy to use the platform for Software-as-a-Service (SaaS) type offerings as it is for local installations.
The platform uses an approach that isolates an FIs data per database/schema (See Separate Databases and Shared Database, Separate Schemas).
Extensible
Whilst each tenant will have a set of core tables, the platform tables can be extended in different ways for each tenant through the use of Data tables functionality.
Command Query Separation
We separate commands (that change data) from queries (that read data).
Why? There are numerous reasons for choosing this approach which at present is not an attempt at full blown CQRS. The main advantages at present are:
-
State changing commands are persisted providing an audit of all state changes.
-
Used to support a general approach to maker-checker.
-
State changing commands use the Object-Oriented paradigm (and hence ORM) whilst querys can stay in the data paradigm.
Maker-Checker
Also known as four-eyes principal. Enables apps to support a maker-checker style workflow process. Commands that pass validation will be persisted. Maker-checker can be enabled/disabled at fine-grained level for any state changing API.
Fine grained access control
A fine grained permission is associated with each API. Administrators have fine grained control over what roles or users have access to.
Package Structure
The intention is for platform code to be packaged in a vertical slice way (as opposed to layers).
Source code starts from github.com/apache/fineract/tree/develop/fineract-provider/src/main/java/org/apache/fineract
-
accounting
-
useradministration
-
infrastructure
-
portfolio
-
charge
-
client
-
fund
-
loanaccount
-
-
accounting
Within each vertical slice is some common packaging structure:
-
api - XXXApiResource.java - REST api implementation files
-
handler - XXXCommandHandler.java - specific handlers invoked
-
service - contains read + write services for functional area
-
domain - OO concepts for the functional area
-
data - Data concepts for the area
-
serialization - ability to convert from/to API JSON for functional area
Design Overview
| The implementation of the platform code to process commands through handlers whilst supporting maker-checker and authorisation checks is a little bit convoluted at present and is an area pin-pointed for clean up to make it easier to on board new platform developers. In the mean time below content is used to explain its workings at present. |
Taking into account example shown above for the users resource.
-
Query: GET /users
-
HTTPS API: retrieveAll method on org.apache.fineract.useradministration.api.UsersApiResource invoked
-
UsersApiResource.retrieveAll: Check user has permission to access this resources data.
-
UsersApiResource.retrieveAll: Use 'read service' to fetch all users data ('read services' execute simple SQL queries against Database using JDBC)
-
UsersApiResource.retrieveAll: Data returned to converted into JSON response
-
Command: POST /users (Note: data passed in request body)
-
HTTPS API: create method on org.apache.fineract.useradministration.api.UsersApiResource invoked
return this.toApiJsonSerializer.serialize(result);
}
@PUT
@Path("{userId}")
@Operation(summary = "Update a User", operationId = "updateUser", tags = { "Users" }, description = "Updates the user")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PutUsersUserIdRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PutUsersUserIdResponse.class))) })
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public String update(@PathParam("userId") @Parameter(description = "userId") final Long userId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateUser(userId) //
.withJson(apiRequestBodyAsJson) //
.build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
// permission to perform specific task.
this.context.authenticatedUser(wrapper).validateHasPermissionTo(wrapper.getTaskPermissionName());
}
validateIsUpdateAllowed();
final String json = wrapper.getJson();
final JsonElement parsedCommand = this.fromApiJsonHelper.parse(json);
JsonCommand command = JsonCommand.from(json, parsedCommand, this.fromApiJsonHelper, wrapper.getEntityName(), wrapper.getEntityId(),
wrapper.getSubentityId(), wrapper.getGroupId(), wrapper.getClientId(), wrapper.getLoanId(), wrapper.getSavingsId(),
wrapper.getTransactionId(), wrapper.getHref(), wrapper.getProductId(), wrapper.getCreditBureauId(),
wrapper.getOrganisationCreditBureauId(), wrapper.getJobName(), wrapper.getLoanExternalId());
return this.processAndLogCommandService.executeCommand(wrapper, command, isApprovedByChecker);
}
@Override
public CommandProcessingResult approveEntry(final Long makerCheckerId) {
final CommandSource commandSourceInput = validateMakerCheckerTransaction(makerCheckerId);
validateIsUpdateAllowed();
final CommandWrapper wrapper = CommandWrapper.fromExistingCommand(makerCheckerId, commandSourceInput.getActionName(),
commandSourceInput.getEntityName(), commandSourceInput.getResourceId(), commandSourceInput.getSubResourceId(),
commandSourceInput.getResourceGetUrl(), commandSourceInput.getProductId(), commandSourceInput.getOfficeId(),
commandSourceInput.getGroupId(), commandSourceInput.getClientId(), commandSourceInput.getLoanId(),
commandSourceInput.getSavingsId(), commandSourceInput.getTransactionId(), commandSourceInput.getCreditBureauId(),
commandSourceInput.getOrganisationCreditBureauId(), commandSourceInput.getIdempotencyKey(),
commandSourceInput.getLoanExternalId());
final JsonElement parsedCommand = this.fromApiJsonHelper.parse(commandSourceInput.getCommandAsJson());
final JsonCommand command = JsonCommand.fromExistingCommand(makerCheckerId, commandSourceInput.getCommandAsJson(), parsedCommand,
this.fromApiJsonHelper, commandSourceInput.getEntityName(), commandSourceInput.getResourceId(),
commandSourceInput.getSubResourceId(), commandSourceInput.getGroupId(), commandSourceInput.getClientId(),
commandSourceInput.getLoanId(), commandSourceInput.getSavingsId(), commandSourceInput.getTransactionId(),
commandSourceInput.getResourceGetUrl(), commandSourceInput.getProductId(), commandSourceInput.getCreditBureauId(),
commandSourceInput.getOrganisationCreditBureauId(), commandSourceInput.getJobName(),
commandSourceInput.getLoanExternalId());
return this.processAndLogCommandService.executeCommand(wrapper, command, true);
}
@Transactional
@Override
public Long deleteEntry(final Long makerCheckerId) {
validateMakerCheckerTransaction(makerCheckerId);
validateIsUpdateAllowed();
this.commandSourceRepository.deleteById(makerCheckerId);
return makerCheckerId;
}
private CommandSource validateMakerCheckerTransaction(final Long makerCheckerId) {
final CommandSource commandSource = this.commandSourceRepository.findById(makerCheckerId)
.orElseThrow(() -> new CommandNotFoundException(makerCheckerId));
if (!commandSource.isAwaitingApproval()) {
throw new CommandNotAwaitingApprovalException(makerCheckerId);
}
AppUser appUser = this.context.authenticatedUser();
String permissionCode = commandSource.getPermissionCode();
appUser.validateHasCheckerPermissionTo(permissionCode);
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
private final CommandSourceService commandSourceService;
private final RetryConfigurationAssembler retryConfigurationAssembler;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private CommandProcessingResult retryWrapper(Supplier<CommandProcessingResult> supplier) {
try {
if (!BatchRequestContextHolder.isEnclosingTransaction()) {
return retryConfigurationAssembler.getRetryConfigurationForExecuteCommand().executeSupplier(supplier);
}
return supplier.get();
} catch (RuntimeException e) {
return fallbackExecuteCommand(e);
}
}
@Override
public CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command,
final boolean isApprovedByChecker) {
return retryWrapper(() -> {
// Do not store the idempotency key because of the exception handling
setIdempotencyKeyStoreFlag(false);
Long commandId = (Long) fineractRequestContextHolder.getAttribute(COMMAND_SOURCE_ID, null);
boolean isRetry = commandId != null;
boolean isEnclosingTransaction = BatchRequestContextHolder.isEnclosingTransaction();
CommandSource commandSource = null;
String idempotencyKey;
if (isRetry) {
commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey();
} else if ((commandId = command.commandId()) != null) { // action on the command itself
commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey();
} else {
idempotencyKey = idempotencyKeyResolver.resolve(wrapper);
}
exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry);
AppUser user = context.authenticatedUser(wrapper);
if (commandSource == null) {
if (isEnclosingTransaction) {
commandSource = commandSourceService.getInitialCommandSource(wrapper, command, user, idempotencyKey);
} else {
commandSource = commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey);
commandId = commandSource.getId();
}
}
if (commandId != null) {
storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
setIdempotencyKeyStoreFlag(true);
return executeCommand(wrapper, command, isApprovedByChecker, commandSource, user, isEnclosingTransaction);
});
}
private CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command,
final boolean isApprovedByChecker, CommandSource commandSource, AppUser user, boolean isEnclosingTransaction) {
final CommandProcessingResult result;
try {
result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker);
} catch (Throwable t) { // NOSONAR
| if a RollbackTransactionAsCommandIsNotApprovedByCheckerException occurs at this point. The original transaction will of been aborted and we only log an entry for the command in the audit table setting its status as 'Pending'. |
-
Check that if maker-checker configuration enabled for this action. If yes and this is not a 'checker' approving the command - rollback at the end. We rollback at the end in order to test if the command will pass 'domain validation' which requires commit to database for full check.
-
findCommandHandler - Find the correct Handler to process this command.
-
Process command using handler (In transactional scope).
-
CommandSource object created/updated with all details for logging to 'm_portfolio_command_source' table.
-
In update scenario, we check to see if there where really any changes/updates. If so only JSON for changes is stored in audit log.
Persistence
TBD
Database support
Fineract officially supports PostgreSQL.
The platform differentiates between these database types in certain cases when there’s a need to use some database specific tooling. To do so, the platform examines the JDBC driver used for running the platform and tries to determine which database is being used.
The currently supported JDBC driver and corresponding mappings can be found below.
JDBC driver class name |
Resolved database type |
|
MySQL |
|
MySQL |
|
PostgreSQL |
The actual code can be found in the DatabaseTypeResolver class.
Tenant database security
The tenant database schema password is stored in the tenant_server_connections table in the tenant database. The password and the read only schema password are encrypted using the fineract.tenant.master-password property. By default, the database property will be encrypted in the first start from a plain text.
When you want to generate a new encrypted password, you can use the org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor class.
Database password encryption usage
java -cp fineract-provider.jar \
-Dloader.main=org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor \
org.springframework.boot.loader.PropertiesLauncher \
<masterPassword> \
<plainPassword>
For example:
java -cp fineract-provider-0.0.0-48f7e315.jar \
-Dloader.main=org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor \
org.springframework.boot.loader.PropertiesLauncher \
fineract-master-password \
fineract-tenant-password
The encrypted password: VLwGl7vOP/q275ZTku+PNGWnGwW4mzzNHSNaO9Pr67WT5/NZMpBr9tGYYiYsqwL1eRew2jl7O3/N1EFbLlXhSA==
Data-access layer
The data-access layer of Fineract is implemented by using JPA (Java Persistence API) with the EclipseLink provider.
Despite the fact that JPA is used quite extensively in the system, there are cases where the performance is a key element for an operation therefore you can easily find native SQLs as well.
The data-access layer of Fineract is compatible with different databases. Since a lot of the native queries are using specific database functions, a wrapper class - DatabaseSpecificSQLGenerator - has been introduced to handle these database specifics. Whenever there’s a need to rely on new database level functions, make sure to extend this class and implement the specific functions provided by the database.
Fineract has been developed for 10+ years by the community and unfortunately there are places where entity relationships are configured with EAGER fetching strategy. This must not confuse anybody. The long-term goal is to use the LAZY fetching strategy for every single relationship. If you’re about to introduce a new one, make sure to use LAZY as a fetching strategy, otherwise your PR will be rejected.
Database schema migration
As for every system, the database structure will and need to evolve over time. Fineract is no different. Originally for Fineract, Flyway was used until Fineract 1.6.x.
After 1.6.x, PostgreSQL support was added to the platform hence there was a need to make the data-access layer and the schema migration as database independent as possible. Because of that, from Fineract 1.7.0, Flyway is not used anymore but Liquibase is.
Some of the changesets in the Liquibase changelogs have database specifics into it but they only run for the relevant databases. This is controller by Liquibase contexts.
The currently available Liquibase contexts are:
-
mysql- only set when the database is a MySQL compatible database (e.g. MariaDB) -
postgresql- only set when the database is a PostgreSQL database -
configured Spring active profiles
-
tenant_store_db- only set when the database migration runs the Tenant Store upgrade -
tenant_db- only set when the database migration runs the Tenant upgrade -
initial_switch- this is a technical context and should NOT be used
The switch from Flyway (1.6.x) to Liquibase (1.7.x) was planned to be as smooth as possible so there’s no need for manual work hence the behavior is described as following:
-
If the database is empty, Liquibase will create the database schema from scratch
-
If the database contains the latest Fineract 1.6.x database structure which was previously migrated with Flyway. Liquibase will seamlessly upgrade it to the latest version. Note: the Flyway related 2 database tables are left as they are and are not deleted.
-
If the database contains an earlier version of the database structure than Fineract 1.6.x. Liquibase will NOT do anything and will fail the application during startup. The proper approach in this case is to first upgrade your application version to the latest Fineract 1.6.x so that the latest Flyway changes are executed and then upgrade to the newer Fineract version where Liquibase will seamlessly take over the database upgrades.
Troubleshooting
-
During upgrade from Fineract 1.5.0 to 1.6.0, Liquibase fails
After dropping the flyway migrations table (schema_version), Liquibase runs its
own migrations which fails (in recreating tables which already exist) because
we are aiming to re-use DB with existing data from Fineract 1.5.0.Solution: The latest release version (1.6.0) doesn’t have Liquibase at all, it
still runs Flyway migrations. Only the develop branch (later to be 1.7.0) got
switched to Liquibase. Do not pull the develop before upgrading your instance.Make sure first you upgrade your instance (aka database schema with Fineract 1.6.0).
Then upgrade with the current develop branch. Check if some migration scripts
did not run which led to some operations failing due to slight differences in
schema. Try with running the missing migrations manually.Note: develop is considered unstable until released.
-
Upgrading database from MySQL 5.7 as advised to Maria DB 10.6, fails. If we
use data from version 18.03.01 it fails to migrate the data. If we use databases
running on 1.5.0 release it completes the startup but the system login fails.Solution: A database upgrade is separate thing to take care of.
-
We are getting
ScehmaUpgradeNeededException: Make sure to upgrade to Fineracterror while upgrading to
1.6 first and then to a newer versiontag 1.6.1.6 version shouldn’t include Liquibase. It will only be released after 1.6.
Make sure Liquibase is droppingschema_versiontable, as there is no Flyway
it is not required. Drop Flyway and use Liquibase for both migrations and
database independence. In case, if you still get errors, you can use git SHA
746c589a6e809b33d68c0596930fcaa7338d5270and Flyway migration will be done to
the latest.TENANT_LATEST_FLYWAY_VERSION = 392; TENANT_LATEST_FLYWAY_SCRIPT_NAME = "V392__interest_recovery_conf_for_rescedule.sql"; TENANT_LATEST_FLYWAY_SCRIPT_CHECKSUM = 1102395052;
Idempotency
Idempotency is the way to make sure your specific action is only executed once.
For example, if you have a button that is supposed to send a repayment, you don’t want to repayment twice if the user clicks the button twice. Idempotency is a way to make sure that the action is only executed once.
There are two ways to use idempotency:
-
HTTP Request with idempotency key header
-
Batch request with batch item header
How it works
The idempotency key with action name and entity name is unique, and identify a specific command in the system.
If no idempotency key is assigned to the request, the system will generate one for you.
-
User send a request
-
The system checks there are already executed commands with the same
idempotency keyandaction nameandentity name -
The action based on the result of the check
-
If the request is completed the system return with the already generated result
-
If not completed, return HTTP 409 response
-
If the request is not completed, we process the requests and store the results in the database
-
Idempotency in HTTP requests
To achieve idempotency in HTTP requests, you can use the HTTP header from fineract.idempotency-key-header-name configuration variables (default Idemptency-Key). This header is a unique identifier for the request. If you send the same request twice, the second request will be ignored and the response from the first request will be returned.
Idempotency in Batch requests
In batch requests, you can set the idempotency key for every batch item, in the batch item header fields. The header key is from fineract.idempotency-key-header-name configuration variables (default Idemptency-Key).
Result of the request
-
When the request is already executed and completed, the system will return a
x-served-from-cacheheader with the valuetruein the response and return the original request body. -
When the request is already executed but still not completed, the system will return to HTTP 409 error code
-
When the request is not executed, the system runs it normally and stores the result in the date
Validation
Programmatic
Use the DataValidatorBuilder, e.g. like so:
new DataValidatorBuilder().resource("fileUpload")
.reset().parameter("Content-Length").value(contentLength).notBlank().integerGreaterThanNumber(0)
.reset().parameter("FormDataContentDisposition").value(fileDetails).notNull()
.throwValidationErrors();
Such code is often encapsulated in *Validator classes (if more than a few lines, and/or reused from several places; avoid copy/paste), like so:
public class YourThingValidator {
public void validate(YourThing thing) {
new DataValidatorBuilder().resource("yourThing")
...
.throwValidationErrors();
}
}
Declarative
FINERACT-1229 is an open issue about adopting Bean Validation for declarative instead of programmatic (as above) validation. Contributions welcome!
Batch execution and jobs
Just like any financial system, Fineract also has batch jobs to achieve some processing on the data that’s stored in the system.
The batch jobs in Fineract are implemented using Spring Batch. In addition to the Spring Batch ecosystem, the automatic scheduling is done by the Quartz Scheduler but it’s also possible to trigger batch jobs via regular APIs.
Glossary
Job |
A Job is an object that encapsulates an entire batch process. |
Step |
A Step is an object that encapsulates an independent phase of a Job. |
Chunk oriented processing |
Chunk oriented processing refers to reading the data one at a time and creating 'chunks' that are written out within a transaction boundary. |
Partitioning |
Partitioning refers to the high-level idea of dividing your data into so called partitions and distributing the individual partitions among Workers. The splitting of data and pushing work to Workers is done by a Manager. |
Remote partitioning |
Remote partitioning is a specialized partitioning concept. It refers to the idea of distributing the partitions among multiple JVMs mainly by using a messaging middleware. |
Manager node |
The Manager node is one of the objects taking a huge part when using partitioning. The Manager node is responsible for dividing the dataset into partitions and keeping track of all the divided partitions' Worker execution. When all Workers nodes are done with their partitions, the Manager will mark the corresponding Job as completed. |
Worker node |
A Worker node is the other important party in the context of partitioning. The Worker node is the one executing the work needed for a single partition. |
Batch jobs in Fineract
Types of jobs
The jobs in Fineract can be divided into 2 categories:
-
Normal batch jobs
-
Partitionable batch jobs
Most of the jobs are normal batch jobs with limited scalability because Fineract is still passing through the evolution on making most of them capable to process a high-volume of data.
List of jobs
Job name |
Active by default |
Partitionable |
Description |
LOAN_CLOSE_OF_BUSINESS |
No |
Yes |
TBD |
Batch job execution
State management
State management for the batch jobs is done by the Spring Batch provided state management. The data model consists of the following database structure:
The corresponding database migration scripts are shipped with the Spring Batch core module under the org.springframework.batch.core package. They are only available as native scripts and are named as schema-.sql where is the short name of the database platform. For MySQL it’s called schema-mysql.sql and for PostgreSQL it’s called schema-postgresql.sql.
When Fineract is started, the database dependent schema SQL script will be picked up according to the datasource configurations.
Chunk oriented processing
Chunking data has not been easier. Spring Batch does a really good job at providing this capability.
In order to save resources when starting/committing/rollbacking transactions for every single processed item, chunking shall be used. That way, it’s possible to mark the transaction boundaries for a single processed chunk instead of a single item processing. The image below describes the flow with a very simplistic example.
In addition to not opening a lot of transactions, the processing could also benefit from JDBC batching. The last step - writing the result into the database - collects all the processed items and then writes it to the database; both for MySQL and PostgreSQL (the databases supported by Fineract) are capable of grouping multiple DML (INSERT/UPDATE/DELETE) statements and sending them in one round-trip, optimizing the data being sent over the network and granting the possibility to the underlying database engine to enhance the processing.
Remote partitioning
Spring Batch provides a really nice way to do remote partitioning. The 2 type of objects in this setup is a manager node - who splits and distributes the work - and a number of worker nodes - who picks up the work.
In remote partitioning, the worker instances are receiving the work via a messaging system as soon as the manager splits up the work into smaller pieces.
Remote partitioning could be done 2 ways in terms of keeping the job state up-to-date. The main difference between the two is how the manager is notified about partition completions.
One way is that they share the same database. When the worker does something to a partition - for example picks it up for processing - it updates the state of that partition in the database. In the meantime, the manager regularly polls the database until all partitions are processed. This is visualized in the below diagram.
An alternative approach to this - when the database is not intended to be shared between manager and workers - is to use a messaging system (could be the same as for distributing the work) and the workers could send back a message to the manager instance, therefore notifying it about failure/completion. Then the manager can simply keep the database state up-to-date.
Even though the alternative solution decouples the workers even better, we thought it’s not necessary to add the complexity of handling reply message channel to the manager.
Also, please note that the partitioned job execution is multitenant meaning that the workers will receive which tenant it should do the processing for.
Supported message channels
For remote partitioning, the following message channels are supported by Fineract:
-
Any JMS compatible message channels (ActiveMQ, Amazon MQ, etc)
-
Apache Kafka
Fault-tolerance scenarios
There are multiple fault tolerance use-cases that this solution must and will support:
-
If the manager fails during partitioning
-
If the manager completes the partitioning and the partition messages are sent to the broker but while the manager is waiting for the workers to finish, the manager fails
-
If the manager runs properly and during a partition processing a worker instance fails
In case of scenario 1), the simple solution is to re-trigger the job via API or via the Quartz scheduler.
In case of scenario 2), there’s no out-of-the-box solution by Spring Batch. Although there’s a custom mechanism in place that’ll resume the job upon restarting the manager. There are 2 cases in the context of this scenario:
-
If all the partitions have been successfully processed by workers
-
If not all the partitions have been processed by the workers
In the first case, we’ll simply mark the stuck job as FAILED along with it’s partitioning step and instruct Spring Batch to restart the job. The behavior in this case will be that Spring Batch will spawn a new job execution but will notice that the partitions have all been completed so it’s not going to execute them once more.
In the latter case, the same will happen as for the first one but before marking the job execution as FAILED, we’ll wait until all partitions have been completed.
In case of scenario 3), another worker instance will take over the partition since it hasn’t been finished.
Configurable batch jobs
There’s another type of distinction on the batch jobs. Some of them are configurable in terms of their behavior.
The currently supported configurable batch jobs are the following:
-
LOAN_CLOSE_OF_BUSINESS
The behavior of these batch jobs are configurable. There’s a new terminology we’re introducing called business steps.
Business steps
Business steps are a smaller unit of work than regular Spring Batch Steps and the two are not meant to be mixed up because there’s a large difference between them.
A Spring Batch Step’s main purpose is to decompose a bigger work into smaller ones and making sure that these smaller Steps are properly handled within a single database transaction.
In case of a business step, it’s a smaller unit of work. Business steps live within a Spring Batch Step. Fundamentally, they are simple classes that are implementing an interface with a single method that contains the business logic.
Here’s a very simple example:
public class MyCustomBusinessStep implements BusinessStep<Loan> {
@Override
public Loan process(Loan loan) {
// do something
}
}
public class LoanCOBItemProcessor implements ItemProcessor<Loan, Loan> {
@Override
public Loan process(Loan loan) {
List<BusinessStep<Loan>> bSteps = getBusinessSteps();
Loan result = loan;
for (BusinessStep<Loan> bStep : bSteps) {
result = bStep.process(result);
}
return result;
}
}
Business step configuration
The business steps are configurable for certain jobs. The reason for that is because we want to allow the possibility for Fineract users to configure their very own business logic for generic jobs, like the Loan Close Of Business job where we want to do a formal "closing" of the loans at the end of the day.
All countries are different with a different set of regulations. However in terms of behavior, there’s no all size fits all for loan closing.
For example in the United States of America, you might need the following logic for a day closing:
-
Close fully repaid loan accounts
-
Apply penalties
-
Invoke IRS API for regulatory purposes
While in Germany it should be:
-
Close fully repaid loan accounts
-
Apply penalties
-
Do some fraud detection on the account using an external service
-
Invoke local tax authority API for regulatory purposes
These are just examples, but you get the idea.
The business steps are configurable through APIs:
Retrieving the configuration for a job:
GET /fineract-provider/api/v1/jobs/{jobName}/steps?tenantIdentifier={tenantId}
HTTP 200
{
"jobName": "LOAN_CLOSE_OF_BUSINESS",
"businessSteps": [
{
"stepName": "APPLY_PENALTY_FOR_OVERDUE_LOANS",
"order": 1
},
{
"stepName": "LOAN_TAGGING",
"order": 2
}
]
}
Updating the business step configuration for a job:
PUT /fineract-provider/api/v1/jobs/{jobName}/steps?tenantIdentifier={tenantId}
{
"businessSteps": [
{
"stepName": "LOAN_TAGGING",
"order": 1
},
{
"stepName": "APPLY_PENALTY_FOR_OVERDUE_LOANS",
"order": 2
}
]
}
The business step configuration for jobs are tracked within the database in the m_batch_business_steps table.
Inline Jobs
Some jobs that work with business entities have a corresponding job that can trigger the job with a list of specified entities.
When the Inline job gets triggered then the corresponding existing job will run in real time with the given entities as a dataset.
List of Inline jobs
Inline Job name |
Corresponding Job |
LOAN_COB |
LOAN_CLOSE_OF_BUSINESS |
Triggering the Inline Loan COB Job:
POST /fineract-provider/api/v1/jobs/LOAN_COB/inline?tenantIdentifier={tenantId}
{
"loanIds": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
]
}
In this case the Loan COB job will work only with the given loan IDs.
Global Configuration for enabling/disabling jobs
Some jobs can be enabled/disabled with global configuration.
If the job is disabled with the global configuration then it cannot be scheduled and cannot be triggered via API.
List of jobs with global configuration
Job name |
Application property |
Environment variable |
Default value |
LOAN_CLOSE_OF_BUSINESS |
fineract.job.loan-cob-enabled |
FINERACT_JOB_LOAN_COB_ENABLED |
true |
Loan account locking
Keeping a consistent state of loan accounts become quite important when we start talking about doing a business day closing each day for loans.
There are 2 concepts for loan account locking:
-
Soft-locking loan accounts
-
Hard-locking loan accounts
Soft-locking simply means that when the Loan COB has been kicked off but workers not yet processing the chunk of loan accounts (i.e. the partition is waiting in the queue to be picked up) and during this time a real-time write request (e.g. a repayment/disbursement) comes in through the API, we simply do an "inlined" version of the Loan COB for that loan account. From a practical standpoint this will mean that before doing the actual repayment/disbursement on the loan account on the API, we execute the Loan COB for that loan account, kind of like prioritizing it.
Hard-locking means that when a worker picks up the loan account in the chunk, real-time write requests on those loan accounts will be simply rejected with an HTTP 409.
The locking is strictly tied to the Loan COB job’s execution but there could be other processes in the future which might want to introduce new type of locks for loans.
The loan account locking is solved by maintaining a database table which stores the locked accounts, it’s called m_loan_account_locks.
When a loan account is present in the table above, it simply means there’s a lock applied to it and whether it’s a soft or hard lock can be determined by the lock_owner column.
And when a loan account is locked, loan related write API calls will be either rejected or will trigger an inline Loan COB execution. There could be a corner case here when the Loan COB fails to process some loan accounts (due to a bug, inconsistency, etc) and the loan accounts stay locked. This is an intended behavior to mark loans which are not supposed to be used until they are "fixed".
Since the fixing might involve making changes to the loan account via API (for example doing a repayment to fix the loan account’s inconsistent state), we need to allow those API calls. Hence, the lock table includes a bypass_enabled column which disables the lock checks on the loan write APIs.
Orphaned COB lock cleanup
A separate failure mode exists where a loan account is fully processed by COB but its lock row stays in m_loan_account_locks (no error was recorded, yet the lock was never removed). Such locks are orphaned: the loan is in a consistent state, but real-time write APIs are still rejected.
To remove these, the Loan COB job runs a final step unlockProcessedLoansStep (after stayedLockedStep) which executes UnlockProcessedLoansTasklet. The tasklet delegates to AccountLockService#removeOrphanedLocksForProcessedAccounts, which deletes lock rows that meet all of the following:
-
lock_ownerisLOAN_COB_CHUNK_PROCESSINGorLOAN_INLINE_COB_PROCESSING -
error IS NULL -
lock_placed_on_cob_business_date IS NOT NULL -
the referenced loan’s
last_closed_business_dateequals the lock’slock_placed_on_cob_business_date(i.e. all business steps for that COB date completed)
The same mechanism is wired into the Working Capital Loan COB job via UnlockProcessedWorkingCapitalLoansTasklet. Locks that carry an error are intentionally left in place – they still represent a loan that needs manual fixing and bypass_enabled handling continues to apply.
Technology
-
Java: www.oracle.com/technetwork/java/javase/downloads/index.html
-
JAX-RS using Jersey
-
JSON using Google GSON
-
Spring I/O Platform: spring.io/platform
-
Spring Framework
-
Spring Boot
-
Spring Security
-
Spring Data (JPA) backed by EclipseLink
-
-
PostgreSQL
TBD
Modules
We are currently working towards a fully modular codebase and will publish more here when we are ready.
| Even if we are not quite there yet with full modularity you can already create your own custom modules to extend Fineract. Please see chapter Custom Modules. |
Introducing Business Date into Fineract - Community version
Business date as a concept does not exist as of now in Fineract. It would be business critical to add such a functionality to support various banking functionalities like “Closing of Business day”, “Having Closing of Business day relevant jobs”, “Supporting logical date management”.
Glossary
*COB |
Close of Business; concept of closing a business day |
*Business day |
Timeframe that logically group together actions on a particular business date |
*Business date |
Logical date; its value is not tied to the physical calendar. Represents a business day |
*Cob date |
Logical date; Represents the business date for actions during COB job execution |
*Created date |
When the transaction was created (audit purposes). Date + time |
*Last modified date |
When the transaction was last modified (audit purposes). Date + time |
*Submitted on date / Posting date |
When the transaction was posted. Tenant date or business date (depends on whether the logical date concept was introduced or not) |
*Transaction date / Value date |
The date on which the transaction occurred or to be accounted for |
Current behaviour
-
Fineract support 3 types of dates:
-
System date
-
Physical/System date of the running environment
-
-
Tenant date
-
Timezoned version of the above system date
-
-
User-provided date
-
Based on the provided date (as string) and the provided date format
-
-
-
There is no support of logical date concept
-
Independent from the system / tenant date
-
-
Jobs are scheduled against system date (CRON), but aligned with the tenant timezone.
-
During the job execution all the data and transactions are using the actual tenant date
-
It could happen some transactions are written for 17th of May and other for 18th of May, if the job was executed around midnight
-
-
There is no support of COB
-
No backdated transactions by jobs
-
There is no support to logically group together transactions and store them with the same transaction date which is independent of the physical calendar of the tenant
-
-
All the transactions and business logic are tied to a physical calendar
Business date
Design
By introducing the business day concept we are not tied anymore to the physical calendar of the system or the tenant. We got the ability to define our own business day boundaries which might end 15 minutes before midnight and any incoming transactions after the cutoff will be accounted for the following business day.
It is a logical date which makes it possible to separate the business day from the physical calendar:
-
Close a business day before midnight
-
Close a business day at midnight
-
Close a business day after midnight
Closing a Business Day could be a longer process (see COB jobs) meanwhile some processes shall still be able to create transactions for that business day (COB jobs), but others are meant to create the transactions for the next (incoming transactions): Business date concept is there to sort that out.
Business date concept is essential when:
-
Having COB jobs:
-
When the COB was triggered:
-
All the jobs which processing the data must still accounted for actual business day
-
All the incoming transactions must be accounted to the next business day
-
-
-
Business day is ending before / after midnight (tenant date / system date)
-
Testing purposes:
-
Since the transactions and job execution is not tied anymore to a physical calendar, we can easily test a whole loan lifecycle by altering the business date
-
-
Handling disruption of service: For any unseen reason the system goes down or there are any disruption in the workflow, the “missed days” can easily be processed one by one as nothing happened
-
There is a disruption at 2022-06-02
-
The issue is fixed by 2022-06-05
-
The COB flow can be executed for 2022-06-03 and when it is finished for 2022-06-04 and after when the time arrives for 2022-06-05
-
This logical date is manageable via:
-
Job
-
API
To maintain such separation from physical calendar we need to introduce the following new dates:
-
Business date
-
COB date
-
Can be calculated based on the actual business date
-
Depend on COB date strategy (see below)
-
-
Business date
The - logical - date of the actual business day, eg: 2022-05-06
-
It does not support time parts
-
It can be managed manually (via API call) or automatically (via scheduled job)
-
All business actions during the business day shall use this date:
-
Posting / submitted on date of transactions
-
Submitted on date of actions
-
(Regular) jobs
-
-
It will be used in every situation where the transaction date / value date is not provided by the user or the user provided date shall be validated.
-
Opening date
-
Closing date
-
Disbursal date
-
Transaction/Value date
-
Posting/Submitted date
-
Reversal date
-
-
Will not be use for audit purposes:
-
Created on date
-
Updated on date
-
COB date
The - logical - date of the business day for job execution, eg: 2022-05-05
-
It can be calculated based on the business date
-
COB date = business date - 1 day
-
Automatically modified alongside with the business date change
-
-
It does not support time parts
-
It is automatically managed by business date change
-
Configurable
-
-
It is used only via COB job execution
-
When we create / modify any business data during the COB job execution, the COB date is to be used:
-
Posting date of transactions
-
Submitted on date of actions
-
Transaction / value date of any actions
-
-
Some basic example
Apply for a loan
Tenant date: 2022-05-23 14:22:12
Business date: 2022-05-22
Submitted on date: 2022-05-23
Outcome: FAIL
Message: The date on which a loan is submitted cannot be in the future.
Reason: Even the tenant date is 2022-05-23, but the business date was 2022-05-22 which means anything further that date must be considered as a future date.
Tenant date: 2022-05-23 14:22:12
Business date: 2022-05-22
Submitted on date: 2022-05-22
Outcome: SUCCESS
Loan application details:
-
Submitted on date: 2022-05-22
Repayment for a loan
Tenant date: 2022-05-25 11:22:12
Business date: 2022-05-24
Transaction date: 2022-05-25
Outcome: FAIL
Message: The transaction date cannot be in the future.
Reason: Even the physical date is 2022-05-25, but the business date was 2022-05-24 which means anything further that date must be considered as a future date.
Tenant date: 2022-05-25 11:22:12
Business date: 2022-05-24
Transaction date: 2022-05-23
Outcome: SUCCESS
Loan transaction details:
-
Submitted on date: 2022-05-24
-
Transaction date: 2022-05-23
-
Created on date: 2022-05-25 11:22:12
Changes in Fineract
We shall modify at all the relevant places where the tenant date was used:
-
With very limited exceptions all places where the tenant date is used we need to modify to use the business date.
-
Replace system date with tenant date or business date (exceptions may apply)
-
Add missing Value dates and Posting dates to entities
-
Having a generic naming conventions for JPA fields and DB fields
-
Renaming the fields accordingly
-
Evaluate value date (transaction date) and posting date (submitted on date), created on date usages
-
Jobs to be checked and modified accordingly
-
Native queries to be checked and modified accordingly
-
Reports to be checked and modified accordingly
-
Every table where update is supported the AbstractAuditableCustom should be implemented
-
Amend Transactions and Journal entries date handling to fit for business date concept
-
For audit fields we shall introduce timezoned datetimes and store them in database accordingly
-
Storing DATETIME fields without Timezone is potential problem due to the daylight savings
-
Also, some external libs (like Quartz) are using system timezone and Fineract will using Tenant timezone for audit fields. To be able to distinct them in DB we shall use DATETIME with TIMESTAMP column types and use timezoned java time objects in the application
-
Reliable event framework
Fineract is capable of generating and raising events for external consumers in a reliable way. This section is going to describe all the details on that front with examples.
Framework capabilities
ACID (transactional) guarantee
The event framework must support ACID guarantees on the business operation level.
Let’s see a simple use-case:
-
A client applies to a loan on the UI
-
The loan is created on the server
-
A loan creation event is raised
What happens if step 3 fails? Shall it fail the original loan creation process?
What happens if step 2 fails but step 3 still gets executed? We’re raising an event for a loan that hasn’t been created in reality.
Therefore, raising an event is tied to the original business transaction to ensure the data that’s getting written into the database along with the respective events are saved in an all-or-nothing fashion.
Messaging integration
The system is able to send the raised events to downstream message channels. The current implementation supports the following message channels:
-
ActiveMQ
Ordering guarantee
The events that are raised will be sent to the downstream message channels in the same order as they were raised.
Delivery guarantee
The framework supports the at-least-once delivery guarantee for the raised events.
Reliability and fault-tolerance
In terms of reliability and fault-tolerance, the event framework is able to handle the cases when the downstream message channel is not able to accept events. As soon as the message channel is back to operational, the events will be sent again.
Selective event producing
Whether or not an event must be sent to downstream message channels for a particular Fineract instance is configurable through the UI and API.
Standardized format
All the events sent to downstream message channels are conforming a standardized format using Avro schemas.
Extendability and customizations
The event framework is capable of being easily extended with new events for additional business operations or customizing existing events.
Ability to send events in bulk
The event framework makes it possible to sort of queue events until they are ready to be sent and send them as a single message instead of sending each event as a separate, individual one.
For example during the COB process, there might be events raised in separate business steps which needs to be sent out but they only need to be sent out at the end of the COB execution process instead of one-by-one.
Architecture
Intro
On a high-level, the concept looks the following. An event gets raised in a business operation. The event data gets saved to the database - to ensure ACID guarantees. An asynchronous process takes the saved events from the database and puts them onto a message channel.
The flow can be seen in the following diagram:
Foundational business events
The whole framework is built upon an existing infrastructure in Fineract; the Business Events.
As a quick recap, Business Events are Fineract events that can be raised at any place in a business operation using the BusinessEventNotifierService. Callbacks can be registered when a certain type of Business Event is raised and other business operations can be done. For example when a Loan gets disbursed, there’s an interested party doing the Loan Arrears Aging recalculation using the Business Event communication.
The nice thing about the Business Events is that they are tied to the original transaction which means if any of the processing on the subscriber’s side fail, the entire original transaction will be rolled back. This was one of the requirements for the Reliable event framework.
Event database integration
The database plays a crucial part in the framework since to ensure transactionality, - without doing proper transaction synchronization between different message channels and the database - the framework is going to save all the raised events into the same relational database that Fineract is using.
Database structure
The database structure looks the following
Name |
Type |
Description |
Example |
|
number |
Auto incremented ID. |
|
|
text |
The event type as a string. |
|
|
text |
The fully qualified name of the schema that was used for the data serialization, as a string. |
|
|
BLOB (MySQL/MariaDB), BYTEA (PostgreSQL) |
The event payload as Avro binary. |
|
|
timestamp |
UTC timestamp when the event was raised. |
|
|
text |
Enum text representing the status of the external event. |
|
|
timestamp |
UTC timestamp when the event was sent. |
|
|
text |
Randomly generated UUID upon inserting a row into the table for idempotency purposes. |
|
|
date |
The business date to when the event was generated. |
|
The above database table contains the unsent events which later on will be sent by an asynchronous event processor.
Upon successfully sending an event, the corresponding statuses will be updated.
Avro schemas
For serializing events, Fineract is using Apache Avro. There are 2 reasons for that:
-
More compact storage since Avro is a binary format
-
The Avro schemas are published with Fineract as a separate JAR so event consumers can directly map the events into POJOs
There are 3 different levels of Avro schemas used in Fineract for the Reliable event framework which are described below.
Standard event schema
The standard event schema is for the regular events. These schemas are used when saving a raised event into the database and using the Avro schema to serialize the event data into a binary format.
For example the OfficeDataV1 Avro schema looks the following:
OfficeDataV1.avsc
{
"name": "OfficeDataV1",
"namespace": "org.apache.fineract.avro.office.v1",
"type": "record",
"fields": [
{
"default": null,
"name": "id",
"type": [
"null",
"long"
]
},
{
"default": null,
"name": "name",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "nameDecorated",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "externalId",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "openingDate",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "hierarchy",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "parentId",
"type": [
"null",
"long"
]
},
{
"default": null,
"name": "parentName",
"type": [
"null",
"string"
]
},
{
"default": null,
"name": "allowedParents",
"type": [
"null",
{
"type": "array",
"items": "org.apache.fineract.avro.office.v1.OfficeDataV1"
}
]
}
]
}
Event message schema
The event message schema is just a wrapper around the standard event schema with extra metadata for the event consumers.
Since Avro is strongly typed, the event content needs to be first serialized into a byte sequence and that needs to be wrapped around.
This implies that for putting a single event message onto a message queue for external consumption, data needs to be serialized 2 times; this is the 2-level serialization.
-
Serializing the event
-
Serializing the already serialized event into an event message using the message wrapper
The message schema looks the following:
MessageV1.avsc{
"name": "MessageV1",
"namespace": "org.apache.fineract.avro",
"type": "record",
"fields": [
{
"name": "id",
"doc": "The ID of the message to be sent",
"type": "long"
},
{
"name": "source",
"doc": "A unique identifier of the source service",
"type": "string"
},
{
"name": "type",
"doc": "The type of event the payload refers to. For example LoanApprovedBusinessEvent",
"type": "string"
},
{
"name": "category",
"doc": "The category of event the payload refers to. For example LOAN",
"type": "string"
},
{
"name": "createdAt",
"doc": "The UTC time of when the event has been raised; in ISO_LOCAL_DATE_TIME format. For example 2011-12-03T10:15:30",
"type": "string"
},
{
"name": "businessDate",
"doc": "The business date when the event has been raised; in ISO_LOCAL_DATE format. For example 2011-12-03",
"type": "string"
},
{
"name": "tenantId",
"doc": "The tenantId that the event has been sent from. For example default",
"type": "string"
},
{
"name": "idempotencyKey",
"doc": "The idempotency key for this particular event for consumer de-duplication",
"type": "string"
},
{
"name": "dataschema",
"doc": "The fully qualified name of the schema of the event payload. For example org.apache.fineract.avro.loan.v1.LoanAccountDataV1",
"type": "string"
},
{
"name": "data",
"doc": "The payload data serialized into Avro bytes",
"type": "bytes"
}
]
}
Bulk event schema
The bulk event schema is used when multiple events are supposed to be sent together. This schema is used also when serializing the data for the database storing but the idea is quite simple. Have an array of other event schemas embedded into it.
Since Avro is strongly typed, the array within the bulk event schema is an array of MessageV1 schemas. That way the consumers can decide which events they want to deserialize and which don’t.
This elevates the regular 2-level serialization/deserialization concept up to a 3-level one:
-
Serializing the standard events
-
Serializing the standard events into a bulk event
-
Serializing the bulk event into an event message
Versioning
Avro is quite strict with changes to an existing schema and there are a number of compatibility modes available.
Fineract keeps it simple though. Version numbers - in the package names and in the schema names - are increased with each published modification; meaning that if the OfficeDataV1 schema needs a new field and the OfficeDataV1 schema has been published officially with Fineract, a new OfficeDataV2 has to be created with the new field instead of modifying the existing schema.
This pattern ensures that a certain event is always deserialized with the appropriate schema definition, otherwise the deserialization could fail.
Code generation
The Avro schemas are described as JSON documents. That’s hardly usable directly with Java hence Fineract generates Java POJOs from the Avro schemas. The good thing about these POJOs is the fact that they can be serialized/deserialized in themselves without any magic since they have a toByteBuffer and fromByteBuffer method.
From POJO to ByteBuffer:
LoanAccountDataV1 avroDto = ...
ByteBuffer buffer = avroDto.toByteBuffer();
From ByteBuffer to POJO:
ByteBuffer buffer = ...
LoanAccountDataV1 avroDto = LoanAccountDataV1.fromByteBuffer(buffer);
The ByteBuffer is a stateful container and needs to be handled carefully. Therefore Fineract has a built-in ByteBuffer to byte array converter; ByteBufferConverter.
|
Downstream event consumption
When consuming events on the other side of the message channel, it’s critical to know which events the system is interested in. With the multi-level serialization, it’s possible to deserialize only parts of the message and decide based on that whether it makes sense for a particular system to deserialize the event payload more.
Whether events are important can be decided based on:
-
the
typeattribute in the message -
the
categoryattribute in the message -
the
dataschemaattribute in the message
These are the main attributes in the message wrapper one can use to decide whether an event message is useful.
If the event needs to be deserialized, the next step is to find the corresponding schema definition. That’s going to be sent in the dataschema attribute within the message wrapper. Since the attribute contains the fully-qualified name of the respective schema, it can be easily resolved to a Class object. Based on that class, the payload data can be easily deserialized using the fromByteBuffer method on every generated schema POJO.
Message ordering
One of the requirements for the framework is to provide ordering guarantees. All the events have to conform a happens-before relation.
For the downstream consumers, this can be verified by the id attribute within the messages. Since it’s going to be a strictly-monotonic numeric sequence, it can be used for ordering purposes.
Event categorization
For easier consumption, the terminology event category is introduced. This is nothing else but the bounded context an event is related to.
For example the LoanApprovedBusinessEvent and the LoanWaiveInterestBusinessEvent are both related to the Loan bounded contexts.
The category in which an event resides in is included in the message under the category attribute.
The existing event categories can be found under the Event categories section.
Asynchronous event processor
The events stored in the database will be picked up and sent by a regularly executed job.
This job is a Fineract job, scheduled to run for every minute and will pick a number of events in order. Those events will be put onto the downstream message channel in the same order as they were raised.
Purging events
The events database table is going to grow continuously. That’s why Fineract has a purging functionality in place that’s gonna delete old and already sent events.
It’s implemented as a Fineract job and is disabled by default. It’s called TBD.
Usage
Using the event framework is quite simple. First, it has to be enabled through properties or environment variable.
The respective options are the following:
-
the
fineract.events.external.enabledproperty -
the
FINERACT_EXTERNAL_EVENTS_ENABLEDenvironment variable
These configurations accept a boolean value; true or false.
The key component to interact with is the BusinessEventNotifierService#notifyPostBusinessEvent method.
Raising events
Raising events is really easy. An instance of a BusinessEvent interface is needed, that’s going to be the event. There are plenty of them available already in the Fineract codebase.
And that’s pretty much it. Everything else is taken care of in terms of event data persisting and later on putting it onto a message channel.
An example of event raising:
@Override
public CommandProcessingResult createClient(final JsonCommand command) {
...
businessEventNotifierService.notifyPostBusinessEvent(new ClientCreateBusinessEvent(newClient));
...
return ...;
}
The above code is copied from the ClientWritePlatformServiceJpaRepositoryImpl class.
|
Example event message content
Since the message is serialized into binary format, it’s hard to represent in the documentation therefore here’s a JSON representation of the data, just as an example.
{
"id": 121,
"source": "a65d759d-04f9-4ddf-ac52-34fa5d1f5a25",
"type": "LoanApprovedBusinessEvent",
"category": "Loan",
"createdAt": "2022-09-05T10:15:30",
"tenantId": "default",
"idempotencyKey": "abda146d-68b5-48ca-b527-16d2b7c5daef",
"dataschema": "org.apache.fineract.avro.loan.v1.LoanAccountDataV1",
"data": "..."
}
| The source attribute refers to an ID that’s identifying the producer service. Fineract will regenerate this ID upon each application startup. |
Raising bulk events
Raising bulk events is really easy as well. The 2 key methods are:
-
BusinessEventNotifierService#startExternalEventRecording -
BusinessEventNotifierService#stopExternalEventRecording
First, you have to start recording your events. This recording will be applied for the current thread. And then you can raise as many events as you want with the regular BusinessEventNotifierService#notifyPostBusinessEvent method, but they won’t get saved to the database immediately. They’ll get "recorded" into an internal buffer.
When you stop recording using the method above, all the recorded events will be saved as a bulk event to the database; and serialized appropriately.
From then on, the bulk event works just like any of the event. It’ll be picked up by the processor to send it to a message channel.
Event categories
TBD
Customizations
The framework provides a number of customization options:
-
Creating new events (that’s already given by the Business Events)
-
Creating new Avro schemas
-
Customizing what data gets serialized for existing events
In the upcoming sections, that’s what going to be discussed.
Creating new events
Creating new events is super easy. Just create an implementation of the BusinessEvent interface and that’s it.
From then on, you can raise those events in the system, although you can’t publish them to an external message channel. If you have the event framework enabled, it’s going to fail with not finding the appropriate serializer for your business event.
There are existing serializers which might be able to handle your new event. For example the LoanBusinessEventSerializer is capable of handling all LoanBusinessEvent subclasses so there’s no need to create a brand new serializer.
|
The interface looks the following:
BusinessEvent.javapublic interface BusinessEvent<T> {
T get();
String getType();
String getCategory();
Long getAggregateRootId();
}
Quite simple. The get method should return the data you want to pass within the event instance. The getType method returns the name of the business event that’s gonna be saved as the type into the database.
| Creating a new business event only means that it can be used for raising an event. To make it compatible with the event framework and to be sent to a message channel, some extra work is needed which are described below. |
Creating new Avro schemas and serializers
First let’s talk about the event serializers because that’s what’s needed to make a new event compatible with the framework.
The serializer has a special interface, BusinessEventSerializer.
BusinessEventSerializer.javapublic interface BusinessEventSerializer {
<T> boolean canSerialize(BusinessEvent<T> event);
Class<? extends GenericContainer> getSupportedSchema();
<T> ByteBufferSerializable toAvroDTO(BusinessEvent<T> rawEvent);
}
An implementation of this interface shall be registered as a Spring bean, and it’ll be picked up automatically by the framework.
| You can look at the existing serializers for implementation ideas. |
New Avro schemas can be easily created. Just create a new Avro schema file in the fineract-avro-schemas project under the respective bounded context folder, and it will be picked up automatically by the code generator.
BigDecimal support in Avro schemas
Apache Avro by default doesn’t support complex types like a BigDecimal. It has to be implemented using a custom snippet like this:
{
"logicalType": "decimal",
"precision": 27,
"scale": 8,
"type": "bytes"
}
It’s a 20 precision and 8 scale BigDecimal.
Obviously it’s quite challenging to copy-paste this snippet to every single BigDecimal field, so there’s a customization in place for Fineract.
The type bigdecimal is supported natively, and you’re free to use it like this:
{
"default": null,
"name": "principal",
"type": [
"null",
"bigdecimal"
]
}
This bigdecimal type will be simple replaced with the BigDecimal snippet showed above during the compilation process.
|
Custom data serialization for existing events
In case there’s a need some extra bit of information within the event message that the default serializers are not providing, you can override this behavior by registering a brand-new custom serializer (as shown above).
Since there’s a priority order of serializers, the only thing the custom serializer need to do is to be annotated by the @Order annotation or to implement the Ordered interface.
An example custom serializer with priority looks the following:
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CustomLoanBusinessEventSerializer implements BusinessEventSerializer {
...
@Override
public <T> boolean canSerialize(BusinessEvent<T> event) {
return ...;
}
@Override
public <T> byte[] serialize(BusinessEvent<T> rawEvent) throws IOException {
...
ByteBuffer buffer = avroDto.toByteBuffer();
return byteBufferConverter.convert(buffer);
}
@Override
public Class<? extends GenericContainer> getSupportedSchema() {
return ...;
}
}
All the default serializers are having Ordered.LOWEST_PRECEDENCE.
|
Appendix A: Properties and environment variables
| Property name | Environment variable | Default value | Description |
|---|---|---|---|
|
|
|
Whether the external event sending is enabled or disabled. |
Introducing Advanced payment allocation
Since the first repayment strategy got introduced, many followed, but there was one thing common in them:
-
They were hard coding the allocation rules for each transaction type.
By introducing the "Advanced payment allocation" the idea was to have a repayment strategy which was:
-
supporting dynamic configuration of the allocation rules for transaction types
-
supporting configuration of more fine-grained allocation rules for future installments
-
supporting reprocessing of transactions and charges in chronological order
Glossary
*Advanced payment allocation |
Ability to configure allocation rules dynamically for transactions |
*Payment allocation |
Rule that defines which outstanding balance to be paid of first on which installment |
*Re-amortization |
Transaction amount to be divided into equal portions by the number of future installments and those installments to be paid by these portions. |
Capabilities
-
Payment allocation should be configurable for transactions:
-
Repayment
-
Goodwill credit
-
Payout refund
-
Merchant refund
-
Charge adjustments
-
etc.
-
-
Can be configured for Loan products
-
Payment allocation rule changes on the loan product will affect only the newly created Loan accounts.
-
-
Chronological reprocess order
-
Transactions (including disbursements) and charges are (re)processed and allocated in chronological order
-
-
Support re-amortization between future installments
-
Transaction amount to be divided into equal portions (based on the number of future installments) and to repay each future installment by the calculated portion.
-
It’s not hard coded, but usually the principal portion needs to be allocated first, but if there are still unprocessed amounts, the rest of the outstanding balances are to be allocated based on the rest of the rules
-
-
-
Main allocation rules (installment level)
-
Past Due Installment(s):
-
Oldest first
-
-
Due Installment(s):
-
Normal installment takes priority over Down-payment installment (if applicable)
-
-
Future Installment(s):
-
Available allocation orders:
-
Next installment first
-
Last installment first
-
Re-amortization*
-
-
-
-
Secondary allocation rules
-
Penalty
-
Fee
-
Interest
-
Principal
-
Configuration
Advanced repayment allocation rules can be configured for the Loan product if "Advanced payment allocation" got selected as repayment strategy.
There will be a (always required) “DEFAULT” transaction type configuration which acts as fallback ruleset, if the there are no configured rules for a specific transaction type.
New repayment strategy
-
Name: Advanced payment allocation
-
Code: advanced-payment-allocation-strategy
-
Order: 8
Allocation rules
-
Past due penalty
-
Past due fee
-
Past due principal
-
Past due interest
-
Due penalty
-
Due fee
-
Due principal
-
Due interest
-
In advance penalty
-
In advance fee
-
In advance principal
-
In advance interest
Future installment allocation rules:
-
Next installment
-
Last installment
-
Re-amortization
Example Request
{
...
"paymentAllocation": [
{
"transactionType": "DEFAULT",
"paymentAllocationOrder": [
{
"paymentAllocationRule": "DUE_PAST_PENALTY",
"order": 1
},
{
"paymentAllocationRule": "DUE_PAST_FEE",
"order": 2
},
{
"paymentAllocationRule": "DUE_PAST_INTEREST",
"order": 3
},
...
{
"paymentAllocationRule": "IN_ADVANCE_INTEREST",
"order": 14
}
],
"futureInstallmentAllocationRule": "NEXT_INSTALLMENT"
}
],
...
}
The above request configures the "DEFAULT" allocation rules:
-
First the already due penalties to be paid
-
Second the already due fees to be paid
-
Last the future interests to be paid
Also for future installments set the allocation rules as
-
First future installment by due date to be paid first
API Backward Compatibility
Overview
Apache Fineract enforces API backward compatibility using swagger-brake, an automated tool that compares OpenAPI specifications between the base branch and a pull request to detect breaking changes. This ensures that existing API consumers are not broken when new changes are deployed.
The check runs automatically on every pull request via the verify-api-backward-compatibility.yml GitHub Actions workflow.
How It Works
The workflow follows these steps:
-
Generate baseline spec — Checks out the base branch (e.g.
develop) and runs./gradlew :fineract-provider:resolveto generate the current OpenAPI specification. -
Generate PR spec — Checks out the PR branch and generates its OpenAPI specification.
-
Sanitize specs — Patches known issues in the generated specs (e.g. missing
schemaentries inrequestBodycontent) to prevent false positives. -
Compare — Runs
checkBreakingChangesvia the swagger-brake Gradle plugin to compare old vs new specs. -
Report — If breaking changes are found:
-
A deduplicated summary table is written to the GitHub Actions Step Summary (visible on the workflow run page).
-
A comment is posted on the PR (when token permissions allow).
-
The full JSON report is archived as a build artifact.
-
The workflow fails, blocking the PR.
-
Breaking Change Rules
swagger-brake detects the following categories of breaking changes:
Endpoint Rules
| Rule | Description |
|---|---|
R001 |
A stable (non-beta) API was changed to beta |
R002 |
An API path was deleted |
Request Rules
| Rule | Description |
|---|---|
R003 |
A request media type (content type) was removed |
R004 |
A request parameter was deleted |
R005 |
An enum value was removed from a request parameter |
R006 |
A parameter location changed (e.g. |
R007 |
A parameter was made required |
R008 |
A parameter type was changed |
R009 |
An attribute was removed from a request body schema |
R010 |
A property type was changed in a request schema |
R011 |
An enum value was removed from a request body schema |
Response Rules
| Rule | Description |
|---|---|
R012 |
A response code was deleted |
R013 |
A response media type was removed |
R014 |
An attribute was removed from a response schema |
R015 |
A property type was changed in a response schema |
R016 |
An enum value was removed from a response schema |
Constraint Rules
| Rule | Description |
|---|---|
R017 |
A request parameter constraint was tightened (covers |
Gradle Configuration
The swagger-brake plugin is configured in fineract-provider/build.gradle:
apply plugin: 'com.docktape.swagger-brake'
swaggerBrake {
newApi = "${project.buildDir}/resources/main/static/fineract.json"
oldApi = findProperty('apiBaseline') ?: "${projectDir}/config/swagger/fineract-baseline.json"
outputFormats = ['JSON']
outputFilePath = "${project.buildDir}/swagger-brake"
deprecatedApiDeletionAllowed = true
strictValidation = false
}
Configuration Options
| Option | Default | Description |
|---|---|---|
|
— |
Path to the new (PR branch) OpenAPI spec. Generated by the |
|
— |
Path to the baseline OpenAPI spec. Provided via |
|
|
Report formats. We use |
|
|
Directory for generated reports. |
|
|
When |
|
|
When |
|
|
List of path prefixes to skip (e.g. |
|
|
List of rule codes to suppress entirely (e.g. |
|
|
Vendor extension name for marking beta APIs. Beta endpoints can be freely modified without triggering violations. |
|
|
Controls nested object serialization depth in logs (range 1-20). Increase if you see |
Running Locally
To run the check locally, you need a baseline spec to compare against:
# 1. Generate the baseline from develop
git stash
git checkout develop
./gradlew :fineract-provider:resolve --no-daemon
cp fineract-provider/build/resources/main/static/fineract.json /tmp/baseline.json
git checkout -
git stash pop
# 2. Generate your current spec and compare
./gradlew :fineract-provider:checkBreakingChanges \
-PapiBaseline="/tmp/baseline.json" \
--no-daemon
The JSON report is written to fineract-provider/build/swagger-brake/.
Handling Breaking Changes
Intentional Breaking Changes
If your PR intentionally introduces a breaking API change (e.g. removing a deprecated field):
-
The workflow will fail and report the violations.
-
Document the breaking change in the PR description with justification.
-
A committer will review and approve the PR with the understanding that the API contract is changing.
Excluding Paths Under Cleanup
If you need to fix incorrect API annotations on endpoints that are not yet stable, use excludedPaths to temporarily exclude them from checking:
swaggerBrake {
excludedPaths = [
'/v1/smscampaigns',
'/v1/email',
]
}
Path exclusion is prefix-based — excluding /v1/smscampaigns will skip all paths starting with that prefix.
Remove the exclusion once the cleanup is complete.
Marking Endpoints as Beta
For endpoints that are experimental or under active development, mark them as beta in the Java code:
@Operation(
summary = "...",
extensions = @Extension(
properties = @ExtensionProperty(name = "x-beta-api", value = "true")
)
)
Beta endpoints can be freely modified, created, or removed without triggering violations. Promoting a beta endpoint to stable (removing the extension) is also non-breaking. However, demoting a stable endpoint to beta is a breaking change (R001).
Report Format
When breaking changes are detected, the workflow produces a deduplicated summary table:
| Rule | Description | Detail | Affected endpoints | Count |
|---|---|---|---|---|
R014 |
Response attribute removed |
|
|
3 |
The deduplication groups violations by rule code and affected attribute, collapsing multiple endpoint occurrences into a single row. This is important because a single schema change (e.g. removing a field from a shared response type) can generate dozens of raw violations — one per endpoint that uses that schema.
Tool Reference
-
Tool: swagger-brake v2.7.0
-
Gradle plugin:
com.docktape.swagger-brake -
Documentation: docktape.github.io/swagger-brake/
-
License: Apache 2.0
Command Processing
| This is an ongoing effort to migrate to an improved CQRS sub-system. Please read Jira ticket FINERACT-2169 to see the current progress. |
Introduction
Fineract accumulated some technical debt over the years. One area that is implicated is type-safety of internal and external facing APIs, the most prominent of which is Fineract’s REST API. In general the package layout of the project reflects a more or less classic layered architecture (REST API, data transfer/value objects, business logic services, storage/repositories). The project predates some of the more modern frameworks and best practices that are available today and on occasions the data structures that are exchanged offer some challenges (e.g. generic types). Fineract’s code base reflects that, especially where JSON de-/serialization is involved. Nowadays, this task would be simply delegated to the Jackson framework, but when Fineract (MifosX) started, the decision was made to use Google’s GSON library and create handcrafted helper classes to deal with JSON parsing. While this provided a lot of flexibility the approach had some downsides:
-
the lowest common denominator is the string type (aka JSON blob); this is where we lose the type information
-
the strings are transformed into JSONObjects; a little bit better than raw strings, but barely more than a hash map
-
"magic" strings are needed to get/set values
-
this approach makes refactoring more difficult
-
to be able to serve an OpenAPI descriptor (as JSON and YAML) we had to re-introduce the type information at the REST API level with dummy classes that contain only the specified attributes; those classes are only used with the Swagger annotations and no were else
-
some developers skipped the layered architecture and found it too tedious to maintain DTOs and JSON helper classes, and as a result just passed JSONObjects right to the business logic layer
-
now the business logic is unnecessarily aware of how Fineract communicates to the outside world and makes replacing/enhancing the communication protocol (e.g. with GRPC) pretty much impossible
The list doesn’t end here, but in the end things boil down to two main points:
-
developer experience: boilerplate code and missing type safety cost more time
-
bugs: the more code the more likely errors get introduced, especially when type safety is missing and we have to rely on runtime errors (vs. compile time).
There has been already some preparatory work done concerning type safety, but until now we avoided dealing with the real source of this issue. Fineract’s architectures devises read from write requests (CQRS) for improved scalability.
The read requests are not that problematic and not (yet) covered, but all write requests pass through a component/service that is called SynchronousCommandProcessingService.
@Slf4j
@RequiredArgsConstructor
public class SynchronousCommandProcessingService implements CommandProcessingService {
public static final String IDEMPOTENCY_KEY_STORE_FLAG = "idempotencyKeyStoreFlag";
public static final String IDEMPOTENCY_KEY_ATTRIBUTE = "IdempotencyKeyAttribute";
public static final String COMMAND_SOURCE_ID = "commandSourceId";
private final PlatformSecurityContext context;
private final ApplicationContext applicationContext;
private final ToApiJsonSerializer<Map<String, Object>> toApiJsonSerializer;
private final ToApiJsonSerializer<CommandProcessingResult> toApiResultJsonSerializer;
private final ConfigurationDomainService configurationDomainService;
private final CommandHandlerProvider commandHandlerProvider;
private final IdempotencyKeyResolver idempotencyKeyResolver;
private final CommandSourceService commandSourceService;
private final RetryConfigurationAssembler retryConfigurationAssembler;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private CommandProcessingResult retryWrapper(Supplier<CommandProcessingResult> supplier) {
try {
if (!BatchRequestContextHolder.isEnclosingTransaction()) {
return retryConfigurationAssembler.getRetryConfigurationForExecuteCommand().executeSupplier(supplier);
}
return supplier.get();
} catch (RuntimeException e) {
return fallbackExecuteCommand(e);
}
}
@Override
public CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command,
final boolean isApprovedByChecker) {
return retryWrapper(() -> {
// Do not store the idempotency key because of the exception handling
setIdempotencyKeyStoreFlag(false);
Long commandId = (Long) fineractRequestContextHolder.getAttribute(COMMAND_SOURCE_ID, null);
boolean isRetry = commandId != null;
boolean isEnclosingTransaction = BatchRequestContextHolder.isEnclosingTransaction();
CommandSource commandSource = null;
String idempotencyKey;
if (isRetry) {
commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey();
} else if ((commandId = command.commandId()) != null) { // action on the command itself
commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey();
} else {
idempotencyKey = idempotencyKeyResolver.resolve(wrapper);
}
exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry);
AppUser user = context.authenticatedUser(wrapper);
if (commandSource == null) {
if (isEnclosingTransaction) {
commandSource = commandSourceService.getInitialCommandSource(wrapper, command, user, idempotencyKey);
} else {
commandSource = commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey);
commandId = commandSource.getId();
}
}
if (commandId != null) {
storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
setIdempotencyKeyStoreFlag(true);
return executeCommand(wrapper, command, isApprovedByChecker, commandSource, user, isEnclosingTransaction);
});
}
private CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command,
final boolean isApprovedByChecker, CommandSource commandSource, AppUser user, boolean isEnclosingTransaction) {
final CommandProcessingResult result;
try {
result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker);
} catch (Throwable t) { // NOSONAR
RuntimeException mappable = ErrorHandler.getMappable(t);
ErrorInfo errorInfo = commandSourceService.generateErrorInfo(mappable);
Integer statusCode = errorInfo.getStatusCode();
commandSource.setResultStatusCode(statusCode);
commandSource.setResult(errorInfo.getMessage());
if (statusCode != SC_OK) {
commandSource.setStatus(ERROR);
}
if (!isEnclosingTransaction) { // TODO: temporary solution
commandSourceService.saveResultNewTransaction(commandSource);
}
// must not throw any exception; must persist in new transaction as the current transaction was already
// marked as rollback
publishHookErrorEvent(wrapper, command, errorInfo);
throw mappable;
}
Retry persistenceRetry = retryConfigurationAssembler.getRetryConfigurationForCommandResultPersistence();
try {
CommandSource finalCommandSource = commandSource;
AtomicInteger attemptNumber = new AtomicInteger(0);
CommandSource savedCommandSource = persistenceRetry.executeSupplier(() -> {
// Critical: Refetch on retry attempts (not on first attempt)
CommandSource currentSource = finalCommandSource;
attemptNumber.getAndIncrement();
if (attemptNumber.get() > 1 && commandSource.getId() != null) {
log.info("Retrying command result save - attempt {} for command ID {}", attemptNumber, finalCommandSource.getId());
currentSource = commandSourceService.getCommandSource(finalCommandSource.getId());
}
// Update command source with results
currentSource.setResultStatusCode(SC_OK);
currentSource.updateForAudit(result);
currentSource.setResult(toApiResultJsonSerializer.serializeResult(result));
currentSource.setStatus(PROCESSED);
// Return saved command source
return commandSourceService.saveResultSameTransaction(currentSource);
});
// Command successfully saved
storeCommandIdInContext(savedCommandSource);
} catch (Exception e) {
// After all retries have been exhausted
log.error("Failed to persist command result after multiple retries for command ID {}", commandSource.getId(), e);
throw new CommandResultPersistenceException("Failed to persist command result after multiple retries", e);
}
result.setRollbackTransaction(null);
// When running inside an enclosing batch transaction, defer hook publication
// until after the transaction commits. This prevents webhooks from firing for
// commands that are subsequently rolled back when a later command in the batch
// fails (e.g. a withdrawal succeeds but its fee charge fails, rolling back both).
if (isEnclosingTransaction && TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result);
}
});
} else {
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result);
}
return result;
}
private void storeCommandIdInContext(CommandSource savedCommandSource) {
if (savedCommandSource.getId() == null) {
throw new IllegalStateException("Command source not saved");
}
// Idempotency filters and retry need this
fineractRequestContextHolder.setAttribute(COMMAND_SOURCE_ID, savedCommandSource.getId());
}
private void publishHookErrorEvent(CommandWrapper wrapper, JsonCommand command, ErrorInfo errorInfo) {
try {
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, gson.toJson(errorInfo));
} catch (Exception e) {
log.error("Failed to publish hook error event for entity: {}, action: {}", wrapper.entityName(), wrapper.actionName(), e);
}
}
private void exceptionWhenTheRequestAlreadyProcessed(CommandWrapper wrapper, String idempotencyKey, boolean retry) {
CommandSource command = commandSourceService.findCommandSource(wrapper, idempotencyKey);
if (command == null) {
return;
}
CommandProcessingResultType status = CommandProcessingResultType.fromInt(command.getStatus());
switch (status) {
case UNDER_PROCESSING -> {
Class<?> lastExecutionExceptionClass = retryConfigurationAssembler.getLastException();
if (lastExecutionExceptionClass == null
|| IdempotentCommandProcessUnderProcessingException.class.isAssignableFrom(lastExecutionExceptionClass)) {
throw new IdempotentCommandProcessUnderProcessingException(wrapper, idempotencyKey);
}
}
case PROCESSED -> throw new IdempotentCommandProcessSucceedException(wrapper, idempotencyKey, command);
case ERROR -> {
if (!retry) {
throw new IdempotentCommandProcessFailedException(wrapper, idempotencyKey, command);
}
}
default -> {
}
}
}
private void setIdempotencyKeyStoreFlag(boolean flag) {
fineractRequestContextHolder.setAttribute(IDEMPOTENCY_KEY_STORE_FLAG, flag);
}
public CommandProcessingResult fallbackExecuteCommand(Exception e) {
throw ErrorHandler.getMappable(e);
}
private NewCommandSourceHandler findCommandHandler(final CommandWrapper wrapper) {
NewCommandSourceHandler handler;
if (wrapper.isDatatableResource()) {
if (wrapper.isCreateDatatable()) {
handler = applicationContext.getBean("createDatatableCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isDeleteDatatable()) {
handler = applicationContext.getBean("deleteDatatableCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isUpdateDatatable()) {
handler = applicationContext.getBean("updateDatatableCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isCreate()) {
handler = applicationContext.getBean("createDatatableEntryCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isUpdateMultiple()) {
handler = applicationContext.getBean("updateOneToManyDatatableEntryCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isUpdateOneToOne()) {
handler = applicationContext.getBean("updateOneToOneDatatableEntryCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isDeleteMultiple()) {
handler = applicationContext.getBean("deleteOneToManyDatatableEntryCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isDeleteOneToOne()) {
handler = applicationContext.getBean("deleteOneToOneDatatableEntryCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isRegisterDatatable()) {
handler = applicationContext.getBean("registerDatatableCommandHandler", NewCommandSourceHandler.class);
} else {
throw new UnsupportedCommandException(wrapper.commandName());
}
} else if (wrapper.isNoteResource()) {
if (wrapper.isCreate()) {
handler = applicationContext.getBean("createNoteCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isUpdate()) {
handler = applicationContext.getBean("updateNoteCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isDelete()) {
handler = applicationContext.getBean("deleteNoteCommandHandler", NewCommandSourceHandler.class);
} else {
throw new UnsupportedCommandException(wrapper.commandName());
}
} else if (wrapper.isSurveyResource()) {
if (wrapper.isRegisterSurvey()) {
handler = applicationContext.getBean("registerSurveyCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isFullFilSurvey()) {
handler = applicationContext.getBean("fullFilSurveyCommandHandler", NewCommandSourceHandler.class);
} else {
throw new UnsupportedCommandException(wrapper.commandName());
}
} else if (wrapper.isLoanDisburseDetailResource()) {
if (wrapper.isUpdateDisbursementDate()) {
handler = applicationContext.getBean("updateLoanDisburseDateCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.addAndDeleteDisbursementDetails()) {
handler = applicationContext.getBean("addAndDeleteLoanDisburseDetailsCommandHandler", NewCommandSourceHandler.class);
} else {
throw new UnsupportedCommandException(wrapper.commandName());
}
} else if (wrapper.isInterestPauseResource()) {
if (wrapper.isInterestPauseCreateResource()) {
handler = applicationContext.getBean("createInterestPauseCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isInterestPauseUpdateResource()) {
handler = applicationContext.getBean("updateInterestPauseCommandHandler", NewCommandSourceHandler.class);
} else if (wrapper.isInterestPauseDeleteResource()) {
handler = applicationContext.getBean("deleteInterestPauseCommandHandler", NewCommandSourceHandler.class);
} else {
throw new UnsupportedCommandException(wrapper.commandName());
}
} else {
handler = commandHandlerProvider.getHandler(wrapper.entityName(), wrapper.actionName());
}
return handler;
}
@Override
public boolean validateRollbackCommand(final CommandWrapper commandWrapper, final AppUser user) {
user.validateHasPermissionTo(commandWrapper.getTaskPermissionName());
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(commandWrapper.taskPermissionName());
return isMakerChecker && !user.isCheckerSuperUser();
}
protected void publishHookEvent(final String entityName, final String actionName, JsonCommand command, final Object result) {
try {
final AppUser appUser = context.authenticatedUser(CommandWrapper.wrap(actionName, entityName, null, null));
final HookEventSource hookEventSource = new HookEventSource(entityName, actionName);
// TODO: Add support for publishing array events
if (command.json() != null) {
Type type = new TypeToken<Map<String, Object>>() {
}.getType();
Map<String, Object> myMap;
try {
myMap = gson.fromJson(command.json(), type);
} catch (Exception e) {
throw new PlatformApiDataValidationException("error.msg.invalid.json", "The provided JSON is invalid.",
new ArrayList<>(), e);
}
Map<String, Object> reqmap = new HashMap<>();
reqmap.put("entityName", entityName);
reqmap.put("actionName", actionName);
reqmap.put("createdBy", context.authenticatedUser().getId());
reqmap.put("createdByName", context.authenticatedUser().getUsername());
reqmap.put("createdByFullName", context.authenticatedUser().getDisplayName());
reqmap.put("request", myMap);
if (result instanceof CommandProcessingResult) {
CommandProcessingResult resultCopy = CommandProcessingResult
.fromCommandProcessingResult((CommandProcessingResult) result);
reqmap.put("officeId", resultCopy.getOfficeId());
reqmap.put("clientId", resultCopy.getClientId());
resultCopy.setOfficeId(null);
reqmap.put("response", resultCopy);
} else if (result instanceof ErrorInfo ex) {
reqmap.put("status", "Exception");
Map<String, Object> errorMap = new HashMap<>();
try {
errorMap = gson.fromJson(ex.getMessage(), type);
} catch (Exception e) {
errorMap.put("errorMessage", ex.getMessage());
}
errorMap.put("errorCode", ex.getErrorCode());
errorMap.put("statusCode", ex.getStatusCode());
reqmap.put("response", errorMap);
}
reqmap.put("timestamp", Instant.now().toString());
final String serializedResult = toApiJsonSerializer.serialize(reqmap);
final HookEvent applicationEvent = new HookEvent(hookEventSource, serializedResult, appUser,
ThreadLocalContextUtil.getContext());
applicationContext.publishEvent(applicationEvent);
}
} catch (Exception e) {
log.error("Failed to publish hook event for entity: {}, action: {}", entityName, actionName, e);
}
}
}
As the name suggests the execution of business logic is synchronous (mostly) due to this part of the architecture. This is not necessarily a problem (not immediately at least), but it’s nevertheless a central bottleneck in the system. Even more important: this service is responsible to route incoming commands to their respective handler classes which in turn execute functions on one or more business logic services. The payload of these commands are obviously not always the same… which is the main reason why we decided to use the lowest common denominator to be able to handle these various types and rendered all payloads as strings. This compromise bubbles now up in the REST API and the business logic layers (and actually everything in between).
Over the years we’ve also added additional features (e.g. idempotency guarantees for incoming write requests) that make it now very hard to reason about the execution flow. Testing the performance impact of such additions to the critical execution path even can’t be properly measured. Note: the current implementation of idempotency relies on database lookups (quite often, for each incoming request) and none of those queries are cached. If we wanted to store already processed requests (IDs) in a faster system (let’s Redis) then this can’t be done without major refactoring.
Architecture
This chapter outlines the new Command Query Responsibility Segregation (CQRS) implementation introduced in Apache Fineract. This architecture aims to improve the separation of concerns, testability, and maintainability of the write-side (command) operations.
| A proposal to cover the read-side (query) is in preparation. |
By shifting from our legacy type-less, synchronous command processing to an improved and extensible command-driven flow, we establish a robust pipeline for handling business operations, cross-cutting concerns (via hooks), and error handling (retries, fallbacks).
Core Components
-
command dispatcher: the entry point for all command operations
-
command hanlder: acts as the bridge between the incoming command and the specific business logic required to handle it
-
command hooks: interception points to execute cross-cutting logic without polluting the dispatcher or the handler; there are three types of hooks
-
before command hook
-
after command hook
-
error command hook
-
Commands
This is the write-side of the architecture…
Dispatcher
The synchronous command dispatcher is the default implementation that is used to dispatch and execute commands. We have already prototypes for other dispatch modes (asynchronous and non-blocking), but they need extensive testing before they can be used in production. When ready they can be dropped in without any further code changes. The default synchronous dispatcher is automatically disabled if an alternative command dispatcher implementation is provided by Spring Framework’s dependency injection mechanism.
package org.apache.fineract.command.core;
import java.util.function.Supplier;
public interface CommandDispatcher {
<REQ, RES> Supplier<RES> dispatch(Command<REQ> command);
}
Synchronous
TBD
Asynchronous
Early phase of development. There is a prototype available in module fineract-command-async, but needs extensive testing before it can be used in production.
|
TBD
LMAX Disruptor
Early phase of development. There is a prototype available in module fineract-command-disruptor, but needs extensive testing before it can be used in production.
|
TBD
Chronicle Queue
Early phase of development. No prototype available yet, but most likely module fineract-command-chronicle will be used.
|
TBD
Command Handler
TBD
Handler Manager
package org.apache.fineract.command.core;
@FunctionalInterface
public interface CommandHandlerManager {
<REQ, RES> RES handle(Command<REQ> command);
}
Handler
package org.apache.fineract.command.core;
import com.google.common.reflect.TypeToken;
import lombok.SneakyThrows;
public interface CommandHandler<REQ, RES> {
RES handle(Command<REQ> command);
@SneakyThrows
default RES fallback(Command<REQ> command, Throwable t) {
// NOTE: any command handler can override this default to implement more specialized fallbacks.
throw t;
}
default boolean matches(Command<REQ> command) {
TypeToken<REQ> handlerType = new TypeToken<>(getClass()) {};
return handlerType.getRawType().isAssignableFrom(command.getPayload().getClass());
}
}
Command Hooks
TBD
Hook Manager
package org.apache.fineract.command.core;
public interface CommandHookManager {
void before(Command command);
void after(Command command, Object response);
void error(Command command, Throwable error);
}
Before Hooks
TBD
package org.apache.fineract.command.core;
@FunctionalInterface
public interface CommandHookBefore<REQ> {
void onBefore(Command<REQ> command);
}
After Hooks
TBD
package org.apache.fineract.command.core;
@FunctionalInterface
public interface CommandHookAfter<REQ, RES> {
void onAfter(Command<REQ> command, RES response);
}
Error Hooks
TBD
package org.apache.fineract.command.core;
@FunctionalInterface
public interface CommandHookError<REQ> {
void onError(Command<REQ> command, Throwable error);
}
Command Audit
TBD
Refactoring Instructions
| The content of this section will be probably distributed to the other parts of this chapter. Just to say: this is work in progress! |
General
POJOs/DTOs
Please make sure that all POJOs (for request and response types) have a similar structure. They should:
-
use Lombok to reduce boilerplate code
-
make sure that all annotations are always in the same order as shown below
-
avoid
recordsfor now (we might or might not migrate later from Lombok torecord) -
each class should implement
java.io.Serializable -
each class should contain a serialization version set to
1L
Example:
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
public class DummyRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotEmpty(message = "{org.apache.fineract.dummy.content.not-empty}")
private String content;
}
Package structure (org.apache.fineract.xxx.*)
Please make sure to always follow this package structure pattern (aka layered architecture):
-
api: contains all REST JAX-RS resource classes (later Spring Web MVC controllers) -
command: primarily used for command specific child class implementations oforg.apache.fineract.command.core.Command(see section "Command Dispatcher preserving Type Information") -
data: contains all DTOs (request and response types) -
domain: contains all entity/table mapping classes -
handler: contains all command handlers -
service: contains business logic services -
mapping: contains MapStruct interfaces -
security: might contain later so called Spring Security "authorization managers" for more complex use cases -
serialization: technically we should not need this package anymore after we are done with the refactorings; in theory there could be very complex data structures that are not easily digestable by Jackson; for those case we could still use this package to add de-/serialization helpers (Jackson provides a proper API for this). We should avoid complex structures! -
starter: Spring Java configuration that allows us to make Fineract customizable (make parts of the system replaceable) -
validation: contains custom Jakarta Validation components/annotations
In general avoid too many nesting levels. Ideally we would only have one additional underneath the base package (org.apache.fineract.*) where in turn only the above package patterns are used.
REST API
Read Requests
The read requests (HTTP GET) usually only require to refactor (if at all) the business logic services in case they don’t return proper Java POJOs. More often than not service functions have a return type of org.apache.fineract.infrastructure.core.data.CommandProcessingResult. This type belongs to the legacy command processing mechanics and basically is just a thin wrapper around a JSON object that is stored in a string variable.
Please name all return types for these read requests consistently. We propose to add always a suffix Response; this makes it immediately clear that we are dealing with a data transfer object (vs database mapping entities) and that it’s something that we return to the clients (vs incoming requests). Historically we’ve used Data as a suffix, but this doesn’t make it clear if it’s used as input or output. Let’s see an example.
This is how the legacy code used to look like:
public class BusinessDateApiResource {
// ...
@GET
@Consumes({ MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List all business dates", description = "")
public String getBusinessDates(@Context final UriInfo uriInfo) {
securityContext.authenticatedUser().validateHasReadPermission("BUSINESS_DATE");
final List<BusinessDateData> foundBusinessDates = this.readPlatformService.findAll();
ApiRequestJsonSerializationSettings settings = parameterHelper.process(uriInfo.getQueryParameters());
return this.jsonSerializer.serialize(settings, foundBusinessDates);
}
// ...
}
This is how the refactored code should look like; there is absolutely no need to manually serialize:
public class BusinessDateApiResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List all business dates", description = "")
public List<BusinessDateResponse> getBusinessDates() {
return businessDateMapper.mapFetchResponse(this.readPlatformService.findAll());
}
On occasions it could be that there could be a service read function that returns a collection of some data structure. In those cases it is fine to use a DTO class with suffix Data. In the current code base we have all kinds of variations like DTO, Dto, DATA and so on. Make sure we are consistent with the naming and let’s use the Data suffix in these cases.
Very often you’ll see the use of PlatformSecurityContext (which is a wrapper around Spring Security’s org.springframework.security.core.context.SecurityContextHolder) to validate read permissions. Avoid this please! Fortunately, the fix is relatively easy. We just need to introduce an "Ant matcher" configuration in the Spring web security configuration (org.apache.fineract.infrastructure.core.config.SecurityConfig). The change should look something like this:
@EnableMethodSecurity
public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/password/forgot")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll()
// businessdate
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_BUSINESS_DATE")
basicAuthenticationEntryPoint(), toApiJsonSerializer, configurationDomainService, cacheWritePlatformService,
The nice side effect is that we’ll have all security rules that we are enforcing in one place. This will enable more flexibility with customizations around security (will be handled in the modular security proposal).
Write Requests
The write requests (HTTP PUT and POST) are the ones that affect the command processing infrastructure. The JSON body in pretty much all nthe legacy cases is probably represented as a simple string variable that gets passed to a command wrapper class (org.apache.fineract.commands.domain.CommandWrapper). All these variables need to be replaced by proper POJO classes that represent the request body. You can get hints how these classes should look like by checking the OpenAPI dummy classes we created to re-introduce the type information for the OpenAPI descriptor (see Swagger annotations).
Usually you’ll have 2 classes to take care of: requests (incoming input parameters) and responses (outgoing results). These classes are technically DTOs (data transfer objects) or VOs (value objects) and to make them more recognizable as such I would start standardizing the naming. E.g. if I have a use case aka REST endpoint that creates client data we would name that DTO class CreateClientRequest; and if any data needs to be sent back to the client then that class should be called CreateClientResponse. This is a very simple mechanic that helps identifying immediately what is what and doesn’t require the developers to come up with any naming stunts. Do not try to re-use these DTOs on multiple endpoints, it’s not worth it. Create a separate DTO for each endpoint. Nice side effect: all this becomes then nicer to read (both in Java code and in the OpenAPI descriptor and the resulting Asciidoc documentation) and this will make it less likely that names are clashing in the OpenAPI descriptor (and during code generation for the Fineract Java Client).
Only new thing that needs to be injected in those REST API resource classes (JAX-RS) or controllers (Spring Web MVC) is the CommandDispatcher component that allows you to send requests to the command dispatcher which in turn will be processed by handlers that eventually call one or more business logic services. Results are sent back to the dispatcher and are received in the REST API aka returned by CommandDispatcher as a java.util.Supplier functional interface, i.e. there is only one function to be implemented (get()). This small abstraction helps us to standardize how the results are delivered (synchronous, asynchronous, non-blocking) and maintain the same internal API.
Jakarta Validation
TBD
Example POJO:
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
public class DummyRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotEmpty(message = "{org.apache.fineract.dummy.content.not-empty}")
private String content;
}
Add your translation keys to fineract-validation/src/main/resources/ValidationMessages.properties
Future: Spring Web MVC
We will not do this right now, but later we might move away from JAX-RS and introduce Spring Web MVC which is mostly just changing the annotations (e.g. @POST vs @PostRequest). This process should be pretty straight forward and could be even (semi-) automated by using OpenRewrite recipes. Once we have proper POJOs for requests and response we can also use Jakarta Validation annotations to validate the request content. You’ll see that currently we have an explicit service injected everywhere that does those checks manually (that includes some JSON parsing); very tedious and work intensive and hard to refactor. The first setup with Jakarta Validation take a little longer, but once that is working for one use case it should be pretty much rinse and repeat for the rest. Occasionally for very exotic validations there might be a need to implement custom validations. Another advantage of this approach: we can finally add the proper internationalized error messages on the server side, not only the translation keys. This removes a ton of code on the client side and ensures that the clients always have the correct messages. Read the Jakarta Validation documentation on how that works and use Hibernate Validation to implement this (pretty much the defacto standard library).
Command Dispatcher preserving Type Information
CommandDispatcher needs to be injected where needed (usually only REST API controllers). By default everything is configured for sync(-hronous) processing. Other modes (async, disruptor) can be easily configured via application.properties, but need more testing and are out of scope for now. As you can see the command object just contains some of metadata (createdAt, username etc.) and the payload aka request object. Obviously we have different use cases so that payload attribute is defined as a generic type. Please check the unit test code how to create a command object with payload/request properly.
public class DummyApiResource {
// ...
@GET
DummyResponse dummy(@HeaderParam("x-fineract-request-id") UUID requestId, @HeaderParam("Fineract-Platform-TenantId") String tenantId, DummyRequest request) {
var command = new DummyCommand();
command.setId(requestId);
command.setPayload(request);
tenantService.set(tenantId);
Supplier<DummyResponse> result = pipeline.send(command);
return result.get();
}
// ...
}
Make sure to create command specific child class of the generic (and abstract) org.apache.fineract.command.core.Command class. Example:
@Data
@EqualsAndHashCode(callSuper = true)
public class DummyCommand extends Command<DummyRequest> {}
If everything is done correctly then no type information will be lost and everything can be parsed without further help by the Jackson parser. Eventually all handcrafted boilerplate JSON parsing code can be dumped. In rare cases we might need to add de-/serialization helper classes (a concept provided by Jackson) to help the parser identify the types properly.
When we use Spring Web MVC unit testing (actually integration testing) gets very easy. Just a couple of annotations and you can execute them pretty much like simple unit tests in your IDE (because Spring Web MVC is a first class citizen obviously in the Spring Framework). NONE of that handcrafted client code like in our current integration tests is required; the tests should be easier to refactor and easier to understand. Writing those tests is optional for now, because we have already over 1000 integration tests. After all those refactorings the REST API should be 100% compatible to upstream even if it uses a different technology stack. If everything passes those ""old"" integration tests then we can be pretty confident that we didn’t mess something up. Migrating the integration tests to simpler Spring Web tests can be done later."
Command Handlers
In the current CQRS implementation we have already a concept called command handlers. Those handlers are responsible to receive the command objects with their (request) payloads and transform the requests as needed an pass them to one or more business logic services. The refactoring of those handlers should not be too complicated, they just need to implement the Java interface CommandHandler. Look at my test samples to see how the implementation details look like.
The old handlers look somewhat like this:
@Service
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
public class CreatePaymentTypeCommandHandler implements NewCommandSourceHandler {
private final PaymentTypeWriteService paymentTypeWriteService;
@Autowired
public CreatePaymentTypeCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
this.paymentTypeWriteService = paymentTypeWriteService;
}
@Override
@Transactional
public CommandProcessingResult processCommand(JsonCommand command) {
return this.paymentTypeWriteService.createPaymentType(command);
}
}
-
and this is how the refactored handler could look like:
@Slf4j
@RequiredArgsConstructor
@Component
public class PaymentTypeCreateCommandHandler implements CommandHandler<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
private final PaymentTypeWriteService paymentTypeWriteService;
@Override
public PaymentTypeCreateResponse handle(Command<PaymentTypeCreateRequest> command) {
// TODO: refactor business logic service to accept properly typed request objects as input
return paymentTypeWriteService.createPaymentType(command.getPayload());
}
}
Dispatch Types
Sync Execution
This is the default execution mode. Performance is to be expected on par with the current legacy implementation all tests need to work with this mode.
Skip: Async Execution
Already included in the current implementation. Just needs a proper configuration in application.properties (see unit tests). One thing that might need some additional coding: the use of thread local variables in multi threaded environments needs some special care to properly work (we use this to identify the current tenant). Also: we should upgrade to JDK 21 and make use of virtual threads (very easy in Spring Boot, simple configuration property). This allows for massive parallel execution that is not bound by physical CPU cores without (take this with a pinch of salt) performance penalties (read: use millions of threads).
Skip: Non-blocking Execution
Already included in the current implementation and configurable. I’ve tried a couple of combinations with LMAX disruptor, but this needs more testing to figure out optimal an configuration. Would be worth to create more realistic JMH benchmarks. I have added a simple one to get a first idea how the mechanics are working.
Maker-Checker
This will be part of a separate proposal. The only related feature I’ve added here was command persistence so that you can save commands for deferred execution. Other than that I want to keep this concept (command processing) clean and avoid mixing to many concepts/concerns in one place. This ensures better maintainability. Maker-checker is actually a security related concept and should probably be handled with the proper Spring Security APIs (AuthorizationManager interface comes to mind). But again, different proposal and can be ignored here for now.
When we encounter the first need to take care of Maker-Checker then let’s figure out a solution that has minimal impact and do a proper cleanup when the Maker-Checker proposal is available.
[!NOTE] The best guess is that Maker-Checker will probably implemented
Features
This section covers specific features and functionality available in Apache Fineract.
Capitalized Income
Overview
Capitalized Income (formerly referred to as Origination Fee) in Apache Fineract is a fee-based or interest-based income item that is added to the principal of a loan and amortized over time. It is designed to be applied at disbursement and is treated as part of the loan’s principal for repayment and interest calculations.
Purpose
This functionality enables financial institutions to:
-
Recognize deferred income (fees or interest) systematically over the loan term
-
Align accounting practices with regulatory requirements
-
Improve income recognition accuracy
Supported Loan Type
|
Capitalized Income is only supported for:
Other loan schedule types and transaction processing strategies are not supported. |
Configuration at Loan Product Level
Capitalized income must be configured on the loan product.
The configuration options include:
-
Enable Capitalized Income: Boolean toggle (default: disabled)
-
Calculation Mode: Only "Flat" is currently supported
-
Later "Percentage based" can be introduced
-
-
Amortization Strategy: Only "EQUAL_AMORTIZATION" is supported
-
Daily equal portions are recognized over the life of the loan
-
Later other strategies can be introduced
-
-
Income Type: Specifies allocation rule. Defines which "balance category" to be used.
|
In Fineract, balance of a transaction is either: Principal, Fee, Penalty, Interest or overpayment |
Options:
* FEE (default)
* INTEREST
GL Mapping
Required GL Account mappings when Capitalized Income is enabled:
-
Deferred Income (Liability):
deferredIncomeLiabilityAccountId- mandatory when enabled -
Income from Capitalization (Income):
incomeFromCapitalizationAccountId- mandatory when enabled
|
Both GL accounts become mandatory when |
Configuration Dependencies
When enableIncomeCapitalization is set to true, all following parameters become mandatory:
-
capitalizedIncomeCalculationType- must be "FLAT" -
capitalizedIncomeStrategy- must be "EQUAL_AMORTIZATION" -
capitalizedIncomeType- must be "FEE" or "INTEREST" -
deferredIncomeLiabilityAccountId- must reference a valid GL account -
incomeFromCapitalizationAccountId- must reference a valid GL account
Behavior and Calculations
-
Capitalized income is added via API on or after the first disbursement date
-
It is treated as a principal portion, recalculating the repayment schedule accordingly
-
Interest and amortization schedules are updated to include the capitalized income amount
-
Validated using formula:
(Total Disbursed + Current Capitalized Income + New Transaction Amount) ≤ Max Amount, where Max Amount depends on loan product configuration: ifallowApprovedDisbursedAmountsOverApplied = trueusesgetOverAppliedMax(loan), otherwise usesgetApprovedPrincipal()
Daily Amortization
-
Recognized daily using the configured strategy
-
Recognized portions move from Deferred Income to Income from Capitalization
Special Handling
-
Preclosure: Remaining balance recognized in full on the preclosure date
-
Charge-off: Amortization stops and remaining balance is charged off
Transaction Types Introduced
-
Capitalized Income
-
Capitalized Income Amortization
-
Capitalized Income Adjustment
-
Capitalized Income Amortization Adjustment
Capitalized Income Transaction
The Capitalized Income transaction in Apache Fineract performs the following actions:
-
Adds a specified amount to the loan principal
-
Considered a deferred income item (such as a fee or interest)
-
Booked as part of the loan’s principal
-
Added post-disbursement and only if the loan type supports it (currently: Progressive Loans)
-
-
Creates a distinct loan transaction
-
Separately tracked with its own transaction type ("Capitalized Income")
-
Not merged with disbursements or repayments
-
-
Updates the loan schedule
-
Recalculates amortization and interest schedule to include the added amount in the outstanding principal
-
-
Triggers accounting entries
-
Debits "Loan Portfolio" (Asset)
-
Credits "Deferred Income" (Liability)
-
Does not recognize income upfront
-
-
Initiates daily amortization
-
Source for daily income recognition through "Capitalized Income Amortization" transactions
-
Progressively converts the deferred amount to recognized income
-
Accounting Entries
| Scenario | Debit |
|---|---|
Credit |
Capitalized Income |
Loan Portfolio (Asset) |
Deferred Income (Liability) |
Capitalized Income Amortization
A Capitalized Income Amortization transaction in Apache Fineract does the following:
-
Recognizes Deferred Income Over Time: Transfers a portion of the capitalized income (originally posted as a liability) into recognized income (posted as interest or fee income), based on a configured daily amortization strategy.
-
Daily Posting: The system automatically creates this transaction each day from the date of capitalized income until the loan maturity or until the full amount is amortized. This is handled by a background job during the COB (Close of Business) process.
-
Uses Equal Amortization: The default and only supported strategy is Equal Amortization, which divides the total capitalized income evenly over the remaining number of days until the loan matures.
Accounting Entries
| Scenario | Debit |
|---|---|
Credit |
Daily amortization |
Deferred Income (Liability) |
Income from Capitalization (Income) |
Stops on Events
-
Preclosure: Triggers final amortization for remaining unrecognized income
-
Charge-off: Halts further amortization; the remaining deferred income is charged off
|
Reversal Handling: If the original Capitalized Income transaction is reversed, all associated amortization transactions are also reversed via "Capitalized Income Amortization Adjustment" transactions. |
Capitalized Income Adjustment
A Capitalized Income Adjustment transaction in Apache Fineract serves to reduce the balance of an existing capitalized income transaction.
Purpose
-
Correct overcharged or misposted capitalized income amounts
-
Reflect fee waivers or negotiated reductions
-
Support backdated corrections if needed
Transaction Behavior
-
It is a credit-type transaction, reducing the capitalized income balance
-
Treated similarly to other credit transactions and follows a defined allocation strategy
-
Can be backdated, but not dated before the original capitalized income transaction
Validation Rules
-
The adjustment amount must not exceed the remaining amount (original capitalized income amount minus total previous adjustments)
-
Adjustment is linked to a specific Capitalized Income transaction (by ID)
-
Multiple adjustments can be made against the same original transaction
-
Adjustments can be reversed if needed
Accounting Entries
| Scenario | Debit | Credit |
|---|---|---|
Adjustment ≤ unrecognized balance |
Deferred Income (Liability) |
Loan Portfolio (Asset) |
Adjustment > unrecognized balance |
Deferred Income (Liability) |
Loan Portfolio (Asset) |
Business Event Triggers
-
Triggers "Capitalized Income Adjustment" event
-
Updates loan balance and possibly loan status depending on impact
Impact
-
Reduces amortization basis
-
May modify future amortization amounts
-
Repayment schedule is not affected directly unless recalculated manually
Capitalized Income Amortization Adjustment
A Capitalized Income Amortization Adjustment in Apache Fineract is a special transaction type used to reverse previously recognized income from capitalized income amortization.
Purpose
-
Automatically generated when a Capitalized Income transaction is reversed or when backdated Capitalized Income Adjustment affects amortization balances
-
Reverses all already recognized portions (amortized income) linked to the original Capitalized Income transaction
When It Occurs
-
Created by daily amortization (COB) or final amortization (triggered on loan closure, charge-off, or by any backdated transaction that affects capitalized income balances)
-
Reverses previously recognized income when amortization needs to be adjusted
-
Restores Deferred Income balances and reverses income recognition
Accounting Entries
| Transaction Type | Debit | Credit |
|---|---|---|
Capitalized Income Amortization Adjustment |
Income from Capitalization (Income) |
Deferred Income (Liability) |
Key Characteristics
-
System-Generated Only: Cannot be created manually by API or UI
-
Ensures Accounting Integrity: Keeps amortized and unrecognized balances aligned after reversals
-
Non-monetary transaction - does not trigger balance changed or status update events
Business Events
-
Triggers a new business event: Capitalized Income Amortization Adjustment
API Endpoints
Configure Capitalized Income on Loan Product
-
Endpoint:
/loanproducts -
Method:
POST
{
...
"enableIncomeCapitalization": true, // Mandatory
"capitalizedIncomeCalculationType": "FLAT", // Mandatory when enabled
"capitalizedIncomeStrategy": "EQUAL_AMORTIZATION", // Mandatory when enabled
"capitalizedIncomeType": "FEE", // Mandatory when enabled
"deferredIncomeLiabilityAccountId": 123, // Mandatory when enabled
"incomeFromCapitalizationAccountId": 456 // Mandatory when enabled
}
Add Capitalized Income
-
Endpoint:
/loans/{loanId}/transactions?command=capitalizedIncome -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions?command=capitalizedIncome -
Method:
POST
{
"transactionDate": "2025-05-01", // Mandatory
"dateFormat": "yyyy-MM-dd", // Mandatory
"locale": "en", // Mandatory
"transactionAmount": 100.0, // Mandatory
"paymentTypeId": 1, // Optional
"note": "Capitalized income fee", // Optional
"externalId": "CINCOME-001" // Optional
}
Get Capitalized Income Amortization Info
-
Endpoint:
/loans/{loanId}/deferredincome -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/deferredincome -
Method:
GET
Response Body
{
"capitalizedIncomeData": [
{
"amount": 50.0, // Total capitalized income amount
"amortizedAmount": 1.1, // Amount already amortized
"unrecognizedAmount": 48.9, // Amount not yet amortized
"amountAdjustment": 0.0, // Any adjustments made
"chargedOffAmount": 0.0 // Amount charged off (if applicable)
}
]
}
Add Capitalized Income Adjustment
-
Endpoint:
/loans/{loanId}/transactions/{capitalizedIncomeTransactionId}?command=capitalizedIncomeAdjustment -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions/{capitalizedIncomeTransactionId}?command=capitalizedIncomeAdjustment -
Method:
POST
{
"transactionDate": "2025-05-01", // Mandatory
"dateFormat": "yyyy-MM-dd", // Mandatory
"locale": "en", // Mandatory
"transactionAmount": 50.0, // Mandatory
"paymentTypeId": 1, // Optional
"note": "Capitalized income fee", // Optional
"externalId": "CINCOMEADJ-001" // Optional
}
Response Body
{
"resourceId": 1,
"resourceExternalId": "CINCOMEADJ-001"
}
Capitalized Income Template API (to retrieve limits)
-
Endpoint:
/loans/{loanId}/transactions/template?command=capitalizedIncome -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions/template?command=capitalizedIncome -
Method:
GET
{
"paymentTypeOptions": [], // List of available payment types
"currency": {...}, // Currency configuration
"date": [2025, 5, 29], // Return the current date
"amount": 0 // Return the maximum amount that can be capitalized (approved amount - disbursed amount - capitalized income)
}
Capitalized Income Adjustment Template API (to retrieve limits)
-
Endpoint:
/loans/{loanId}/transactions/template?command=capitalizedIncomeAdjustment&transactionId={capitalizedIncomeTransactionId} -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions/template?command=capitalizedIncomeAdjustment&transactionId={capitalizedIncomeTransactionId} -
Method:
GET
{
"paymentTypeOptions": [], // List of available payment types
"currency": {...}, // Currency configuration
"date": [2025, 5, 29], // Return the current date
"amount": 0 // Return the maximum amount that can be adjusted (capitalized income - adjustment)
}
Accounting Entries
| Transaction Type | Debit | Credit |
|---|---|---|
Capitalized Income |
Loan Portfolio (Asset) |
Deferred Income (Liability) |
Capitalized Income Amortization |
Deferred Income (Liability) |
Income from Capitalization (Income) |
Capitalized Income Adjustment |
Deferred Income (Liability) |
Loan Portfolio (Asset) |
Capitalized Income Amortization Adjustment |
Income from Capitalization (Income) |
Deferred Income (Liability) |
Business Events
Triggered for Capitalized Income
-
LoanCapitalizedIncomeTransactionCreatedBusinessEvent -
LoanBalanceChangedBusinessEvent
Daily Amortization
-
LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent -
LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent
Capitalized Income Adjustment
-
LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent -
LoanBalanceChangedBusinessEvent
Reversal
-
LoanAdjustTransactionBusinessEvent
Database Structure
Configuration
Stored on Loan Product (m_product_loan)
| Field | Data Type | Description |
|---|---|---|
|
|
Enable capitalized income feature (default: |
|
|
Calculation method (ENUM: |
|
|
Amortization strategy (ENUM: |
|
|
Income type (ENUM: |
Stored on Loan (m_loan)
| Field | Data Type | Description |
|---|---|---|
|
|
Enable capitalized income feature (default: |
|
|
Calculation method (ENUM: |
|
|
Amortization strategy (ENUM: |
|
|
Income type (ENUM: |
Balances
On Loan
| Field | Data Type | Description |
|---|---|---|
|
|
Total capitalized income amount (nullable) |
|
|
Total adjustment amount (nullable) |
Capitalized Income Balance (m_loan_capitalized_income_balance)
Each capitalized income has its own balance (1 row for each transaction)
| Field | Data Type | Description |
|---|---|---|
|
|
Unique identifier (Primary Key) |
|
|
Version for optimistic locking |
|
|
Associated loan ID (Foreign Key, NOT NULL) |
|
|
Associated loan transaction ID (Foreign Key, NOT NULL) |
|
|
Capitalized income transaction amount (NOT NULL) |
|
|
Capitalized income transaction date (NOT NULL) |
|
|
Amortization - not yet recognized amount (NOT NULL) |
|
|
Charged-off balance (nullable) |
|
|
Total adjustment amount (nullable) |
|
|
Audit field - user who created the record |
|
|
Creation timestamp (UTC) |
|
|
Last modifier user ID |
|
|
Last modification timestamp (UTC) |
Constraints
-
Foreign Key Constraints:
-
loan_idreferencesm_loan(id) -
loan_transaction_idreferencesm_loan_transaction(id) -
created_byreferencesm_appuser(id) -
last_modified_byreferencesm_appuser(id)
-
Notes
|
Buy Down Fee
Overview
Buy Down Fee is a specialized fee mechanism in Apache Fineract that allows financial institutions to collect upfront fees from borrowers to reduce their effective interest rate over the loan term. This feature is particularly designed for 0% interest "buy down" loans where a merchant fee is collected and amortized into interest/fee income over the life of the loan.
The key characteristic of Buy Down Fee is that the amortized fee is NOT visible to the customer and NOT affecting the repayment schedule - it operates as a background process for proper revenue recognition while maintaining transparency in customer-facing loan terms.
Purpose
This functionality enables financial institutions to:
-
Interest Rate Reduction: Borrowers can reduce their effective interest rate by paying an upfront fee
-
Merchant Fee Support: Enables 0% interest loan products with merchant-paid fees
-
Revenue Recognition: Provides controlled amortization of fee income over the loan term
-
Customer Transparency: Fee amortization is invisible to customers, maintaining clean loan presentation
-
Accounting Integration: Proper journal entries and accounting treatment for fee transactions
Supported Loan Type
|
Buy Down Fee is only supported for loans that have all of the following:
Other transaction processing strategies or loan schedule types are not supported. |
Configuration at Loan Product Level
Buy Down Fee must be configured on the loan product.
The configuration options include:
-
Enable Buy Down Fee: Boolean toggle (
enableBuyDownFee) (default: disabled) -
Calculation Mode: Only "Flat" is currently supported (
buyDownFeeCalculationType) -
Amortization Strategy: Only "EQUAL_AMORTIZATION" is supported (
buyDownFeeStrategy)-
Daily equal portions are recognized over the life of the loan
-
-
Income Type: Specifies allocation rule (
buyDownFeeIncomeType)
Options:
* FEE
* INTEREST
GL Mapping
Required GL Account mappings when Buy Down Fee is enabled:
-
Buy Down Expense Account:
buyDownExpenseAccountId- mandatory when enabled -
Deferred Income Liability Account:
deferredIncomeLiabilityAccountId- mandatory when enabled -
Income from Buy Down Account:
incomeFromBuyDownAccountId- mandatory when enabled
|
All GL accounts become mandatory when |
Configuration Dependencies
When enableBuyDownFee is set to true, all following parameters become mandatory:
-
buyDownFeeCalculationType- must be "FLAT" -
buyDownFeeStrategy- must be "EQUAL_AMORTIZATION" -
buyDownFeeIncomeType- must be "FEE" or "INTEREST" -
buyDownExpenseAccountId- must reference a valid GL account -
deferredIncomeLiabilityAccountId- must reference a valid GL account -
incomeFromBuyDownAccountId- must reference a valid GL account
Validation Rules
Product Level Validations
-
Buy Down Fee can only be enabled for Progressive Loan products
-
When
enableBuyDownFeeistrue, all related parameters become mandatory -
Calculation type must be
FLAT(other types not yet supported) -
Strategy must be
EQUAL_AMORTIZATION(other strategies not yet supported) -
Income type must be either
FEEorINTEREST -
Both expense and income GL accounts must be provided and valid
-
GL accounts must have correct account types (EXPENSE and INCOME respectively)
-
deferredIncomeLiabilityAccountIdmapping requirements:-
Must be a valid LIABILITY type GL account
-
Represents temporary holding of not-yet-recognized income
-
Used to track unamortized Buy Down Fee portion
-
Cannot be zero or null when Buy Down Fee is enabled
-
Transaction Level Validations
-
Buy Down Fee transactions can only be added to active loans
-
Transaction amount must be positive (greater than zero)
-
Transaction date cannot be before the first disbursement date
-
Loan must have Buy Down Fee enabled in its product configuration
-
Transaction date cannot be in the future
-
Client/Group must be active
-
Loan must be disbursed
-
Multiple Buy Down Fee transactions per loan are supported
Adjustment Validations
-
Original Buy Down Fee transaction must exist
-
Adjustment amount cannot exceed remaining balance (amount - previous adjustments)
-
Adjustment date cannot be before original transaction date
-
Cannot reverse Buy Down Fee transaction if it has linked adjustments
Buy down fee adjustments are related to the buy down fee transaction (they have relation with type ADJUSTMENT between them), and there can be more than one adjustment to the same buy down fee transaction.
Error Responses
Common Error Codes
-
buy.down.fee.not.enabled: Buy Down Fee not enabled for loan product -
cannot.be.before.first.disbursement.date: Invalid transaction date -
cannot.be.more.than.remaining.amount: Adjustment exceeds balance -
loan.transaction.not.found: Referenced transaction not found
Error Response Example
{
"developerMessage": "Buy down fee is not enabled for this loan product",
"httpStatusCode": "400",
"defaultUserMessage": "Buy down fee is not enabled for this loan product",
"userMessageGlobalisationCode": "buy.down.fee.not.enabled",
"errors": [
{
"developerMessage": "Buy down fee is not enabled for this loan product",
"defaultUserMessage": "Buy down fee is not enabled for this loan product",
"userMessageGlobalisationCode": "buy.down.fee.not.enabled",
"parameterName": null
}
]
}
Configuration Error Messages
-
"Buy Down Fee calculation type is required": Provide
buyDownFeeCalculationTypewhen enabling Buy Down Fee -
"Buy Down Fee strategy is required": Provide
buyDownFeeStrategywhen enabling Buy Down Fee -
"Buy Down Fee income type is required": Provide
buyDownFeeIncomeTypewhen enabling Buy Down Fee -
"Buy Down expense account is required": Provide valid
buyDownExpenseAccountId -
"Deferred income liability account is required": Provide valid
deferredIncomeLiabilityAccountId -
"Income from Buy Down account is required": Provide valid
incomeFromBuyDownAccountId -
"Buy Down fees can only be added to active loans": Ensure loan status is ACTIVE before adding Buy Down Fee transactions
Behavior and Calculations
-
Buy Down Fee transactions can only be added to active loans
-
Transaction amount must be positive (greater than zero)
-
Transaction date cannot be before the first disbursement date
-
Loan must have Buy Down Fee enabled in its product configuration
Daily Amortization
-
Recognized daily using the configured strategy
-
Recognized portions move from Deferred Income to Income from Buy Down
Special Handling
-
Preclosure: Remaining balance recognized in full on the preclosure date
-
Charge-off: Amortization stops and remaining balance is charged off
Transaction Types Introduced
-
Buy Down Fee
-
Buy Down Fee Amortization
-
Buy Down Fee Adjustment
-
Buy Down Fee Amortization Adjustment
Buy Down Fee Transaction
The Buy Down Fee transaction in Apache Fineract performs the following actions:
-
Creates a distinct loan transaction
-
Separately tracked with its own transaction type ("Buy Down Fee")
-
Not merged with disbursements or repayments
-
-
Triggers accounting entries
-
Debits "Buy Down Expense Account" (Expense)
-
Credits "Deferred Income Liability Account" (Liability)
-
Does not recognize income upfront
-
-
Initiates daily amortization
-
Source for daily income recognition through "Buy Down Fee Amortization" transactions
-
Progressively converts the deferred amount to recognized income
-
Accounting Entries
| Scenario | Debit |
|---|---|
Credit |
Buy Down Fee |
Buy Down Expense Account |
Deferred Income Liability Account |
Buy Down Fee Amortization
A Buy Down Fee Amortization transaction in Apache Fineract does the following:
-
Recognizes Deferred Income Over Time: Transfers a portion of the buy down fee (originally posted as a liability) into recognized income, based on a configured daily amortization strategy.
-
Daily Posting: The system automatically creates this transaction each day from the date of buy down fee until the loan maturity or until the full amount is amortized. This is handled by a background job during the COB (Close of Business) process.
-
Uses Equal Amortization: The default and only supported strategy is Equal Amortization, which divides the total buy down fee evenly over the remaining number of days until the loan matures.
Accounting Entries
| Scenario | Debit |
|---|---|
Credit |
Daily amortization |
Deferred Income Liability Account |
Income from Buy Down Account |
Stops on Events
-
Preclosure: Triggers final amortization for remaining unrecognized income
-
Charge-off: Halts further amortization; the remaining deferred income is charged off using Charge-off Expense Account
Buy Down Fee Adjustment
A Buy Down Fee Adjustment transaction in Apache Fineract serves to reduce the balance of an existing buy down fee transaction.
Purpose
-
Correct overcharged or misposted buy down fee amounts
-
Reflect fee waivers or negotiated reductions
-
Support backdated corrections if needed
Transaction Behavior
-
It is a credit-type transaction, reducing the buy down fee balance
-
Can be backdated, but not dated before the original buy down fee transaction
Validation Rules
-
The adjustment amount cannot exceed remaining balance (amount - previous adjustments)
-
Adjustment date cannot be before original transaction date
-
Adjustment date cannot be before disbursement date
-
Adjustment date cannot be in the future
-
Cannot reverse Buy Down Fee transaction if it has linked adjustments
-
Loan must be in Active, Closed, or Overpaid status
-
Buy Down Fee must be enabled on the loan product
-
Loan must use Progressive Schedule
Accounting Entries
| Scenario | Debit | Credit |
|---|---|---|
Buy Down Fee Adjustment |
Deferred Income Liability Account |
Buy Down Expense Account |
Buy Down Fee Amortization Adjustment
A Buy Down Fee Amortization Adjustment in Apache Fineract is a special transaction type used to reverse previously recognized income from buy down fee amortization.
Purpose
-
Automatically generated when a Buy Down Fee transaction is reversed
-
Reverses all already recognized portions (amortized income) linked to the original Buy Down Fee transaction
When It Occurs
-
Trigger: Only initiated during the reversal of a Buy Down Fee transaction
-
Reverses all amortization that has occurred up to that point
-
Restores Deferred Income balances and reverses income recognition
Accounting Entries
| Transaction Type | Debit | Credit |
|---|---|---|
Buy Down Fee Amortization Adjustment |
Income from Buy Down Account |
Deferred Income Liability Account |
Key Characteristics
-
System-Generated Only: Cannot be created manually by API or UI
-
Ensures Accounting Integrity: Keeps amortized and unrecognized balances aligned after reversals
-
Links to Original Amortization: Maintains traceability by referencing the reversed Buy Down Fee transaction
API Endpoints
Configure Buy Down Fee on Loan Product
-
Endpoint:
/loanproducts -
Method:
POST
{
...
"enableBuyDownFee": true, // Mandatory
"buyDownFeeCalculationType": "FLAT", // Mandatory when enabled
"buyDownFeeStrategy": "EQUAL_AMORTIZATION", // Mandatory when enabled
"buyDownFeeIncomeType": "FEE", // Mandatory when enabled
"buyDownExpenseAccountId": 123, // Mandatory when enabled
"deferredIncomeLiabilityAccountId": 456, // Mandatory when enabled
"incomeFromBuyDownAccountId": 789 // Mandatory when enabled
}
Add Buy Down Fee
-
Endpoint:
/loans/{loanId}/transactions?command=buyDownFee -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions?command=buyDownFee -
Method:
POST
{
"transactionDate": "2025-05-01", // Mandatory
"dateFormat": "yyyy-MM-dd", // Mandatory
"locale": "en", // Mandatory
"transactionAmount": 100.0, // Mandatory
"paymentTypeId": 1, // Optional
"note": "Buy down fee", // Optional
"externalId": "BUYDOWN-001" // Optional
}
Response Body
{
"resourceId": 1,
"resourceExternalId": "BUYDOWN-001"
}
Get Buy Down Fee Amortization Info
-
Endpoint:
/loans/{loanId}/buydown-fees -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/buydown-fees -
Method:
GET
Response Body
[
{
"id": 1,
"loanId": 123,
"transactionId": 456,
"buyDownFeeDate": "2025-05-01",
"buyDownFeeAmount": 100.0,
"amortizedAmount": 5.0,
"notYetAmortizedAmount": 95.0,
"adjustedAmount": 0.0,
"chargedOffAmount": 0.0
}
]
Add Buy Down Fee Adjustment
-
Endpoint:
/loans/{loanId}/transactions/{buyDownFeeTransactionId}?command=buyDownFeeAdjustment -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions/{buyDownFeeTransactionId}?command=buyDownFeeAdjustment -
Method:
POST
{
"transactionDate": "2025-05-01", // Mandatory
"dateFormat": "yyyy-MM-dd", // Mandatory
"locale": "en", // Mandatory
"transactionAmount": 50.0, // Mandatory
"paymentTypeId": 1, // Optional
"note": "Buy down fee adjustment", // Optional
"externalId": "BUYDOWNADJ-001" // Optional
}
Response Body
{
"resourceId": 1,
"resourceExternalId": "BUYDOWNADJ-001"
}
Buy Down Fee Template API (to retrieve limits)
-
Endpoint:
/loans/{loanId}/transactions/template?command=buyDownFee -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions/template?command=buyDownFee -
Method:
GET
{
"paymentTypeOptions": [], // List of available payment types
"currency": {...}, // Currency configuration
"date": [2025, 5, 29], // Return the current date
"amount": 0 // Return the maximum amount that can be applied
}
Buy Down Fee Adjustment Template API (to retrieve limits)
-
Endpoint:
/loans/{loanId}/transactions/template?command=buyDownFeeAdjustment -
Alternative Endpoint:
/loans/external-id/{loanExternalId}/transactions/template?command=buyDownFeeAdjustment -
Method:
GET
{
"paymentTypeOptions": [], // List of available payment types
"currency": {...}, // Currency configuration
"date": [2025, 5, 29], // Return the current date
"amount": 0 // Return the maximum amount that can be adjusted
}
Database Structure
Configuration
Loan Product Table (m_product_loan)
| Field | Data Type | Description |
|---|---|---|
|
|
Enable buy down fee feature (default: |
|
|
Calculation method (ENUM: |
|
|
Amortization strategy (ENUM: |
|
|
Income type (ENUM: |
Balances
Buy Down Fee Balance Table (m_loan_buy_down_fee_balance)
| Field | Data Type | Description |
|---|---|---|
|
|
Primary Key (auto-increment) |
|
|
Version for optimistic locking (NOT NULL) |
|
|
Foreign Key to |
|
|
Foreign Key to |
|
|
Buy down fee transaction amount (NOT NULL) |
|
|
Buy down fee transaction date (NOT NULL) |
|
|
Not yet amortized amount (NOT NULL) |
|
|
Charged-off balance (nullable) |
|
|
Total adjustment amount (nullable) |
|
|
User who created the record (NOT NULL) |
|
|
Creation timestamp in UTC (NOT NULL) |
|
|
Last modifier user ID (NOT NULL) |
|
|
Last modification timestamp in UTC (NOT NULL) |
Constraints and Indexes
-
Primary Key:
id -
Foreign Keys:
-
loan_id→m_loan(id) -
loan_transaction_id→m_loan_transaction(id) -
created_by→m_appuser(id) -
last_modified_by→m_appuser(id)
-
Related Transaction Types
Buy Down Fee operations are stored in m_loan_transaction table with these transaction types:
-
BUY_DOWN_FEE- Initial buy down fee creation -
BUY_DOWN_FEE_ADJUSTMENT- Adjustment to existing buy down fee -
BUY_DOWN_FEE_AMORTIZATION- Daily amortization transaction -
BUY_DOWN_FEE_AMORTIZATION_ADJUSTMENT- Adjustment to amortization
Accounting Entries
| Transaction Type | Debit | Credit |
|---|---|---|
Buy Down Fee |
Buy Down Expense Account |
Deferred Income Liability Account |
Buy Down Fee Amortization |
Deferred Income Liability Account |
Income from Buy Down Account |
Buy Down Fee Adjustment |
Deferred Income Liability Account |
Buy Down Expense Account |
Buy Down Fee Amortization Adjustment |
Income from Buy Down Account |
Deferred Income Liability Account |
Business Events
Triggered for Buy Down Fee
-
LoanBuyDownFeeTransactionCreatedBusinessEvent -
LoanBalanceChangedBusinessEvent
Daily Amortization
-
LoanBuyDownFeeAmortizationTransactionCreatedBusinessEvent -
LoanBuyDownFeeAmortizationAdjustmentTransactionCreatedBusinessEvent
Buy Down Fee Adjustment
-
LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent -
LoanBalanceChangedBusinessEvent
Reversal
-
LoanAdjustTransactionBusinessEvent
Available Disbursement Calculation
Buy Down Fee does not affect the available disbursement amount calculation:
Available Disbursement = Approved Loan Amount
- Total Disbursed Amount
- Total Capitalized Income
Buy Down Fee transactions are separate from the loan disbursement logic and do not reduce the available disbursement amount.
Notes
|
Approved amount modification on loans
Overview
In Apache Fineract, after a loan is disbursed, it is possible to alter the principal amount that the loan was approved with. This means that the amount to be disbursed can be fine-tuned throughout the loan lifecycle. The approved loan amount can either be modified directly or indirectly through different endpoints.
Supported Loan Type
Approved amount modifications are supported on all loan types.
Business Events
-
Triggers a new business event: Loan Approved Amount Changed
API Endpoints
Modifying approved amount on loans
Fineract supports the direct modification of the approved amount on loans
-
Endpoint:
/loans/<loan_id>/approved-amount -
Alternative Endpoint:
/loans/external-id/<loan_external_id>/approved-amount -
Method:
PUT
{
"amount": 1000.0,
"locale": "en"
}
Response Body
{
"changes": {
"locale": "en",
"newApprovedAmount": 1000.0,
"oldApprovedAmount": 1500.0
},
"clientId": 6,
"groupId": 10,
"officeId": 2,
"resourceExternalId": "95174ff9-1a75-4d72-a413-6f9b1cb988b7",
"resourceId": 3
}
Validations
-
The approved amount of the loan cannot be lower than the
total principal disbursed + total expected principal + total principal from capitalized income transactions. -
The approved amount of the loan cannot be set higher, than the proposed amount of the loan or if
allow approved/disbursed over applied amountconfiguration is enabled then the calculated threshold.
Modifying available disbursement amount on loans
Fineract supports the indirect modification of the approved amount on loans. This is called modifying the available disbursement amount.
|
Available disbursement amount is only a calculated value used by this endpoint to indirectly update the approved amount of the loan. It is not stored anywhere. |
The approved amount is calculated as: total principal disbursed + total expected principal + total principal from capitalized income + "amount" from the request.
-
Endpoint:
/loans/<loan_id>/available-disbursement-amount -
Alternative Endpoint:
/loans/external-id/<loan_external_id>/available-disbursement-amount -
Method:
PUT
{
"amount": 100.0,
"locale": "en"
}
Response Body
{
"changes": {
"locale": "en",
"newApprovedAmount": 100.0,
"oldApprovedAmount": 1000.0,
"newAvailableDisbursementAmount": 100.0,
"oldAvailableDisbursementAmount": 1000.0
},
"clientId": 6,
"groupId": 10,
"officeId": 2,
"resourceExternalId": "95174ff9-1a75-4d72-a413-6f9b1cb988b7",
"resourceId": 3
}
Validations
-
The available disbursement amount cannot be lower than 0.
-
The approved amount of the loan cannot be set higher, than the proposed amount of the loan or if
allow approved/disbursed over applied amountconfiguration is enabled then the calculated threshold. This means that the new available disbursement amount cannot be higher thanmaximumLoanPrincipalThreshold - total principal disbursed - total expected principal - total principal from capitalized income
Approved amount history
Modifying the approved amount of the loan through either endpoint also creates a history entry that can be used to observe the changes overtime.
-
Endpoint:
/loans/<loan_id>/approved-amount -
Alternative Endpoint:
/loans/external-id/<loan_external_id>/approved-amount -
Method:
GET
Response Body
[
{
"loanId": 152,
"externalLoanId": "9e058913-3de8-4f6e-9e09-4b2067c4bb91",
"newApprovedAmount": 800.000000,
"oldApprovedAmount": 1000.000000,
"dateOfChange": "2025-08-05T16:35:43.427229+02:00"
},
{
"loanId": 152,
"externalLoanId": "9e058913-3de8-4f6e-9e09-4b2067c4bb91",
"newApprovedAmount": 600.000000,
"oldApprovedAmount": 800.000000,
"dateOfChange": "2025-08-05T16:35:43.543779+02:00"
},
{
"loanId": 152,
"externalLoanId": "9e058913-3de8-4f6e-9e09-4b2067c4bb91",
"newApprovedAmount": 400.000000,
"oldApprovedAmount": 600.000000,
"dateOfChange": "2025-08-05T16:35:43.603855+02:00"
}
]
Backdated interest modification
In the previous implementation we were only allowing the interest rate modification from current date and from now on we will allow Interest modification backdated on progressive loans as well.
|
Only available on progressive loans. |
Functionality
Validations updated to allow backdated interest change, even on charged-off or otherwise closed loan. Making progressive loans more flexible.
-
Interest rate can be modified from backdate any date from first disbursement date
-
Interest will be affected from the applied date itself.
-
Backdate can be done on already paid Installments as well
-
Repayment schedule will be recalculated with New EMI and Interest from the Interest applied schedule date
-
Backdated PAID Installments and Unpaid/Partial Paid Installment EMI would be changed as per the new calculated EMI
-
-
Installments paid Interest amount will be reverse replayed as per the new Interest rate from the applied date
-
Transactions will be reversed replayed if there is any change is allocations
-
Accrual adjustments will be done during reverse replay (during the COB process)
-
-
No of Installments will remain the same, only EMI and Interest would get affected
-
Backdated Interest modification allowed on the loan that is charged off
-
If the repayment that was made before the charge-off is reversed and replayed due to backdated Interest modification,then the accounting entry of the reversed transaction and replayed transaction should follow standard accounting rules and not charge-off accounting rules
-
-
Backdated interest modification allowed on the loan that is overpaid, and CBR is complete.
-
Any action that triggers the recalculation (ex: reversal of backdated transaction) on the CBR loan will result in treating CBR as a credit transaction during reverse-replay. Same logic to be applied if the backdated interest modification is allowed on CBR loan accounts.
-
-
Asset transfer (externalization)
-
If the repayment that was made before the asset owner change got reversed and replayed due to backdated Interest modification,
then, the accounting entry for the reversed transaction and replayed transaction include the tag of the current asset owner
-
-
Since backdated Interest modification is allowed on CBR/overpaid loans, we can keep the modification open on closed loans as well
-
system will do the chronological reverse-replay when the backdated Interest is changed on closed loans, schedule and transaction will be allocated accordingly
-
API endpoints
Create reschedule loans request (create reschedule)
-
Endpoint:
/rescheduleloans -
Method:
POST
{
"loanId": 1, // Mandatory
"newInterestRate": 1, // Mandatory for interest rate change type reschedule
"rescheduleFromDate": "2024-01-01", // Mandatory
"rescheduleReasonId": 54, // Mandatory
"submittedOnDate": "2024-01-01", // Mandatory
"dateFormat": "yyyy-MM-dd", // Mandatory
"locale": "en" // Mandatory
}
{
"resourceId": 1,
"loanId": 1,
"clientId": 1,
"officeId": 1
}
Update reschedule loans request (approve reschedule)
-
Endpoint:
/rescheduleloans/<reschedule_Id> -
Method:
POST
{
"approvedOnDate": "2024-01-01", // Mandatory for approval
"submittedOnDate": "2024-01-01", // Mandatory
"dateFormat": "yyyy-MM-dd", // Mandatory
"locale": "en" // Mandatory
}
{
"changes": {
"approvedByUserId": 1,
"approvedOnDate": "2024-01-01",
"dateFormat": "yyyy-MM-dd",
"locale": "en"
},
"clientId": 186,
"loanId": 188,
"officeId": 1,
"resourceId": 35
}
Interest Rate Modification For Progressive Loan
Overview
The Original Interest Rate Modification feature allows updating the interest rate for active loans. The updated interest rate can be applied only for active loans and effective date should be in the future.
This capability is introduced to support flexible interest rate management in loan lifecycles, reflecting changes due to inflation, risk reassessment, or customer-specific conditions.
New Progressive Loan Interest Change Modification feature can be applied for overpaid, charged off or even backdated cases, which makes it much more usable.
Scope and Limitations
-
Only supported for progressive loan types.
-
Loans must be disbursed
-
Interest rate modifications apply from a specified applied date, which can be backdated from the original disbursement date onward.
-
Paid EMIs (Equal Monthly Installments) and interest amounts may be affected by backdated interest rate changes.
-
The modified interest rate affects EMI amounts
-
Installment counts are not affected
-
Reversals/backdated repayments are allowed after modification.
Feature Behavior
-
When a new interest rate is applied, the system recalculates EMI values starting from the applied date.
-
If the applied date is in the past, previously paid installments will be reprocessed under the new interest rate.
-
The updated interest rate is effective from the applied date itself.
-
Repayment schedule is updated using the current recalculation strategy.
Configuration
Interest calculation and interest recalculation strategies are inherited from the loan product configuration at the time of loan application.
This means:
-
The interest calculation method (e.g., declining balance, flat) and
-
The interest recalculation strategy
are fixed per loan account once the loan is created.
As a result, no further changes to these configurations are possible after loan creation. Any interest rate modifications must operate within the originally defined calculation and recalculation strategies.
API Specification
Endpoint
related API-s
Retrieve a Loan Reschedule Request
Retrieve a Preview of The New Loan Repayment Schedule
Reject a Loan Reschedule Request
Approve a Loan Reschedule Request
Request Payload
The following fields are accepted in the request body for interest rate modification. All date fields must follow the format specified by the dateFormat field, and parsing is performed using the specified locale.
| Field | Type | Description |
|---|---|---|
|
Long |
Identifier of the loan to be modified. Must refer to a disbursed progressive loan. |
|
BigDecimal |
Required. The new interest rate to be applied. Must be zero or positive, and comply with the loan product’s min/max rate constraints. |
|
String |
Required when any date fields are provided. Defines the expected format of date values (e.g., |
|
String |
Required. Specifies the locale (e.g., |
|
String |
Optional. The date the request is submitted. If provided, must match the specified |
|
String |
Required. The date from which the new interest rate becomes effective. Must be on or after the loan disbursement date. Format must match |
|
String |
Optional. A free-text comment describing the reason for the interest rate change. |
|
Long |
Optional. ID referencing a predefined rescheduling reason (e.g., from a dropdown or lookup table). |
|
The following fields are not applicable in the context of an interest rate change request:
These parameters are reserved for other loan rescheduling operations. |
newInterestRate
When processing an interest rate modification request, the system validates the newInterestRate parameter as follows:
-
The value must be a valid
BigDecimalparsed with the appropriate locale. -
The interest rate must be zero or positive.
-
If defined on the loan product, the new interest rate must satisfy the following boundaries:
-
It must be greater than or equal to the product-level
minNominalInterestRatePerPeriod. -
It must be less than or equal to the product-level
maxNominalInterestRatePerPeriod.
-
These boundaries are enforced using the product’s configured range at the time the loan was applied. If no minimum or maximum is set on the product, only the zero-or-positive constraint is enforced.
Example
Example Create Request
POST rescheduleloans
Content-Type: application/json
{
"loanId": 1,
"graceOnPrincipal": null,
"graceOnInterest": null,
"extraTerms": null,
"rescheduleFromDate": "04 December 2014",
"dateFormat": "dd MMMM yyyy",
"locale": "en",
"recalculateInterest": null,
"submittedOnDate": "04 September 2014",
"newInterestRate" : 28,
"rescheduleReasonId": 1
}
Response
{
"loanId": 1,
"resourceId": 2
}
Example Approval
POST rescheduleloans/2?command=approve
Content-Type: application/json
{
"locale": "en",
"dateFormat": "dd MMMM yyyy",
"approvedOnDate": "11 September 2014"
}
{
"loanId": 1,
"resourceId": 2,
"changes": {
"locale": "en",
"dateFormat": "dd MMMM yyyy",
"approvedOnDate": "11 September 2014",
"approvedByUserId": 3
}
}
Developer Notes
The core concept is that the AdvancedPaymentScheduleTransactionProcessor processes transactions in order of their effective date, allowing it to handle backdated transaction cases.
The transaction processor uses the EMICalculator to manage interest rate changes over time, ensuring that changes only affect future transactions relative to the actual processing transaction. The ProgressiveLoanInterestScheduleModel is responsible for holding and calculating interest for future installments.
The underlying principle is to split repayment periods into smaller interest periods, enabling the calculation of interest for partial repayment periods. This approach makes it easier to adjust interest rates for specific interest periods as needed.
Contract Termination
Overview
Contract Termination in Apache Fineract is a loan management feature that allows financial institutions to terminate loan contracts. When applied to loans with unpaid installments, this functionality accelerates the maturity date and makes the outstanding loan balance immediately due as of the termination date.
Purpose
This functionality enables financial institutions to:
-
Terminate loan contracts when required by business rules
-
Accelerate payment schedules by making outstanding balances immediately due
-
Maintain proper loan status tracking and accounting
-
Support charge-off and recovery operations on terminated loans
Supported Loan Type
|
Contract Termination is only supported for:
Other loan schedule types and inactive loan states are not supported. |
Business Rules
Eligibility Requirements
Contract termination can only be applied when:
-
Loan Status: The loan must be in
Activestatus -
Schedule Type: Only
Progressiveloan schedule type is supported -
Not Charged Off: Loan must not be in charged-off state
-
Not Already Terminated: Loan must not already have contract termination applied
Termination Date Rules
-
Contract termination can only be done as of the current business date
-
Backdated termination is not allowed
-
No future-dated termination permitted
Schedule Impact
When contract termination is applied:
-
Maturity Acceleration: If unpaid installments exist, the loan maturity date is accelerated to the termination date
-
Interest Calculation: Interest is calculated only until the contract termination date
-
Maturity Date Updated: The loan maturity date is updated to the contract termination date
-
Principal Outstanding: The full outstanding principal balance becomes due
-
Delinquency Bucketing: Continues as per the new accelerated schedule
Post-Termination Operations
After contract termination:
-
Charge-off Allowed: Terminated loans can be charged off
-
Charge-backs Allowed: Terminated loans support charge-back transactions
-
Future Installments: All installments scheduled after termination date are removed from the schedule
-
Accrual Activities: Accrual and accrual activity transactions stop after termination
-
Backdated Payments: Backdated payments and reversals are allowed
-
Contract Termination Reversal: The termination can be undone/reversed
Special Handling
Post-Maturity Termination
If contract termination is done after the original maturity date:
-
No schedule acceleration occurs (installments and maturity date remain unchanged)
-
Interest calculation follows normal rules up to the original maturity date
-
The loan remains in its current state without forced acceleration
Contract Termination Reversal
-
Contract termination can be undone/reversed
-
Schedule will be recalculated and reapplied accordingly
-
All associated transactions are properly reversed and replayed
Transaction Types
Contract Termination Transaction
The Contract Termination transaction in Apache Fineract performs the following actions:
-
Accelerates Payment Schedule: Makes all outstanding amounts immediately due
-
Creates Distinct Transaction: Tracked separately with transaction type "Contract Termination"
-
Updates Loan Sub-Status: Changes loan sub-status to
CONTRACT_TERMINATION(value: 900) -
Triggers Schedule Recalculation: Updates repayment schedule with accelerated terms
-
No Accounting Entries: Contract termination itself does not generate accounting entries
-
Stops Accrual Activity: Interest accrual and accrual activities cease after termination
Transaction Behavior
-
Transaction date is set to current business date
-
Amount represents total outstanding balance as calculated by loan summary
-
Triggers loan reprocessing for interest-bearing loans with recalculation enabled
-
Non-monetary transaction (excluded from monetary transaction queries)
Accrual Transactions
During contract termination, the system may generate:
-
Final accrual transaction up to termination date
-
Accrual adjustment transaction if needed
-
Associated journal entries
-
Relevant business events for accrual processing
Contract Termination Undo
The Contract Termination Undo transaction reverses a previous contract termination:
-
Reverses Termination Transaction: Marks the original termination as reversed
-
Removes Sub-Status: Restores loan to previous sub-status state
-
Recalculates Schedule: Regenerates original repayment schedule
-
Reprocesses Transactions: Re-runs transaction processing logic
-
Triggers Business Events: Notifies system of balance and status changes
API Endpoints
Apply Contract Termination
-
Endpoint:
/loans/{loanId}?command=contractTermination -
Alternative Endpoint:
/loans/external-id/{loanExternalId}?command=contractTermination -
Method:
POST
{
"note": "Contract terminated due to default", // Optional
"externalId": "95174ff9-1a75-4d72-a413-6f9b1cb988b7" // Optional
}
Response Body
{
"entityId": 1,
"entityExternalId": "95174ff9-1a75-4d72-a413-6f9b1cb988b7",
"officeId": 1,
"clientId": 1,
"loanId": 1,
"changes": {
"subStatus": 900
}
}
Undo Contract Termination
-
Endpoint:
/loans/{loanId}?command=undoContractTermination -
Alternative Endpoint:
/loans/external-id/{loanExternalId}?command=undoContractTermination -
Method:
POST
{
"note": "Reversing contract termination", // Optional
"reversalExternalId": "95174ff9-1a75-4d72-a413-6f9b1cb988b7" // Optional
}
Response Body
{
"entityId": 1,
"entityExternalId": "95174ff9-1a75-4d72-a413-6f9b1cb988b7",
"officeId": 1,
"clientId": 1,
"groupId": null,
"loanId": 1,
"changes": {
"subStatus": null
}
}
Business Events
Triggered for Contract Termination
-
LoanTransactionContractTerminationPostBusinessEvent- After termination processing -
LoanBalanceChangedBusinessEvent- After termination processing -
LoanAdjustTransactionBusinessEvent- During termination transaction processing
Triggered for Contract Termination Undo
-
LoanUndoContractTerminationBusinessEvent- Before and after undo operation -
LoanBalanceChangedBusinessEvent- After schedule recalculation -
LoanAdjustTransactionBusinessEvent- During reversal processing
Database Impact
Loan Sub-Status
Updated on Loan (m_loan)
| Field | Data Type | Description |
|---|---|---|
|
|
Set to 900 (CONTRACT_TERMINATION) when terminated, reset to NULL when undone |
Transaction Records
Loan Transaction (m_loan_transaction)
| Field | Data Type | Description |
|---|---|---|
|
|
Set to 38 (CONTRACT_TERMINATION) |
|
|
Current business date |
|
|
Total outstanding balance |
|
|
Set to TRUE when undone |
Validation Rules
Contract Termination Validation
-
Loan must be in active status (
loan.isOpen()) -
Loan product must use Progressive schedule type
-
Loan must not be charged off (
!loan.isChargedOff()) -
Loan must not already be terminated (
!loan.isContractTermination()) -
Client or group must be active
Contract Termination Undo Validation
-
Original contract termination transaction must exist
-
Transaction must not be already reversed
-
Proper permissions required for reversal operations
Integration Points
Charge Operations
-
Charge-off operations can be performed on terminated loans
-
Charge-back transactions are supported on terminated loans
-
Charge adjustments follow normal business rules
Delinquency Management
-
Delinquency bucketing continues based on accelerated schedule
-
Delinquency calculations use the new due dates from termination
Accounting Integration
-
No direct accounting entries for contract termination transaction
-
Potential accrual transactions generated up to termination date
-
Journal entries created for final accrual transactions
-
Interest accrual stops after termination
-
Business events triggered for accrual processing
Notes
|
Loan Charges
Overview
Loan charge products can be created with different charge time types and charge calculation types. The created charge product can be associated with loan products or individual loan accounts to automate fee and penalty application throughout the loan lifecycle.
Benefits
-
Automates charge application at disbursement, specified dates, or installment schedules
-
Supports multiple calculation methods including flat amounts and various percentage-based calculations
-
Handles multi-disbursement loans with distinct first disbursement vs. tranche disbursement behavior
-
Provides penalty automation for overdue installments
-
Enables flexible fee structures for different loan products
Charge Time Types and Calculation Types
The following charge time types and corresponding calculation types are supported for loans:
| Charge Time Type | Description | Available Charge Calculation Types |
|---|---|---|
Disbursement |
Charged at the time of first disbursement. For multi-disbursement loans, it applies only to the first disbursement. |
|
Specified Due Date |
Charge applied on a specific date defined by the user within the loan lifecycle. |
|
Installment Fees |
Charged with each repayment installment throughout the loan term. |
|
Overdue Fees |
Penalties automatically applied when installments become overdue. |
|
Tranche Disbursement |
Charged for each disbursement tranche in multi-disbursement loans. |
|
API Endpoints
Charge Product Management
-
Endpoint:
/v1/charges -
Methods:
GET(list all charges),POST(create charge) -
Purpose: Manage charge product definitions
-
Endpoint:
/v1/charges/{chargeId} -
Methods:
GET(retrieve),PUT(update),DELETE(delete) -
Purpose: Individual charge product operations
-
Endpoint:
/v1/charges/template -
Methods:
GET -
Purpose: Retrieve charge creation template with dropdown options
Loan Charge Management
-
Endpoint:
/v1/loans/{loanId}/charges -
Alternative:
/v1/loans/external-id/{loanExternalId}/charges -
Methods:
GET(list loan charges),POST(add charge to loan) -
Purpose: Manage charges on specific loans
-
Endpoint:
/v1/loans/{loanId}/charges/{chargeId} -
Alternative:
/v1/loans/external-id/{loanExternalId}/charges/external-id/{chargeExternalId} -
Methods:
GET(retrieve),PUT(update),DELETE(remove),POST(execute commands) -
Purpose: Individual loan charge operations
-
Endpoint:
/v1/loans/{loanId}/charges/template -
Methods:
GET -
Purpose: Get template for adding charges to loans
Charge Commands
Loan charges support command-based operations via POST requests with command query parameter:
-
POST /v1/loans/{loanId}/charges/{chargeId}?command=waive- Waive charge -
POST /v1/loans/{loanId}/charges/{chargeId}?command=pay- Pay charge directly
Configuration
Charge Product Setup
-
Charge Time Type: Select when the charge should be applied (disbursement, specified date, installments, overdue, or tranche)
-
Charge Calculation Type: Choose calculation method (flat amount or percentage-based)
-
Amount/Percentage: Define the charge amount or percentage value
-
Currency: Must match the loan product currency
-
Penalty Flag: Mark charges as penalties for reporting purposes
-
Active Status: Enable/disable charge availability
Loan Product Association
-
Associate default charges to loan products during product configuration
-
Default charges are automatically applied when loans are created from the product
-
Individual charges can be added to specific loans regardless of product defaults
Validation Rules
-
Charge currency must match loan currency
-
Specified due dates must fall within the loan term
-
Percentage values must be within valid ranges (0-100)
-
Overdue charges require penalty flag to be enabled
-
Disbursement charges can only be added before loan disbursement
Journal Entry Aggregation
Overview
The Journal Entry Aggregation Job is a Spring Batch-based solution designed to efficiently aggregate journal entries in the Fineract system. This job processes journal entries in configurable chunks, improving performance and resource utilization when dealing with large volumes of financial transactions.
Key Features
Chunk-based Processing
-
Processes journal entries in configurable batch sizes
-
Reduces memory footprint by working with manageable data subsets
-
Improves performance through efficient batch processing
Tracking and Deduplication
-
Tracks processed date ranges to prevent duplicate aggregations
-
Uses
JournalEntryAggregationTrackingto maintain execution history -
Skips already processed date ranges in subsequent runs
Configurable Exclude Recent N Days
-
Excludes the last N days (from business date) from processing
-
Default
Exclude Recent N Dayscan be customized via application properties
How It Works
Job Flow
Job Initialization
-
Determines the date range to process based on last execution
-
Sets up execution context with date boundaries
Data Reading
-
Fetches unaggregated journal entries within the target date range
-
Groups entries by GL account, product, office, and other dimensions
Processing
-
Aggregates debit and credit amounts for each group
-
Handles external asset owner mappings
-
Processes data in configurable chunk sizes
Tracking
-
Records successful aggregation runs
-
Maintains execution history for future reference
Configuration
Job Parameters
-
aggregatedOnDate: (Optional) Specific date to process (defaults to business date) -
chunkSize: (Optional) Number of records to process in each chunk
Application Properties
# Exclude Recent N days from aggregation
fineract.job.journal-entry-aggregation.exclude-recent-N-days=1
# Chunk size for batch processing
fineract.job.journal-entry-aggregation.chunk-size=1000
Usage
Manual Execution
Trigger the job manually through the Fineract API:
POST /jobs/short-name/JRNL_AGG
Content-Type: application/json
{
}
Scheduled Execution
Configure the job to run on a schedule by adding to your scheduler configuration.
Monitoring
Monitor job execution through:
-
Job execution logs
-
JOURNAL_ENTRY_AGGREGATION_TRACKINGtable -
Spring Batch job execution tables
Best Practices
Chunk Size Tuning
-
Larger chunks improve throughput but increase memory usage
-
Monitor memory usage and adjust chunk size accordingly
Scheduling
-
Schedule during off-peak hours for large datasets
-
Consider running more frequently with smaller
Exclude Recent N Daysvalues
Error Handling
-
Failed jobs can be restarted from the last successful chunk
-
Review job execution logs for any processing issues
Performance Considerations
-
Indexing: Ensure proper indexes exist on
aggregated_on_date,office_id, and other filtering columns -
Partitioning: Consider partitioning large journal entry tables by date for better performance
-
Batch Window: Allocate sufficient time for the job to complete during maintenance windows
Database Schema
m_journal_entry_aggregation_summary Table
This table stores the aggregated journal entry amounts, grouped by various dimensions for efficient reporting and analysis.
| Column | Type | Nullable | Description |
|---|---|---|---|
id |
BIGINT |
No |
Primary key |
gl_account_id |
BIGINT |
No |
Reference to |
product_id |
BIGINT |
Yes |
Reference to the product (if applicable) |
office_id |
BIGINT |
No |
Reference to |
entity_type_enum |
SMALLINT |
No |
Type of entity (e.g., loan, savings) |
submitted_on_date |
DATE |
No |
The date of the business date when entry was submitted |
aggregated_on_date |
DATE |
No |
The date when aggregation was performed |
debit_amount |
DECIMAL(19,6) |
No |
Sum of debit amounts |
credit_amount |
DECIMAL(19,6) |
No |
Sum of credit amounts |
external_owner_id |
BIGINT |
Yes |
Reference to external owner (if applicable) |
job_execution_id |
BIGINT |
No |
Reference to batch job execution |
created_date |
TIMESTAMP |
No |
Record creation timestamp |
last_modified_date |
TIMESTAMP |
Yes |
Last modification timestamp |
The table is designed to support efficient querying of aggregated financial data by:
* Date ranges (using submitted_on_date and aggregated_on_date)
* Organizational structure (using office_id)
* Financial dimensions (using gl_account_id and product_id)
* Entity types (using entity_type_enum)
m_journal_entry_aggregation_tracking Table
This table maintains a history of aggregation job executions, tracking which date ranges have been processed to prevent duplicate aggregations.
| Column | Type | Nullable | Description |
|---|---|---|---|
id |
BIGINT |
No |
Primary key |
job_execution_id |
BIGINT |
No |
Reference to Spring Batch job execution |
aggregated_on_date_from |
DATE |
No |
Start date of the aggregation period |
aggregated_on_date_to |
DATE |
No |
End date of the aggregation period |
submitted_on_date |
DATE |
No |
The date of the business date when entry was submitted |
status |
VARCHAR(20) |
No |
Status of the aggregation (e.g., COMPLETED, FAILED) |
error_message |
TEXT |
Yes |
Error details if the job failed |
start_time |
TIMESTAMP |
No |
When the aggregation started |
end_time |
TIMESTAMP |
Yes |
When the aggregation completed |
records_processed |
INT |
Yes |
Number of records processed |
created_date |
TIMESTAMP |
No |
Record creation timestamp |
last_modified_date |
TIMESTAMP |
Yes |
Last modification timestamp |
Key aspects of the tracking table:
* Tracks the exact date ranges processed in each job execution
* Maintains job status and error information for debugging
* Records performance metrics (processing time, record counts)
* Used by the job to determine which date ranges need processing in subsequent runs
Indexes are created on frequently queried columns to ensure optimal performance for reporting and analysis.
This aggregation job provides a robust, scalable solution for processing journal entries while maintaining data integrity and providing clear audit trails of all aggregation activities.
Asset Externalization
1. Purpose & Scope
The Asset Externalization capability enables loan ownership transfer between a Platform Owner and external investors within Fineract, supporting:
-
Loan sales and securitization for GPL products
-
Real-time ownership awareness with COB-based execution
-
Full accounting, reporting, and auditing for Platform Owner–owned and investor-owned loans
-
API-driven ownership lifecycle management
-
Event-driven downstream integration (ETL, Credit domains)
Ownership is exclusive: once sold, the loan is 100% investor-owned until buyback.
2. In-Scope vs Out-of-Scope
In Scope (Fineract Responsibilities)
-
Persist and manage loan ownership throughout loan lifecycle
-
Accept ownership transfer requests (sale, buyback, cancel)
-
Execute ownership changes at Close of Business (COB)
-
Generate accounting journal entries per ownership
-
Publish ownership change events
-
Provide GET/POST APIs for ownership and transfers
-
Support delayed settlement (pending → active)
-
Produce investor accounting reports
Out of Scope
-
General high-level mechanism for determining which loans/clients are eligible for sale or buyback (loan state requirements are scope and implemented)
-
Smart Order Router which could prioritize internal logic or market logic
-
Credit ETL (Extract, Transform, Load) schema decisions outside Fineract
3. Ownership Model
Ownership Characteristics
-
Single owner at any time (Platform Owner or Investor)
-
Ownership stored with:
-
Asset Owner ID
-
Transfer (Sale / Buyback) ID
-
Purchase Price Ratio
-
Effective date range
-
Status
-
Ownership updates requested in real time, executed during COB
Ownership Statuses
Status |
Description |
PENDING |
Transfer requested, not yet executed |
ACTIVE |
Investor owns the loan |
BUYBACK |
Ownership reverted to Platform Owner |
DECLINED |
Transfer invalid due to balance/state |
CANCELLED |
Transfer cancelled before execution |
4. Functional Flows
4.1 Asset Sale (Ownership Transfer to Investor)
Preconditions
-
Loan must be ACTIVE
-
No active ownership
-
Balance at COB > 0.00
Process
-
Sale request submitted via API
-
Ownership record created with status PENDING
-
At COB on settlement date:
-
If balance > 0.00 → status becomes ACTIVE
-
If balance ≤ 0.00 → status becomes DECLINED
-
-
Journal entries posted using closing balance
-
Ownership effective from next day
Persisted Data
-
Asset Owner ID
-
Sale Transaction ID
-
Purchase Price Ratio
-
Settlement date
-
Effective date range
-
Timestamps
4.2 Buyback (Ownership Transfer to Platform Owner)
Preconditions
-
Active ownership exists
-
Loan must be ACTIVE
-
Can be triggered for fraud, reversals, or business decision
Process
-
Buyback request submitted
-
Buyback transfer record created
-
At COB:
-
Ownership marked INACTIVE
-
Journal entries posted using closing balance
-
-
Ownership reverts to Platform Owner
No buyback occurs if
-
Loan is CLOSED
-
No active ownership
-
Loan not found
4.3 Delayed Settlement (Phase 2)
-
Sale request can include future settlement date
-
Ownership remains PENDING until settlement
-
Pending accounting entries applied
-
At settlement COB:
-
Pending → ACTIVE if balance > 0
-
Pending → DECLINED if balance ≤ 0
5. Accounting Behavior
General Rules
-
All accounting logic remains unchanged except owner tagging
-
Fees, payments, delinquency, charge-off rules remain unchanged
-
Fees after sale belong to investor
-
No ownership transfer or GL entries if balance ≤ 0
5.1 Sale & Buyback Journal Entries (COB)
Sale
-
DR Loan/Fee Receivable – Investor
-
CR Loan/Fee Receivable – Platform Owner
Buyback
-
DR Loan/Fee Receivable – Platform Owner
-
CR Loan/Fee Receivable – Investor
5.2 Investor-Owned Loan Transactions
For investor-owned loans, journal entries are posted against investor GLs for:
-
Disbursement
-
Repayments (all payment types)
-
Refunds
-
Goodwill credits
-
Snooze & NSF fees
-
Fee waivers
-
Credit balance refunds
-
Repayment adjustments & reversals
-
Charge-offs (non-payment, deceased, bankrupt)
-
Recoveries (repayments, refunds)
6. Events
Ownership Change Events
Events are published in real time when ownership changes occur.
Published For
-
Sale
-
Buyback
-
Declined transfers
Event Payload
-
Date/time
-
Loan account ID
-
Sale/Buyback Transaction ID
-
Asset Owner ID
-
Loan balance
Consumption
-
Fineract SOR → Adaptor → Credit ETL
-
Persisted in Credit databases
7. APIs
Transfer Requests
Ownership transfer requests are supported for sale, buyback, and cancel, targeting loans by internal or external identifiers.
Root endpoint
-
/external-asset-owners
Sale / Buyback / Cancel
-
POST /external-asset-owners/transfers?loanId=<loan_id>&command=<sale|buyback|cancel> -
POST /external-asset-owners/transfers?loanExternalId=<loan_external_id>&command=<sale|buyback|cancel> -
POST /external-asset-owners/transfers/loans/<loan_id>?command=<action> -
POST /external-asset-owners/transfers/loans/external-id/<loan_external_id>?command=<action>
Retrieve Ownership
Ownership and transfer information can be retrieved using:
-
GET /external-asset-owners/transfers?transferExternalId=<transfer_external_id> -
GET /external-asset-owners/transfers?loanId=<loan_id> -
GET /external-asset-owners/transfers?loanExternalId=<loan_external_id>
Retrieve Accounting Data
Accounting data related to ownership is available through:
-
Transfer journal entries
-
GET /external-asset-owners/transfers/external-id/{ownerExternalId}/journal-entries -
Owner journal entries
-
GET /external-asset-owners/owners/external-id/{ownerExternalId}/journal-entries
8. Data Model (Summary)
Core Tables
-
m_external_asset_owner -
m_external_asset_owner_transfer -
m_external_asset_owner_active_transfer_loan_mapping -
m_external_asset_owner_journal_entry_mapping -
m_external_asset_transfer_journal_entry_mapping -
m_external_asset_owner_transfer_details
Supports:
-
Ownership history
-
Effective dating
-
Auditing
-
Investor-level accounting reconciliation
9. Reporting
Daily Investor Reports
-
GL Trial Balance Summary (by investor)
-
Transaction Detail Report (by investor)
Used for reconciliation of:
-
Investor cash
-
GL balances
-
Ownership-related postings
10. Validation Rules (Consolidated)
-
Only ACTIVE loans accepted for sale
-
No transfer if:
-
Loan CLOSED
-
Balance ≤ 0 at COB
-
Active ownership already exists
-
Invalid loan ID
-
Only one pending transfer allowed per loan
-
Ownership changes occur only during COB
11. Key Design Constraints
-
Transfer initiation via API only
-
Execution strictly at COB
-
Ownership effective date always starts day after settlement
-
Journal entries linked to owner via mapping tables
-
Accounting and Investor modules are logically coupled
Pause Delinquency
Overview
Pause Delinquency is a feature that allows users to temporarily pause the delinquency calculation for loan accounts. This functionality provides flexibility in managing loan delinquency status during specific periods, such as when customers are experiencing temporary financial difficulties or during special circumstances.
The system provides API support for establishing a "pause delinquency" on loan accounts and resuming paused delinquency when needed. When a loan is paused, no evaluation of delinquency is performed for that loan account during COB processing, and the delinquency bucket and tags remain unchanged.
Introduction
Purpose
This document specifies the functional and business requirements for the Pause Delinquency feature in Fineract. It defines the rules for pausing and resuming delinquency calculations, database design, API endpoints, event handling, and validation rules.
Scope
The scope of this document includes:
-
Pause and Resume delinquency actions at the loan account level
-
Database schema for storing delinquency actions
-
API endpoints for managing delinquency actions
-
Integration with existing delinquency calculation logic
-
Event emission when pause flag changes
-
Loan API extension with pause status information
-
Validation rules for pause and resume actions
Applicability
The requirements described apply to:
-
Active loan accounts only
-
All loan products that support delinquency tracking
Definitions and Key Concepts
Delinquency: The state of a loan account when payments are overdue beyond the configured delinquency ranges.
Pause: An action that temporarily stops the delinquency calculation for a loan account. During the pause period, the delinquency status remains unchanged.
Resume: An action that ends an active pause period and re-enables delinquency calculation. The resume date becomes the effective end date for the pause period.
Pause Period: The time interval between the pause start date and the effective end date (either the pause end date or the resume date, whichever comes first).
Delinquency Calculation Paused Status: The current pause status is determined by checking if the business date falls within any active pause periods. The system maintains a list of pause periods, each with an active field indicating if that period is currently active.
Effective Pause Period: The actual pause period after considering resume actions. If a resume action occurs during a pause period, it effectively moves the pause end date earlier.
Design Decisions and Considerations
Loan-Level Feature
This is a feature that belongs to the loan and not a loan product. Each loan account can have its own pause/resume configuration independent of the loan product settings.
Scope of Pause
Pause applies to both:
-
Loan-level delinquency calculation
-
Installment-level delinquency calculation
The pause mechanism controls whether the delinquency recalculation runs during COB processing. When delinquency calculation runs after the pause period, it takes into account the duration of the pause.
Pause and Resume Actions
Once a loan is paused, it can be resumed using a resume action. It is not possible to undo a pause by updating or deleting the pause itself. The resume action must be used to end an active pause period.
Database Design
Overview
The delinquency pause functionality uses the m_loan_delinquency_action table to store delinquency actions (pause and resume) for loan accounts. The existing delinquency-related tables remain unchanged.
Existing Tables
The following existing tables are related to delinquency:
m_delinquency_bucket: A bucket is a set of preconfigured delinquency ranges which is referred by the loan product.
m_delinquency_range: The delinquency ranges (e.g.: 5-15 days, 15-30 days, etc.).
m_delinquency_bucket_mappings: Creates the association between the ranges and buckets. This table defines which ranges belong to a bucket.
m_loan_delinquency_tag_history: Stores the delinquency calculation result, with a history.
Table: m_loan_delinquency_action
The m_loan_delinquency_action table stores loan-level configuration for delinquency actions (pause and resume).
| Column Name | Type | Constraints | Description |
|---|---|---|---|
id |
BIGINT |
PK, not null |
Primary Key of the delinquency action |
loan_id |
BIGINT |
Index created, not null, FK to m_loan |
Stores the association for the loan |
action |
VARCHAR(128) |
not null |
The enum type of the action (e.g.: PAUSE, RESUME) |
start_date |
Date |
not null |
The effective start of the given action, inclusive |
end_date |
Date |
optional |
The effective end date of the given action, inclusive |
created_by |
BIGINT |
not null |
Audit field |
created_on_utc |
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field - MySQL: DATETIME(6), PostgreSQL: TIMESTAMP WITH TIME ZONE |
last_modified_by |
BIGINT |
not null |
Audit field |
last_modified_on_utc |
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field - MySQL: DATETIME(6), PostgreSQL: TIMESTAMP WITH TIME ZONE |
Example Data
| id | loan_id | action | start_date | end_date | created_by | created_on_utc | last_modified_by | last_modified_on_utc |
|---|---|---|---|---|---|---|---|---|
1 |
100 |
PAUSE |
2023-10-05 |
2023-10-10 |
1001 |
2023-10-05T10:49:54Z |
1001 |
2023-10-05T10:49:54Z |
2 |
100 |
RESUME |
2023-10-08 |
1001 |
2023-10-07T10:49:54Z |
1001 |
2023-10-07T10:49:54Z |
|
3 |
100 |
PAUSE |
2023-10-11 |
2023-10-15 |
1001 |
2023-10-07T11:49:54Z |
1001 |
2023-10-08T10:49:54Z |
Database Query Logic
Determining Current Pause Status
In order to decide if the delinquency calculation is currently paused or not, the system checks for the most recent delinquency action (ordered by creation date) that is effective on the current business date:
-
For PAUSE actions: the business date must be between the pause start date and end date (inclusive)
-
For RESUME actions: the business date must be on or after the resume start date, and the resume action has no end date
The logic for interpreting the result:
-
Nothing is returned → delinquency calculation is enabled
-
PAUSE entry is returned → currently paused
-
RESUME entry is returned → delinquency calculation is enabled
Calculating Effective Pause Periods
For calculating delinquency ranges, the following algorithm is used:
-
All pause and resume entries are retrieved for a loan from the database
-
Effective pause periods are calculated as follows:
-
Groups entries by action type (PAUSE or RESUME)
-
For each PAUSE entry, finds a matching RESUME entry (if any) that overlaps with the pause period
-
When a matching RESUME is found, it sets the effective end date of the pause period to the resume start date
-
-
For each installment, the system filters out pause periods that are not relevant (e.g., not overlapping with the installment’s unpaid period)
-
The effective delinquency is calculated using the formula:
current date - unpaid installment date - sum(relevant pause days)
API Design
Delinquency Actions Endpoints
Two endpoints are provided for managing delinquency actions:
-
One endpoint retrieves all delinquency actions created for a loan
-
Another endpoint submits a new delinquency action for an existing loan
There are no endpoints for update and delete of existing delinquency actions. Once created, actions cannot be modified or deleted directly.
Retrieve All Delinquency Actions
Retrieve all delinquency-actions for a given loan:
GET /loans/{loanId}/delinquency-actions
GET /loans/external-id/{loanExternalId}/delinquency-actions
Response:
Returns an array of delinquency actions:
[
{
"id": 123,
"action": "PAUSE",
"startDate": [2023, 10, 5],
"endDate": [2023, 10, 8],
"createdById": 1,
"createdOn": "2023-10-05T10:49:54Z",
"updatedById": 1,
"lastModifiedOn": "2023-10-05T10:49:54Z"
},
{
"id": 125,
"action": "PAUSE",
"startDate": [2023, 11, 5],
"endDate": [2023, 11, 8],
"createdById": 1,
"createdOn": "2023-11-05T10:49:54Z",
"updatedById": 1,
"lastModifiedOn": "2023-11-05T10:49:54Z"
}
]
Create New Delinquency Action
Create a new delinquency action for a loan:
POST /loans/{loanId}/delinquency-actions
POST /loans/external-id/{loanExternalId}/delinquency-actions
Request Body:
{
"action": "pause",
"startDate": "2023-10-05",
"endDate": "2023-10-08",
"dateFormat": "yyyy-MM-dd",
"locale": "en"
}
Note: The dateFormat and locale fields are optional. If not provided, default values will be used.
Response:
Returns a standard command processing result:
{
"officeId": 1,
"clientId": 1,
"resourceId": 123
}
The resourceId field contains the ID of the created delinquency action.
Loan Read Endpoint
The loan read endpoint includes delinquency information, including pause periods.
GET /loans/{loanId}
GET /loans/external-id/{loanExternalId}
Response Structure:
The GetLoansLoanIdResponse contains the following fields related to delinquency:
-
GetLoansLoanIdDelinquencySummary delinquent- comprehensive delinquency summary data -
DelinquencyRangeData delinquencyRange- current delinquency range classification
Delinquency Summary Fields:
The GetLoansLoanIdDelinquencySummary object contains:
-
Collection Data:
-
availableDisbursementAmount(BigDecimal) - available amount for disbursement -
availableDisbursementAmountWithOverApplied(BigDecimal) - available amount with over-applied consideration -
pastDueDays(Integer) - number of days past due -
nextPaymentDueDate(LocalDate) - next payment due date -
nextPaymentAmount(BigDecimal) - next payment amount -
delinquentDays(Integer) - number of delinquent days -
delinquentDate(LocalDate) - date when loan became delinquent -
delinquentAmount(BigDecimal) - total delinquent amount -
delinquentPrincipal(BigDecimal) - delinquent principal amount -
delinquentInterest(BigDecimal) - delinquent interest amount -
delinquentFee(BigDecimal) - delinquent fee amount -
delinquentPenalty(BigDecimal) - delinquent penalty amount -
lastPaymentDate(LocalDate) - date of last payment -
lastPaymentAmount(BigDecimal) - amount of last payment -
lastRepaymentDate(LocalDate) - date of last repayment -
lastRepaymentAmount(BigDecimal) - amount of last repayment -
Pause Periods:
-
delinquencyPausePeriods(List<GetLoansLoanIdDelinquencyPausePeriod>) - list of pause periods for the loan -
Installment Level Delinquency:
-
installmentLevelDelinquency(List<GetLoansLoanIdLoanInstallmentLevelDelinquency>) - installment-level delinquency data (if enabled)
Pause Period Structure:
Each GetLoansLoanIdDelinquencyPausePeriod object contains:
-
active(Boolean) - indicates if this pause period is currently active based on the business date -
pausePeriodStart(LocalDate) - start date of the pause period -
pausePeriodEnd(LocalDate) - effective end date of the pause period
Note: The system supports multiple pause periods. The active field in each pause period indicates whether that specific period is currently active. A period is considered active if the current business date falls between pausePeriodStart (inclusive) and pausePeriodEnd (inclusive).
Validation Rules
General Rules
-
Pause and resume delinquency actions can only be created for active loan accounts
-
Currently only PAUSE and RESUME actions are supported
Validation Rules for Pause
-
Pause start date must be on or after the disbursement date of the loan
-
Pause must last at least a day (start date must be before end date)
-
Overlapping pause periods are not allowed
-
A loan account can have multiple pause periods, but the dates do not overlap with one another
-
Both start date and end date are mandatory for pause actions
Validation Rules for Resume
-
Resume can only be used when there is an active pause
-
Resume start date must be exactly equal to the current business date (cannot be backdated or future-dated)
-
In case of resume, end_date must not be specified
-
Only one resume action can exist for a given date (duplicate resumes on the same date are not allowed)
-
The resume date will become the effective pause end date for pause date calculations
Business Rules
Delinquency Calculation During Pause
-
While a loan account is actively paused:
-
No evaluation of delinquency will be performed for that loan account during COB processing
-
The delinquency bucket and tags remain unchanged during the pause period
-
Pause Period Expiration
-
When the business date moves beyond the pause period end date (or resume date, if applicable), the pause period is no longer active
-
Delinquency calculation runs during COB processing when the business date is outside any active pause periods
-
The system checks pause status during COB processing and calculates delinquency if the loan is not currently paused
Delinquency Recalculation After Pause Period
-
When a pause period ends (either by reaching the end date or through a resume action), delinquency is recalculated during the next COB (Close of Business) run when the system processes the loan and detects that it is no longer paused
-
The resume date (if used) becomes the effective pause end date for pause date calculations
-
Delinquency calculation running after the pause period takes into account the duration of the pause by subtracting pause days from the overdue calculation
Effective Pause Period Calculation
-
Effective pause periods are calculated by merging the pause and related resume entries
-
When there is a resume entry, it technically moves the end_date of the pause period earlier
-
For each installment, only relevant pause periods (overlapping with the installment’s unpaid period) are considered
-
The effective delinquency is calculated using the formula:
current date - unpaid installment date - sum(relevant pause days)
Event Definition
Overview
Events are emitted when delinquency actions are created and when the state of the delinquency calculation flag changes during COB processing.
New Business Event
LoanAccountDelinquencyPauseChangedBusinessEvent is emitted whenever a delinquency action (pause or resume) is created for a loan account. This event is triggered immediately after the action is saved to the database.
Existing Events
In the regular loan COB process, the system calculates and sets delinquency tags for loans with the current date.
LoanDelinquencyRangeChangeBusinessEvent is emitted when:
* The pause status changes during COB processing (e.g., when entering or exiting a pause period)
* A backdated pause action is created that affects the current delinquency status
Changes to CollectionDataV1
CollectionDataV1 (Avro schema) has been extended with a new field:
-
delinquencyPausePeriods(array ofDelinquencyPausePeriodV1) - list of pause periods for the loan
Each DelinquencyPausePeriodV1 contains:
-
active(boolean) - indicates if this pause period is currently active -
pausePeriodStart(string) - start date of the pause period -
pausePeriodEnd(string) - effective end date of the pause period
This allows external systems to track pause periods through the existing event infrastructure without requiring a new event schema version.
Event Trigger
The following events are triggered:
-
LoanAccountDelinquencyPauseChangedBusinessEvent: Emitted when any delinquency action (pause or resume) is created
-
LoanDelinquencyRangeChangeBusinessEvent: Emitted when the pause status changes during COB processing or when a backdated pause affects current status
Backdated Pause Handling
When a backdated pause action is created (start date is before the current business date), the system will:
-
Recalculate the loan’s delinquency data immediately
-
If the pause end date is after the current business date, emit
LoanDelinquencyRangeChangeBusinessEventto reflect the current pause status -
Always emit
LoanAccountDelinquencyPauseChangedBusinessEventwhen any action is created
Example Scenarios
Scenario #1: Simple Pause Period
Setup:
* Loan disbursement: January 1st, 2022 (Amount: 1000)
* Installment #1: Due January 7th, 2022 (Amount: 250)
* Installment #2: Due January 28th, 2022 (Amount: 250)
* Installment #3: Due February 14th, 2022 (Amount: 250)
* Installment #4: Due March 7th, 2022 (Amount: 250)
Pause Period: January 2nd - January 20th, 2022
Behavior:
-
Pause Flag: False on January 1st, True from January 2nd to January 20th, False from January 21st onwards
-
Installment #1:
-
Pause days: 1-18 (January 2nd-20th)
-
Unpaid installment date: Increments daily from 0
-
Overdue days: Shows 1 from January 7th to January 20th, then increments from 2 (January 21st)
-
-
Installment #2:
-
Pause days: 0-5 (January 2nd-20th, but only relevant days after installment due date)
-
Unpaid installment date: Increments daily from 0
-
Overdue days: Shows 1 from January 28th, then increments
-
-
Installment #3:
-
Pause days: 0-13 (January 2nd-20th, but only relevant days before installment due date)
-
Unpaid installment date: Increments daily from 0
-
Overdue days: Shows 1 from February 14th, then increments
-
Scenario #2: Pause with Resume
Setup:
* Same loan schedule as Scenario #1
Pause Period: January 2nd - January 20th, 2022
Resume Date: January 15th, 2022
Behavior:
-
Effective Pause Period: January 2nd - January 14th, 2022 (resume on 15th effectively ends the pause earlier)
-
Installment #1:
-
Pause days: 0-13 (January 2nd-14th)
-
Unpaid installment date: Increments daily from 0
-
Overdue days: Shows 1 from January 7th to January 14th, then increments from 1 (January 15th)
-
-
Installment #2:
-
Pause days: 0-13 (January 2nd-14th)
-
Unpaid installment date: Increments daily from 0
-
Overdue days: Shows 1 from January 28th, then increments
-
-
Installment #3:
-
Pause days: 0-13 (January 2nd-14th)
-
Unpaid installment date: Increments daily from 0
-
Overdue days: Shows 1 from February 14th, then increments
-
The resume action effectively moves the pause end date from January 20th to January 14th, resulting in fewer pause days being counted for delinquency calculations.
Summary
The Pause Delinquency feature provides a flexible mechanism for temporarily suspending delinquency calculations on loan accounts. Key aspects include:
-
Loan-level configuration (not product-level)
-
Support for multiple non-overlapping pause periods
-
Resume functionality to end pause periods early
-
Automatic expiration of pause periods when business date moves beyond the end date
-
Integration with existing delinquency calculation logic
-
Event emission when pause status changes
-
API endpoints for managing pause/resume actions
-
Extension of loan read API with pause status information
Loan Re-Amortization
Overview
Re-amortization recalculates EMI (Equal Monthly Installment) amounts for remaining installments based on the outstanding principal balance as of the current business date.
Example
A customer has a 1000 EUR loan with 4 monthly installments of 250 EUR each. After missing the first two payments (500 EUR overdue), re-amortization redistributes this amount across the remaining 2 installments, resulting in 500 EUR per installment instead of 250 EUR - without changing due dates or adding new installments.
Re-Amortization vs Re-Aging
| Aspect | Re-Amortization | Re-Aging |
|---|---|---|
Schedule structure |
Preserves existing installment count and due dates |
Creates new installments with user-defined count, start date, and frequency |
User input |
Only interest handling type |
Start date, number of installments, period type, frequency, interest handling |
Use case |
Redistribute overdue principal across existing future installments |
Restructure loan for customers in payment difficulty |
Supported Loan Type
Re-amortization is only available for:
-
Progressive loan schedule type
-
Advanced Payment Allocation strategy
-
For interest-bearing loans: interest recalculation must be enabled
Eligibility Criteria
-
Loan must be active (disbursed and not closed)
-
Transaction date must be before or on maturity date
-
Loan must not be charged-off
-
Loan must not be contract terminated
-
Only one re-amortization transaction is allowed per business day
Interest Handling Types
For interest-bearing loans, re-amortization supports three strategies:
| Type | Description |
|---|---|
|
Standard behavior - outstanding principal is redistributed across remaining installments |
|
Outstanding principal and interest are split equally across remaining installments |
Business Events
-
LoanReAmortizeBusinessEvent- triggered after re-amortization -
LoanReAmortizeTransactionBusinessEvent- triggered for the transaction -
LoanUndoReAmortizeBusinessEvent- triggered after undo operation -
LoanUndoReAmortizeTransactionBusinessEvent- triggered for undo transaction
Permissions
-
REAMORTIZE_LOAN- required to apply re-amortization -
UNDO_REAMORTIZE_LOAN- required to undo re-amortization
API Endpoints
Apply Re-Amortization
-
Endpoint:
POST /loans/{loanId}/transactions?command=reAmortize -
Alternative:
POST /loans/external-id/{loanExternalId}/transactions?command=reAmortize
Request Body
{
"locale": "en",
"dateFormat": "dd MMMM yyyy",
"externalId": "ext-123",
"reAmortizationInterestHandling": "DEFAULT",
"reasonCodeValueId": 1
}
| Parameter | Required | Description |
|---|---|---|
|
Yes |
Locale for parsing |
|
Yes |
Date format pattern |
|
No |
External identifier for the transaction (max 100 chars) |
|
No |
Interest handling type: |
|
No |
Code value ID for re-amortization reason (from |
Response Body
{
"officeId": 1,
"clientId": 1,
"loanId": 1,
"resourceId": 15,
"resourceExternalId": "ext-123",
"changes": {
"locale": "en",
"dateFormat": "dd MMMM yyyy"
}
}
Undo Re-Amortization
-
Endpoint:
POST /loans/{loanId}/transactions?command=undoReAmortize -
Alternative:
POST /loans/external-id/{loanExternalId}/transactions?command=undoReAmortize
Request Body
{
"locale": "en",
"dateFormat": "dd MMMM yyyy",
"externalId": "reversal-ext-123"
}
Response Body
{
"officeId": 1,
"clientId": 1,
"loanId": 1,
"resourceId": 15,
"resourceExternalId": "ext-123",
"changes": {
"locale": "en",
"dateFormat": "dd MMMM yyyy"
}
}
Preview Re-Amortization
Generates a preview of the re-amortized schedule without modifying the loan.
-
Endpoint:
GET /loans/{loanId}/transactions/reamortization-preview -
Alternative:
GET /loans/external-id/{loanExternalId}/transactions/reamortization-preview
Query Parameters
| Parameter | Required | Description |
|---|---|---|
|
Yes |
Interest handling type: |
Response Body
Returns the projected loan schedule with recalculated installments:
{
"currency": {
"code": "USD",
"decimalPlaces": 2
},
"loanTermInDays": 60,
"totalPrincipalDisbursed": 1000.00,
"totalPrincipalExpected": 1000.00,
"totalInterestCharged": 50.00,
"totalFeeChargesCharged": 0.00,
"totalRepaymentExpected": 1050.00,
"periods": [
{
"period": 1,
"dueDate": "01 February 2023",
"principalDue": 500.00,
"interestDue": 25.00,
"totalDueForPeriod": 525.00
},
{
"period": 2,
"dueDate": "01 March 2023",
"principalDue": 500.00,
"interestDue": 25.00,
"totalDueForPeriod": 525.00
}
]
}
Transaction Template
-
Endpoint:
GET /loans/{loanId}/transactions/template?command=reAmortization
Behavior
Installments excluded from redistribution:
-
Down-payment installments
-
Re-aged installments
-
Additional (N+1) installments
Outstanding principal from installments with due date ≤ transaction date is redistributed equally across future installments.
Implementation Notes
Creates a REAMORTIZE transaction with:
-
transactionDate= current business date -
amount= outstanding principal until transaction date -
principalPortion= outstanding principal -
interestPortion= 0 -
feeChargesPortion= 0 -
penaltyChargesPortion= 0
Non-monetary transaction - no GL entries created.
Loan Re-Aging
Overview
Re-aging also known as Settlement Plan is to assist customers who are in duress around repaying their loans;
The goal is to create a new set of Installments for the same loan account from the principal outstanding pending on the loan account
For example, for a customer who is severely behind on payments, they could agree to a settlement plan where instead of having to pay now, it could be spread over the next 12 months, paying back the remaining 750 euro balance but over 10 equal payments.
Introduction
Purpose
This document specifies the functional and business requirements for the Re-Aging feature for Progressive loan products in Fineract.
It defines rules for interest handling, schedule regeneration, charge/fee mapping, special installments, and reversal logic to ensure consistent behavior across different interest handling methods.
Scope
The scope of this document includes:
* Re-Aging for Progressive, interest-bearing loans * Handling of Default, Equal Amortization scenarios * Support for backdated transactions * Schedule regeneration and installment remapping * Special collected installment creation * Reversal and replay of Re-Age transactions * User-driven configuration of re-aged schedule (start date, number of installments, period type/frequency)
This document does not cover:
* Non-Progressive loan strategies * Charge-off loan accounts * Interest-bearing loans outside the scope of Fineract Progressive product definition * External API documentation (covered separately)
Applicability
The requirements described apply to:
* Progressive loan products * Non-interest-nearing and Interest-bearing accounts with or without interest recalculation enabled * Loan accounts where re-aging may occur at any date ≥ last repayment date
Definitions and Key Concepts
Re-Age: The process of recalculating and adjusting the remaining loan schedule, including principal, interest, fees, and penalties, starting from a specified start date.
Transaction Date: The accounting/event date when re-aging is applied.
Start Date: The due date of the first re-aged installment (re-age start date).
Special Collected Installment: A single installment created to capture paid portions from installments with due-date after the transaction date.
N+1 Installment: Any additional installment added to the schedule before or after re-aging to balance principal, interest, and fees.
Interest Handling: The method applied during re-aging — Default, Equal Amortization.
Chargeback: A partial or full reversal of a previously posted repayment, adjusting principal, interest, and/or fees back to their prior state.
General Re-Aging Rules
Eligibility Criteria
-
Re-Aging is allowed only for Progressive loan products.
-
The re-age transaction date must be greater than or equal to the last repayment date.
-
Re-Aging can be applied anywhere in the loan lifecycle, including before maturity.
Transaction Date vs Start Date
-
Transaction Date: The accounting/event date when re-aging is applied.
-
Start Date: The due date of the first re-aged installment.
-
In backdated scenarios, transaction date = start date.
-
In non-backdated scenarios, transaction date ≤ start date.
-
The re-aged schedule begins from the start date, irrespective of past due installments.
Partial Payments Handling
-
Any installment with a partially paid amount before the transaction date:
-
The paid portion remains allocated to the original installment.
-
The unpaid portion is included in the re-aged schedule.
-
-
Installments fully paid before transaction date remain unchanged.
Down-Payment Handling
-
Down-payments are not affected by re-aging.
-
They remain linked to the original installment and are excluded from the re-age calculations.
Chargebacks
-
Charge-back amounts (principal, interest, fees) are processed according to the selected interest handling scenario:
-
Equal Amortization: split across new installments.
-
Default: principal re-aged, interest moved to first new installment.
-
-
Only unpaid are affected.
Special Collected Installments
-
Any installment with due-date > transaction date that has payments posted before the transaction date:
-
Paid portions are merged into a single special collected installment.
-
Due date = transaction date.
-
GL postings must remain identical to original payments.
-
The special installment does not participate in interest/principal redistribution.
-
-
Supports reverse and replay logic for backdated transactions.
Reversal and Replay Rules
-
Re-Age transactions can be reversed only if they are the latest transaction on the loan account.
-
Any backdated repayment or reversal triggers reverse + replay:
-
Reverts schedule to original state before re-age.
-
Removes special collected installment if it exists.
-
Restores charge/fee mapping.
-
-
Backdated transaction posting or reversals triggers reverse + replay
User Input Parameters
Users must provide the following inputs when applying re-aging:
* Start Date: first due date of the re-aged schedule.
* Number of Re-aged Installments: total installments to generate.
* Period Type: Daily, Weekly, Monthly, or Yearly.
* Period Frequency: number of units per period type.
* Interest Handling Method: Default, Equal Amortization.
* These inputs drive the schedule generation, replacement of future installments, and calculation of N+1 installment.
* In case of non-interest-bearing loans interest handling strategy is omitted
Implementation details
Implementation core is located in AdvancedPaymentScheduleTransactionProcessor.
the entrypoint of the feature is handleReAge.
handleReAge function is the purpose of configure the different re-age strategies and handle both interest bearing and non interest bearing scenarios.
There is no real handling, and all the different specific handlers are described in the related interest handling scenarios dev notes chapters.
Non-Interest-Bearing Scenario
Background and Context
Re-aging functionality for Non-Interest-Bearing Progressive loans existed only after maturity date.
This means Fineract now supports re-aging before maturity date.
This enhancement introduces the ability to:
* Apply re-aging at any point after the first disbursement
* Apply re-aging before or after maturity
* Override installment schedule when necessary * Handle partially paid installments via special installment creation
Principal-only amortization — no future interest calculation involved.
General Re-Aging Rules
-
Re-aging date may be before last repayment date
-
Only outstanding principal + outstanding fees/penalties are redistributed
-
Re-aging transaction remains non-monetary
-
GL entries are not created
Fees/Penalties behavior:
* Fees/penalties retain their due dates
* Re-mapped to appropriate new installment when impacted by re-aging
Schedule Modification Requirements
When the re-aging transaction occurs before maturity date or overlaps future repayment periods:
-
Installments before the re-aging transaction date
-
Only paid portions are retained
-
Any unpaid portion is included in re-aging redistribution
-
-
Installments on or after the re-aging transaction date
-
Replaced with newly generated re-aged installments
-
New due dates and installment structure are based on re-aging configuration (period count, period type, frequency)
-
Handling of N+1 Installments (Post-Maturity Items)
N+1 installments exist to hold items that occur after the core repayment term — typically post-maturity fees and penalties (and in some contexts, post-maturity interest; interest is out of scope for this document but noted here for context).
Business rule:
* After re-aging, an existing N+1 installment is kept only if its due date is strictly after the new maturity date produced by the re-aged schedule.
* If an N+1 installment’s due date is on or before the new maturity date, it is removed and any underlying items are handled as part of the regenerated re-aged schedule.
Special Installment Handling
Special installments are created when the re-aging transaction date is before or on previous maturity date.
In this case:
* Any installment occurring after the re-aging date that has a paid portion is collected and converted into special installments to preserve historical repayment accuracy.
* Transactions already posted remain mapped to these special installments.
* All general ledger entries remain unchanged — no reversals are introduced because the payments were already consumed by the loan.
This ensures:
* Historical repayment activity is represented accurately.
* Only unpaid obligations are restructured in the new re-aged repayment plan.
Developer’s Note
Implementation is found in AdvancedPaymentScheduleTransactionProcessor Entry point for non-interest-bearing loans is handleReAgeWithCommonStrategy.
This implementation also contains solution for interest-bearing but not EMICalculator compatible loan schedules.
Implementation-wise it has the same logic, the only exception is the interest related calculations ends in zero amounts.
Core Logic steps:
-
determine outstanding balances
-
update transaction amounts to outstanding balances
-
handle reversal if total amount is zero
-
calculate EqualAmortizationValues for attributes recalculated by re-age
-
handle charges if needed
-
collect paidInAdvanceBalances - to move them into the special installment if required
-
create special installment for early repaid balances.
-
create first re-aged installment
-
try to merge or insert it depending on the existing schedule
-
add charge mapping
-
-
create other re-aged installments if needed
-
try to merge or insert it depending on the existing schedule
-
add charge mapping
-
Note: merging the re-aged installments depends on the existing installment due-date and index.
Also, it keeps the charge mapping correct, in case charges (fees and penalties) should not be affected by re-age.
Interest Handling Scenarios
Scenario #1 — Equal Amortization
Core Logic
-
Outstanding unpaid amounts (principal, interest, fees/penalties ≤ transaction date) are split equally across the newly generated installments.
-
No new interest accrues after re-age — interest recalculation and declining balance logic are turned off.
-
User-selected interest scope determines which interest is included:
-
Outstanding Payable Interest: only interest due to date.
-
Outstanding Full Interest: all accrued interest, including future installments if applicable.
-
Outstanding Principal, Interest, Fees/Penalties
-
Principal: only unpaid portion included in equal split.
-
Interest: split according to user-selected interest scope.
-
Fees/Penalties: unpaid portions with due-date ≤ transaction date are included in the equal split; due-date > transaction date remain mapped to original installments.
Special Collected Installment
Same to non-interest-bearing scenario
Schedule Generation & N+1 Installment Adjustment
-
Re-aged installments start from the user-provided start date.
-
The number of installments, period type, and frequency are user-defined.
-
N+1 installment may be adjusted to balance rounding differences in principal/interest/fees.
-
Fully paid installments before start date remain unchanged; partially paid installments retain the paid portion, with unpaid portion included in re-age.
GL / Transaction Recording
-
A non-monetary re-age transaction is posted with amount = total outstanding principal + interest + fees/penalties included in equal split.
-
Special collected installment GL postings match original repayments.
-
Reversal of re-age removes non-monetary transaction and restores original schedule and mappings.
Developer’s Note
Entrypoint: AdvancedPaymentScheduleTransactionProcessor:handleReAgeEqualAmortizationEMICalculator
Core Logic steps:
-
determine outstanding balances
-
update transaction amounts to outstanding balances
-
handle reversal if total amount is zero
-
calculate EqualAmortizationValues for non progressive model handled attributes
-
handle charges
-
collect paidInAdvanceBalances - to move them into the special installment if required
-
update model with EMICalculator - see corresponding steps in next chapter
-
remove installments after transaction date
-
keep N+1 installments and update it’s from date if it has a due date after the new maturity date, otherwise it is removed
-
-
create special and re-aged installments according to model and update by EqualAmortizationValues calculated values
-
update installments index
Entry point: reAgeEqualAmortization
-
Determine original maturity and check if transaction is after it
-
Calculate outstanding principal, interest for re-age
-
Calculate already paid balances since transaction date
-
Also sets moved credited principal & interest balances to handle not paid credited portions post re-age correctly which will be added back to fist re-age repayment period
-
-
Accelerate maturity date
-
this step is actually modifies existing model by removing interest and repayment periods from re-age transaction date
-
-
Close existing repayment periods
-
Marks outstanding interest as moved due to re-aging.
-
Sets EMI of each period to total paid amount.
-
Stops further unrecognised interest calculation.
-
-
Handle early repaid installments
-
If any principal or interest has been paid beyond the transaction date:
-
Preserves paid amounts in a special collected repayment period.
-
-
Update model for re-aged periods
-
Generates empty repayment periods according to the number of new installments.
-
-
Calculate EMI for new periods
-
Splits principal, interest, and fees/penalties equally across new installments.
-
Ensures rounding differences are applied to the last installment.
-
-
Recalculate outstanding balance
-
Add zero-interest period
-
Ensures interest rate after re-age start is handled correctly.
-
If transaction is posted before original maturity date, we apply 0 interest from the transaction date
-
If transaction is posted after maturity date, we should add zero interest period from the original maturity date.
-
-
Calculate last unpaid repayment period EMI
-
Ensures final installment balances the loan fully.
-
Scenario #2 — Default Behavior
Core Logic
-
Overdue principal and charge-back principal are re-aged across new installments.
-
Overdue interest and charge-back interest are moved 100% to the first new re-aged installment.
-
Future interest continues to accrue according to loan product rules (only past-due interest may be included in re-age calculation if applicable).
-
Overdue fees and penalties are moved to the first new re-aged installment.
-
Future fees and penalties remain on their original installments and are adjusted externally (not part of re-age logic).
Overdue vs Future Interest Handling
-
Only past-due interest may be included in the re-age calculation.
-
Overdue interest is not split — it goes entirely to the first re-aged installment.
-
Future interest is calculated normally per the interest model.
-
Charge-back interest follows the same rule: moved entirely to the first re-aged installment.
Overwriting Existing Future Installments
-
Existing installments with due-date ≥ start date are replaced by re-aged installments.
-
New installments are generated according to user-provided start date, number of installments, period type, and frequency.
-
If re-age reduces total installments, any installments beyond new maturity date are removed.
-
N+1 installments are recalculated to balance principal, interest, and fees.
Special Collected Installment
Same to non-interest-bearing scenario
Fees/Penalties Handling
-
Overdue fees and penalties are moved to the first re-aged installment.
-
Fees/penalties with due-date > start date remain on their original installments; mappings adjusted if installment numbering changes.
-
Recalculation of future fees and penalties is handled outside re-age logic.
Schedule Generation & N+1 Installment Adjustment
-
Schedule starts from start date provided by user.
-
Number of installments, period type, and frequency are user-defined.
-
Fully paid installments before start date remain unchanged.
-
Partially paid installments: paid portion retained, unpaid portion included in re-aged principal.
Reversal / Backdated Transaction Handling
-
Re-age can be reversed only if it is the latest transaction.
-
Any backdated repayment or reversal triggers reverse + replay:
-
Removes special collected installment.
-
Restores original installment schedule.
-
Restores original charge/fee mappings and GL postings.
-
Re-applies re-age if necessary.
-
Schedule & Installment Mapping
Charge/Fee → Installment Mapping
-
All fees and penalties are linked to specific installments in the schedule.
-
After re-aging, the mapping must be updated to point fees/penalties to the correct new installment IDs.
-
For fees/penalties with due-date > start date:
-
Preserve original due-date.
-
Adjust installment mapping only if installment IDs are shifted due to schedule regeneration.
-
-
Overdue fees/penalties moved to first re-aged installment must have their mapping updated to the new installment ID.
Due Date Recalculation Rules
-
Re-aged installments start from user-provided start date.
-
Subsequent installment due-dates are calculated based on:
-
Period Type: Daily, Weekly, Monthly, Yearly
-
Period Frequency: number of periods between installments
-
-
Fully paid installments before start date remain unchanged.
-
Partial payments are preserved for principal and fees; only unpaid portions are re-aged.
-
Any installment after the new maturity date is deleted if the re-age reduces total number of installments.
N+1 Installment Adjustment
-
After generating re-aged installments, the N+1 installment is adjusted to balance rounding differences in principal, interest, and fees.
-
Applies to all interest handling scenarios.
-
Ensures total outstanding principal, interest, and fees are fully allocated across the new schedule.
Edge Cases
-
Special collected installment:
-
Created if transaction date is before or on maturity date.
-
Merges all paid portions into a single installment.
-
GL postings must match original payments.
-
Does not participate in principal/interest redistribution.
-
-
Reversal of re-age:
-
Only allowed if it is the latest transaction.
-
Removes special collected installment.
-
Restores original installment schedule, charge/fee mappings, and GL postings.
-
-
Backdated re-age:
-
Transaction date = start date.
-
Reverse + replay logic applied to rebuild schedule correctly.
-
Validation Rules
Transaction Date Validation
-
Transaction date cannot be before the start of the loan lifecycle.
-
For backdated re-age, transaction date = start date.
Start Date Validation
-
Start date cannot be earlier than the loan disbursement date.
-
Start date drives the first installment of the re-aged schedule.
Re-Age Eligibility
-
Re-aging is allowed only for Progressive loan strategy.
-
Charge-off loan accounts are not eligible for re-aging.
Summary Comparison Table For Interest Handling Scenarios
| Feature | Equal Amortization | Default Behavior | Notes |
|---|---|---|---|
Outstanding Principal |
Equally split across new installments |
Re-aged according to schedule |
Only unpaid portion is considered; paid portion preserved |
Outstanding Interest |
Equally split (full or payable) among new installments |
Overdue interest moved 100% to first re-aged installment; future interest continues normally |
— |
Charge-back Principal |
Equally split |
Re-aged according to schedule |
— |
Charge-back Interest |
Equally split |
Moved 100% to first re-aged installment |
— |
Overdue Fees / Penalties |
Equally split |
Moved to first re-aged installment |
Future fees handled externally, not part of re-age |
Special Collected Installment |
Created if installments after transaction date are partially paid; due date = transaction date; GL postings preserved |
Same as Equal Amortization |
Not included in principal/interest redistribution |
User Input Parameters |
Start date, number of installments, period type/frequency, interest handling method |
Same as Equal Amortization |
Inputs drive new schedule generation |
Delayed Schedule Captures / Full Term Tranches
Overview
Full Term Tranche is a feature for multi-disbursement Progressive loans where each disbursement (tranche) is amortized over the full original term of the loan, instead of over the remaining term only. This supports use cases where captures (disbursements) can happen later in the loan lifecycle, while still granting the customer the full intended term for each captured amount.
Configuration
Loan Product Level
The Full Term Tranche feature is configured on the loan product.
The following conditions apply:
-
Multi-disbursement must be enabled
-
Loan schedule type must be PROGRESSIVE (which requires the advanced payment allocation strategy)
A boolean configuration flag is available on the loan product:
-
allowFullTermForTranche– Allow full term length for each tranche disbursement -
Default value:
false -
When set to
true, the system enables full term schedule calculation for each disbursement -
Validation rules:
-
If multi-disbursement is not enabled, enabling this flag is rejected
-
If the schedule type is not PROGRESSIVE, enabling this flag is rejected
-
Loan Account Level
On loan creation, the loan-level flag is determined as follows:
-
By default, the loan inherits the
allowFullTermForTranchevalue from the loan product -
The loan application request may optionally include a boolean
allowFullTermForTranche: -
If present, it overrides the inherited value for that specific loan
-
If set to
truewhile the product-level flag isfalse, the request is rejected with validation error
The loan entity stores this value as a persistent field.
Behavior
Base Schedule Generation
For the first disbursement, the repayment schedule is generated according to the standard Progressive EMI calculation rules:
-
The number of repayments and term are taken from the loan product configuration
-
Installment dates follow the pattern defined by:
-
Loan term frequency and type
-
Repayment frequency and type
-
The first installment due date is derived from the loan configuration (disbursement date, repayment frequency, minimum days between disbursement and first repayment, calendar settings if applicable)
When allowFullTermForTranche is disabled (false), subsequent disbursements behave as in the existing multi-disbursement implementation:
-
The new amount is distributed over the remaining future installments
-
The original maturity date is not extended due to additional disbursements
Full Term Tranche Behavior (Enabled)
When allowFullTermForTranche is enabled (true) and the product has a positive number of repayments configured:
-
For each new disbursement:
-
The system determines the period in the current schedule where the disbursement date falls
-
A temporary full-term schedule is calculated for the disbursed amount:
-
Principal: equal to the disbursed amount
-
Term length (number of repayments): equal to the loan product’s number of repayments
-
Term frequency and repayment frequency: copied from the loan product
-
Start date: the start date of the period that contains the disbursement date (or the current maturity date if no such period exists)
-
The temporary schedule covers the full term for that disbursement, starting from the identified start date
-
The temporary schedule is then merged into the existing loan schedule:
-
If a period in the temporary schedule has the same from-date and due-date as an existing period, the principal and interest due amounts are added to the existing period
-
If a period in the temporary schedule does not match any existing period, a new period is added to the schedule
-
When installments are created from the merged schedule, only periods with due dates on or after the disbursement date become installments
As a result, overlapping installments from multiple disbursements on the same due date are aggregated into a single installment with summed amounts, and the maturity date can move later than originally configured to accommodate the full term of later disbursements.
Disbursement Scenarios
When full term tranche is enabled:
-
Disbursement on an existing installment date: The due amounts (principal and interest) for that date are increased according to the full-term calculation for the new tranche
-
Disbursement between installment dates (mid-period): A new full-term schedule is calculated from the start of the period that contains the disbursement date, and the newly generated installments are merged into the existing schedule
-
Multiple disbursements before the first repayment date: Each disbursement contributes its own full-term schedule from the same starting point, and the resulting installments reflect the aggregated principal and interest across all tranches
API
The allowFullTermForTranche field is available in:
-
Loan Product API: Create/update requests and read responses
-
Loan API:
-
Create/update requests (optional, inherits from product if not provided)
-
Read responses (indicates whether the feature is active for the loan)
The repayment schedule returned by the loan read endpoint reflects the merged full-term schedule when this feature is enabled, showing increased installment amounts on overlapping due dates and extended number of installments when later disbursements extend the term.
Loan Origination Details
Overview
Tracks the originator of a loan (merchant, broker, affiliate, platform, or channel) for revenue sharing and reporting. Originator details are propagated through business events and reporting.
Configuration
Enabling the Module
fineract.module.loan-origination.enabled=${FINERACT_MODULE_LOAN_ORIGINATION_ENABLED:true}
Enabled by default. When disabled, API endpoints become unavailable, event enrichment is skipped, and the loan creation flow continues to work without originator processing.
Global Configuration
| Configuration Key | Default | Description |
|---|---|---|
|
|
Allows automatic creation of new originator records when an unknown |
Data Model
Originator Registry (m_loan_originator)
| Column | Type | Required | Description |
|---|---|---|---|
|
BIGINT (PK) |
Yes |
Auto-generated primary key |
|
VARCHAR(100) |
Yes |
Unique, immutable external identifier (a.k.a. Revenue Share ID) |
|
VARCHAR(255) |
No |
Originator display name |
|
VARCHAR(20) |
Yes |
|
|
INT (FK) |
No |
Code value reference to |
|
INT (FK) |
No |
Code value reference to |
|
DATETIME |
Yes |
Record creation timestamp (UTC) |
|
BIGINT (FK) |
Yes |
Foreign key to |
|
DATETIME |
Yes |
Last modification timestamp (UTC) |
|
BIGINT (FK) |
Yes |
Foreign key to |
Loan-Originator Mapping (m_loan_originator_mapping)
Associates loans with originators. Supports multiple originators per loan, though typical usage is one.
| Column | Type | Required | Description |
|---|---|---|---|
|
BIGINT (PK) |
Yes |
Auto-generated primary key |
|
BIGINT (FK) |
Yes |
Foreign key to |
|
BIGINT (FK) |
Yes |
Foreign key to |
|
DATETIME |
Yes |
Record creation timestamp (UTC) |
|
BIGINT (FK) |
Yes |
Foreign key to |
|
DATETIME |
Yes |
Last modification timestamp (UTC) |
|
BIGINT (FK) |
Yes |
Foreign key to |
A unique constraint on (loan_id, originator_id) prevents duplicate assignments.
Code Values
LoanOriginatorType (default values): MERCHANT, BROKER, AFFILIATE, PLATFORM
LoanOriginationChannelType (default values): ONLINE, IN_STORE, API, AGGREGATOR
Both code value sets are extensible — additional values can be added via the standard Code Values API.
API Endpoints
Originator Registry APIs
Create a Loan Originator
POST /v1/loan-originators
Permission: CREATE_LOAN_ORIGINATOR
{
"name": "Best Merchant in US",
"externalId": "best-merchant-us-east",
"status": "ACTIVE",
"originatorTypeId": 12,
"channelTypeId": 44
}
-
externalId— required, unique, max 100 characters -
name— optional, max 255 characters -
status— optional, defaults toACTIVE. Allowed values:ACTIVE,PENDING,INACTIVE -
originatorTypeId— optional, must reference a validLoanOriginatorTypecode value -
channelTypeId— optional, must reference a validLoanOriginationChannelTypecode value
Response:
{
"resourceId": 13,
"resourceExternalId": "best-merchant-us-east"
}
| HTTP Code | Description |
|---|---|
200 |
Created successfully |
400 |
Required parameter missing or incorrect format |
403 |
Duplicate external ID or insufficient permissions |
404 |
Originator type or channel type does not exist |
List All Loan Originators
GET /v1/loan-originators
Permission: READ_LOAN_ORIGINATOR
[
{
"id": 13,
"externalId": "best-merchant-us-east",
"name": "Best Merchant in US",
"status": "ACTIVE",
"originatorType": {
"id": 12,
"name": "MERCHANT",
"active": true,
"mandatory": false
},
"channelType": {
"id": 44,
"name": "ONLINE",
"active": true,
"mandatory": false
}
}
]
Retrieve a Loan Originator
GET /v1/loan-originators/{originatorId}
GET /v1/loan-originators/external-id/{externalId}
Permission: READ_LOAN_ORIGINATOR
Get Template Data
GET /v1/loan-originators/template
Permission: READ_LOAN_ORIGINATOR
Returns a pre-generated externalId, available status values, and code value options for originator type and channel type.
Update a Loan Originator
PUT /v1/loan-originators/{originatorId}
PUT /v1/loan-originators/external-id/{externalId}
Permission: UPDATE_LOAN_ORIGINATOR
{
"status": "PENDING"
}
Updatable fields: name, status, originatorTypeId, channelTypeId. Only changed fields need to be included.
Response:
{
"resourceId": 13,
"resourceExternalId": "best-merchant-us-east",
"changes": {
"status": "PENDING"
}
}
| HTTP Code | Description |
|---|---|
200 |
Updated successfully |
400 |
Unsupported parameter (e.g. |
404 |
Originator not found |
|
The |
Delete a Loan Originator
DELETE /v1/loan-originators/{originatorId}
DELETE /v1/loan-originators/external-id/{externalId}
Permission: DELETE_LOAN_ORIGINATOR
| HTTP Code | Description |
|---|---|
200 |
Deleted successfully |
403 |
Originator is currently mapped to one or more loans |
404 |
Originator not found |
|
An originator cannot be deleted if it is currently mapped to any loans. |
Loan-Originator Mapping APIs
Attach Originator to Loan
POST /v1/loans/{loanId}/originators/{originatorId}
POST /v1/loans/{loanId}/originators/external-id/{originatorExternalId}
POST /v1/loans/external-id/{loanExternalId}/originators/{originatorId}
POST /v1/loans/external-id/{loanExternalId}/originators/external-id/{originatorExternalId}
Permission: ATTACH_LOAN_ORIGINATOR
No request body.
Response:
{
"loanId": 45,
"loanExternalId": "11793428-12cb-42fe-ab9f-72b4ddf2453a",
"originatorId": 13,
"originatorExternalId": "best-merchant-us-east"
}
| HTTP Code | Description |
|---|---|
200 |
Attached successfully |
403 |
Loan is not in Submitted and Pending Approval status, originator is not ACTIVE, or mapping already exists |
404 |
Loan or originator not found |
Detach Originator from Loan
DELETE /v1/loans/{loanId}/originators/{originatorId}
DELETE /v1/loans/{loanId}/originators/external-id/{originatorExternalId}
DELETE /v1/loans/external-id/{loanExternalId}/originators/{originatorId}
DELETE /v1/loans/external-id/{loanExternalId}/originators/external-id/{originatorExternalId}
Permission: DETACH_LOAN_ORIGINATOR
No request body. Response format is the same as Attach.
| HTTP Code | Description |
|---|---|
200 |
Detached successfully |
403 |
Loan is not in Submitted and Pending Approval status |
404 |
Loan, originator, or mapping not found |
Retrieve Originators for a Loan
GET /v1/loans/{loanId}/originators
GET /v1/loans/external-id/{loanExternalId}/originators
Permission: READ_LOAN
{
"originators": [
{
"id": 13,
"externalId": "best-merchant-us-east",
"name": "Best Merchant in US",
"status": "ACTIVE",
"originatorType": {
"id": 12,
"name": "MERCHANT",
"active": true,
"mandatory": false
},
"channelType": {
"id": 44,
"name": "ONLINE",
"active": true,
"mandatory": false
}
}
]
}
Originators via Retrieve Loan API
Originator details can also be fetched as part of the standard Retrieve Loan API using the associations query parameter:
GET /v1/loans/{loanId}?associations=originators
GET /v1/loans/external-id/{loanExternalId}?associations=originators
Permission: READ_LOAN
The originators association is also included when associations=all is used. Since the associations parameter defaults to all, originator data is included in the Retrieve Loan response by default (even without an explicit associations parameter). The response adds an originators field to the loan object with the same structure as the dedicated endpoint above.
Attach/Detach Validation Rules
|
Inline Originator Creation During Loan Application
Originators can be provided as part of the loan creation request (POST /v1/loans):
{
"...": "...",
"originators": [
{
"id": 1,
"externalId": "XYZ",
"name": "PP Merchant",
"originatorTypeId": 1,
"channelTypeId": 2
}
]
}
-
idorexternalIdis mandatory for each entry -
If
idis provided: attaches the existing originator (lookup byidtakes priority overexternalId) -
If only
externalIdis provided:-
Attaches the existing originator if found
-
Creates a new originator and attaches it (only when
enable-originator-creation-during-loan-applicationistrue) -
Returns 403 if originator is not found and creation is not enabled
-
-
name,originatorTypeId,channelTypeIdare optional, used only when creating a new entry -
Newly created originators are automatically assigned
ACTIVEstatus -
Duplicate originators within the same request are silently skipped
Business Events Integration
Originator details are automatically included in all loan and loan transaction external business events.
An OriginatorDetailsV1 Avro record is added as an optional originators field (list) to:
-
LoanAccountDataV1.avsc— all loan-centric events -
LoanTransactionDataV1.avsc— all loan transaction-centric events
{
"name": "OriginatorDetailsV1",
"fields": [
{"name": "id", "type": ["null", "long"]},
{"name": "externalId", "type": ["null", "string"]},
{"name": "name", "type": ["null", "string"]},
{"name": "status", "type": ["null", "string"]},
{"name": "originatorType", "type": ["null", "CodeValueDataV1"]},
{"name": "channelType", "type": ["null", "CodeValueDataV1"]}
]
}
The field is optional with default null, preserving backward compatibility for existing event consumers.
On any loan or loan transaction event publication, the enricher fetches originator mappings for the loan, builds the originators list from the registry, and attaches it to the event payload.
Security and Permissions
| Permission | Description |
|---|---|
|
Create originator records |
|
View originator registry records and template data |
|
View loan-originator associations ( |
|
Modify originator records |
|
Delete originator records (only if not mapped to loans) |
|
Attach an originator to a loan |
|
Detach an originator from a loan |
Reporting
Originator external IDs are included in the following stretchy reports:
-
Transaction Summary Report
-
Trial Balance Report
Report queries join m_loan_originator_mapping and m_loan_originator via a CTE. Multiple originators per loan are aggregated into a comma-separated string (STRING_AGG on PostgreSQL, GROUP_CONCAT on MySQL). The resulting column is originator_external_ids. Loans without originators have NULL in this column.
Taxes on Loan Charges
|
Introduced in FINERACT-1289 — Tax component not working as expected: tax bifurcation in journal entries was not applied when a tax group was mapped to a loan charge. |
Overview
When a loan charge (fee or penalty) is linked to a tax group, the system must produce separate journal entry lines for the base charge amount and the tax portion. Prior to this fix, the tax bifurcation was silently skipped and the full charge amount was posted as a single income entry, causing incorrect GL balances and tax liability omissions.
This fix ensures that every time a loan charge amount is set or recalculated, any configured tax group is evaluated, the tax split is computed per tax component, and both the net charge amount and the tax liability are recorded as distinct journal entries under both accrual-based and cash-based accounting modes.
Benefits
-
Tax liability GL accounts are credited correctly when a taxed charge is collected.
-
Income accounts reflect the net-of-tax charge amount rather than the gross amount.
-
Per-component tax breakdowns are persisted, enabling audit trails and reporting.
-
Both accrual and cash accounting modes handle tax bifurcation consistently.
Design
Key Components
| Component | Module | Purpose |
|---|---|---|
|
|
JPA entity ( |
|
|
Computes the per-component tax split from a |
|
|
Calls |
|
|
Carries per-component tax payment data (charge ID, credit GL account ID, amount, penalty flag) from the loan transaction to the accounting layer. |
|
|
Shared helper that filters tax payments by type (fee/penalty), computes net charge amounts, and creates credit/debit journal entries for tax liability accounts. |
|
|
Extended to split fee and penalty journal entries into net income and tax liability entries when tax payments are present. |
|
|
Extended with the same tax bifurcation logic for cash-basis accounting. |
Data Model
Two schema changes are introduced by migration 1035_add_tax_to_loan_charge.xml.
-- Column added to m_loan_charge to store the total tax amount for the charge
ALTER TABLE m_loan_charge
ADD COLUMN tax_amount DECIMAL(19,6) NULL;
-- New table storing the per-component tax breakdown for each loan charge
CREATE TABLE m_loan_charge_tax_details (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
loan_charge_id BIGINT NOT NULL, -- FK → m_loan_charge.id
tax_component_id BIGINT NOT NULL, -- FK → m_tax_component.id
amount DECIMAL(19,6) NOT NULL
);
Accounting Impact
When a loan transaction pays a charge that has associated tax, the journal entries are split between the net charge amount and the tax liability.
Accrual-Based Accounting — Fee Payment
| Side | Account | Amount |
|---|---|---|
DR |
Fees Receivable |
Full fee amount (including tax) |
CR |
Income from Fees |
Net fee amount (fee minus tax) |
CR |
Tax Liability (per component) |
Tax amount |
Accrual-Based Accounting — Fee Reversal
| Side | Account | Amount |
|---|---|---|
CR |
Fees Receivable |
Full fee amount (including tax) |
DR |
Income from Fees |
Net fee amount |
DR |
Tax Liability (per component) |
Tax amount |
The same split applies to penalty charges using the penalty income and receivable accounts. Cash-based accounting follows the same debit/credit rules without the receivable leg.
If no tax group is configured on the charge, or the computed tax is zero, the existing single-entry behaviour is preserved.
Configuration
Prerequisites
-
Create one or more Tax Components (Administration → Tax Configuration → Tax Components) with the applicable percentage rate.
-
Create a Tax Group that references the tax components.
-
On the Charge product, assign the tax group under the Tax Group field.
-
Add the charge to a Loan Product that has either Periodic Accrual or Cash-Based accounting enabled.
Validation Rules
-
Tax is computed at the time the charge amount is set or updated; a later change to the charge amount triggers recomputation.
-
The effective date for tax rate lookup defaults to the charge submission date, falling back to the current business date when the submission date is absent.
-
If the tax group yields a zero total tax (e.g., all components have 0% rate on the effective date), no tax entries are created and the charge behaves as untaxed.
Usage Example
-
Configure a tax component "VAT 16%" and a tax group "Standard VAT".
-
Create a flat loan fee of 1,000 and link it to the "Standard VAT" group.
-
Add the fee to a loan product with periodic accrual accounting.
-
After disbursement, the fee appears with
amount = 1,000andtax_amount = 160. -
When the borrower repays the charge the system posts:
-
DR Fees Receivable 1,000
-
CR Income from Fees 840
-
CR Tax Liability 160
-
Template API Filtering
The charge template API is enhanced to support filtered responses for charge creation.
Previously, the template endpoint returned every possible configuration option regardless of applicability. This behavior remains unchanged when no filter parameters are provided.
With this enhancement, the API supports hierarchical filtering of template options to reduce irrelevant configuration values for Working Capital Loan charge products.
Filtering Levels
Filtering is applied in the following order:
-
chargeAppliesTo-
filters and returns only applicable `chargeTimeType`s
-
-
chargeTimeType-
filters and returns only applicable `chargeCalculationType`s
-
Each filtering level depends on the previous level being provided.
chargeAppliesTo Filter
A new query parameter is introduced:
GET /charges/template?chargeAppliesTo=<value>
When chargeAppliesTo is specified, the API returns only the template fields and dropdown options applicable to the selected entity type.
Supported values include:
* Working Capital Loan
chargeTimeType Filter
An additional filtering level is available when chargeAppliesTo is also provided:
GET /charges/template?chargeAppliesTo=<value>&chargeTimeType=<value>
Supported values for WCP Loans:
-
Specified due date
The API response filters the available chargeCalculationType options based on the selected charge time type.
Backward Compatibility
If no query parameters are provided, the template API continues to return all available options to preserve legacy behavior.
include::working-capital-payment-allocation.adoc
:leveloffset: +1
Working Capital Loan — Projected Amortization Schedule
Overview
Dynamically updated amortization schedule for Working Capital (WC) Loans — zero-interest, discount/fee-based products with flexible, sales-based repayments. Uses EIR methodology for discount fee income recognition, deferral, and NPV calculation.
Each payment period is exactly one day: period N date = expectedDisbursementDate + N days.
Goal
To design and implement a Working Capital Loan product with an Effective Interest Rate (EIR)-based calculation, enabling accurate discount fee income recognition, regulatory compliance, and flexible repayment structures.
The objective includes building a loan framework capable of:
-
Supporting discount/fees-based working capital financing
-
Accurately calculating and amortizing loans using EIR methodology, ensuring transparent cost of credit and compliance with accounting and regulatory standards
-
Supporting excess payments, fewer payments, and no-payment calculation scenarios without impacting financial accuracy
Configuration at Loan Product Level
Product Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
|
VARCHAR(100) |
Yes |
Product name (unique) |
|
VARCHAR(4) |
Yes |
Product short code (unique) |
|
VARCHAR(100) |
No |
External identifier. Must be unique when provided. |
|
VARCHAR(500) |
No |
Product description |
|
BIGINT |
No |
Fund reference for the product |
|
DATE |
No |
Product start date |
|
DATE |
No |
Product close date. If supplied with |
|
VARCHAR(3) |
Yes |
ISO currency code |
|
SMALLINT |
Yes |
Decimal places for currency. Allowed range: 0-6. |
|
INT |
Yes |
Currency multiple. Must be zero or greater. |
|
DECIMAL(19,6) |
Yes |
Default proposed principal amount. Must be positive. |
|
DECIMAL(19,6) |
Yes |
Percentage rate applied to Total Payment Value (for example, |
|
INT |
Yes |
Day-count convention for NPV calculation. Must be greater than zero. |
|
ENUM |
Yes |
Amortization type. Current supported value: |
|
INT |
Yes |
Repayment interval count |
|
ENUM |
Yes |
Repayment frequency type: |
|
DECIMAL(19,6) |
No |
Default discount fee amount. Must be zero or greater when provided. |
|
DECIMAL(19,6) |
No |
Principal range constraints. |
|
DECIMAL(19,6) |
No |
Period payment rate range constraints. Minimum must be less than or equal to maximum, and |
|
BIGINT |
No |
Delinquency bucket classification |
|
INT |
No |
Grace days before delinquency (default: 0) |
|
ENUM |
No |
Delinquency start type: |
|
BIGINT |
No |
Breach classification reference |
|
BIGINT |
No |
Near-breach classification reference. Can only be set when |
|
ARRAY |
Yes |
Payment allocation rules for the product. Required on create and validated per transaction type. |
|
OBJECT |
No |
Optional object that controls which product settings may be overridden at loan level. See Configurable Attributes below. |
|
ENUM |
Yes |
Product accounting rule: |
Configurable Attributes (Loan-Level Overrides)
These flags control whether the corresponding field can be overridden at the individual loan level:
| Attribute | Description |
|---|---|
|
Allow override of origination fee / discount at loan level |
|
Allow override of repayment interval |
|
Allow override of repayment frequency type |
|
Allow override of delinquency bucket |
|
Allow override of breach and near-breach classifications |
Payment Allocation Rules
Each product defines payment allocation rules per transaction type. Working Capital payment allocation supports only principal, fee, and penalty buckets, with due and in-advance variants:
-
DUE_PENALTY -
DUE_FEE -
DUE_PRINCIPAL -
IN_ADVANCE_PENALTY -
IN_ADVANCE_FEE -
IN_ADVANCE_PRINCIPAL
|
Working Capital loans have no nominal interest rate. Revenue is generated through discount fee amortization using EIR methodology, tracked through |
Origination Fee (Discount) Treatment
The origination fee — referred to as "discount" in the product and loan configuration — is the upfront fee that represents the cost of credit. It is the difference between what the borrower must repay (Total Payment Value) and the net amount disbursed.
Lifecycle of the Discount
| Stage | Behavior |
|---|---|
Create |
Discount amount entered (optional). Defaults to product-level value if not specified. |
Approve |
Optional |
Disburse |
Optional |
Post-Disbursement |
A discount fee can be added once through the discount endpoint, only on the disbursement business date and only when a discount fee transaction is not already related to the disbursement. Discount fee adjustments are supported against an active discount fee transaction up to the remaining discount amount. |
Deferred Income at Disbursement
At disbursement, the origination fee becomes the initial deferred balance (unrealized income). This amount is progressively recognized as income over the loan lifecycle through the EIR amortization model:
-
principal= disbursed amount + discount -
principalOutstanding=principal - principalPaid -
totalDiscountFee= discount amount -
unrealizedIncomeFromDiscountFee=totalDiscountFee - realizedIncomeFromDiscountFee -
realizedIncomeFromDiscountFee= 0 (nothing recognized yet)
As repayments are received, the cursor-based amortization mechanism determines how much discount fee income is eligible to move from unrealizedIncomeFromDiscountFee to realizedIncomeFromDiscountFee.
Lifecycle
| Stage | Behavior |
|---|---|
Approve |
Generates a projected schedule from approved loan parameters and expected disbursement date. |
Disburse |
Regenerates the schedule using the actual disbursement amount and actual disbursement date. |
Repayment |
|
Period Payment Rate Change |
Adds a rate segment from the business date and rebuilds the schedule using the new period payment rate for the remaining term. |
State Transitions
| Command | From State | To State | Key Validations |
|---|---|---|---|
|
Submitted and Pending Approval |
Approved |
Approval date not future, not before submitted date; expected disbursement date not before approval date; approved amount ⇐ proposed principal; discount ⇐ created discount/product default |
|
Submitted and Pending Approval |
Rejected |
Rejection date not future, not before submitted date |
|
Approved |
Submitted and Pending Approval |
Clears approval data and regenerates the schedule from the submitted loan values |
|
Approved |
Active |
Disbursement date not future, not before submitted/approval date, and valid for office calendar; amount > 0 and ⇐ approved principal; client must be active |
|
Active |
Approved |
Allowed only when there are no active monetary transactions other than disbursement, discount fee, discount fee amortization, and discount fee adjustment; reverses transactions, resets balance, and regenerates the schedule |
repayment transaction |
Active or Overpaid |
Active, Overpaid, or Closed (Obligations Met) |
Transaction date not future and not before disbursement date; amount > 0; payment is allocated and the schedule is rebuilt |
credit balance refund transaction |
Overpaid |
Overpaid or Closed (Obligations Met) |
Transaction date not future, not before disbursement date, and not backdated before business date; amount > 0 and ⇐ available overpayment |
goodwill credit transaction |
Active, Overpaid, or Closed (Obligations Met) |
Active, Overpaid, or Closed (Obligations Met) |
Transaction date not future and not before disbursement date; amount > 0; allocation and accounting follow repayment-like processing |
Input Parameters
The amortization schedule model is calculated from these loan-level parameters:
| Parameter | Type | Description |
|---|---|---|
|
Money |
Upfront discount fee. Amortized over lifecycle. Older stored model JSON may use |
|
Money |
Principal disbursed. Must be positive. |
|
Money |
Merchant’s TPV. |
|
BigDecimal |
Percentage rate applied to TPV (for example, |
|
Integer |
Day-count convention (e.g., 360). Must be positive. |
|
Date |
Loan start date. |
Formulas
expectedPaymentAmount = (TPV × periodPaymentRate / 100) / npvDayCount
originalPaymentNumber = roundUp((netDisbursement + discountFee) / expectedPayment)
EIR = RATE(originalPaymentNumber, -expectedPayment, netDisbursement)
paymentsLeft = max(0, segmentRelativePaymentNo - appliedPaymentCount)
discountFactor = 1 / (1 + EIR) ^ paymentsLeft
npvSource = actualPayment (if applied) or forecastPayment (if not)
npvValue = max(0, npvSource × discountFactor) // row 0: -netDisbursementAmount (unclamped)
Stored Values at Loan Account Level
After EIR calculation, the following values are persisted:
-
Period Payment Amount — constant daily expected payment
-
Total Days — loan term in days
-
Daily EIR — the effective interest rate per period
-
Calculated Annual EIR — annualized effective rate
Stored Values per Repayment
For each actual repayment transaction:
-
Payment Amount — actual cash received
-
Actual Amortization (Income) — fee income recognized for this payment
-
Income Modification — difference between actual and expected amortization
Schedule Fields
| Field | Formula / Description |
|---|---|
|
1-based. Row 0 = disbursement. |
|
|
|
Constant daily expected payment. Row 0: |
|
|
|
|
|
|
|
|
|
Actual cash paid. Null if no payment. |
|
Cursor-based: |
|
Applied positive-payment rows: |
|
|
Disbursement Row (paymentNo = 0)
|
|
|
1.0 |
|
|
|
|
|
|
all other nullable fields |
null |
Tail Periods
Appended when shortfall remains after the effective term. Each tail row internally forecasts min(remainingShortfall, expectedPayment). In the public schedule response, tail rows expose paymentNo, paymentDate, discountFactor, and npvValue; amount, balance, amortization, and deferred-balance fields are null. Trailing rows with zero forecast are trimmed.
EIR-Based Income Recognition
Cursor-Based Actual Amortization
When a repayment is received, the system calculates how much origination fee income to recognize using a cursor-based approach:
-
The cursor tracks how many "periods worth" of expected amortization have been consumed
-
For each payment:
periodsConsumed = actualPaymentAmount / expectedPaymentAmount -
The cursor advances by this amount, consuming the corresponding expected amortization amounts
-
Partial periods are interpolated
This mechanism means:
-
A payment equal to the expected amount recognizes exactly one period’s expected amortization
-
A payment larger than expected (excess) recognizes proportionally more income
-
A payment smaller than expected recognizes proportionally less income
-
No payment on a given day means no income is recognized for that day
Income Modification
The incomeModification field tracks the difference between what was actually amortized and what was expected:
-
Paid periods:
incomeModification = actualAmortization - expectedAmortization-
Positive value = more income recognized than expected (excess payment)
-
Negative value = less income recognized than expected (underpayment)
-
-
Unpaid periods: no actual amortization is calculated and
incomeModificationis null
Deferred Balance Tracking
The deferred balance represents the remaining unrecognized origination fee:
-
At disbursement:
deferredBalance = discountFeeAmount -
After each payment:
deferredBalance = discountFee - cumulativeActualAmortization -
At loan maturity (fully paid):
deferredBalance = 0
Calculation Algorithm
-
Balances & expected amortizations:
balance[i] = balance[i-1]×(1+EIR) - expectedPayment. Expected amort capped atdiscountFee. -
Aggregate payments by date (same-date payments summed). Repayments are normalized to a valid schedule date.
-
Shortfall/excess analysis: compare each applied payment to expected.
-
Cursor-based actual amortization: cursor advances by
actualPayment/expectedPaymentperiods; interpolates partial periods. -
Excess distribution: reduces forecast payments backward from last period.
-
Tail periods: appended for remaining shortfall.
-
Total net amortization:
-netDisbursement + Σ(npvSource[i] × DF[i]) + tailNpv. -
Assemble rows, trim trailing zero-forecast.
Rebuild Flow
Every schedule-changing operation triggers a full rebuild: aggregate payments → build payment list (1 to effective term, actual or null) → balances → payment analysis → cursor amortizations → excess distribution → tail → net amortization → assemble rows → trim.
Loan Balance
The m_wc_loan_balance table maintains a running balance snapshot for each loan. Outstanding amounts are derived from stored due and paid amounts:
| Field | Description |
|---|---|
|
Total principal expected for repayment. Initialized at disbursement as |
|
Cumulative principal repaid. |
|
Derived as |
|
Fee amount, cumulative fee paid, and derived outstanding fee amount. |
|
Penalty amount, cumulative penalty paid, and derived outstanding penalty amount. |
|
Discount fee income that has been recognized (amortized). Increases as repayments trigger cursor-based amortization and COB posts discount fee amortization. |
|
Derived as |
|
Amount paid beyond total outstanding principal, fee, and penalty amounts. Used for credit balance refunds. |
|
Stored total disbursement field. Current balance update flow initializes the balance from the disbursement transaction amount and discount. |
|
Total discount fee attached to the loan. |
|
Total discount fee adjustment amount posted against discount fee transactions. |
Balance Invariants
-
principalOutstanding >= 0 -
principalPaid >= 0 -
feeOutstanding >= 0 -
penaltyOutstanding >= 0 -
realizedIncomeFromDiscountFee >= 0 -
unrealizedIncomeFromDiscountFee >= 0 -
realizedIncomeFromDiscountFee + unrealizedIncomeFromDiscountFee = totalDiscountFee
COB (Close of Business) Processing
The Working Capital COB job processes WC loans daily during the Close of Business batch run.
Job Configuration
-
Job name:
WORKING_CAPITAL_LOAN_CLOSE_OF_BUSINESS -
Display name: "Working Capital Loan COB"
-
Coexists with the standard
LOAN_CLOSE_OF_BUSINESSjob without interference
Processing Behavior
-
Updates
lastClosedBusinessDatetobusinessDate - 1for each eligible loan -
Increments loan
versionfor each day processed -
Processes loans one day at a time when catching up over skipped dates
-
Releases all account locks after completion
Status Eligibility
| Status | Processed by COB? |
|---|---|
Submitted and Pending Approval |
Yes |
Approved |
Yes |
Active |
Yes |
Transfer in Progress |
Yes |
Transfer on Hold |
Yes |
Overpaid |
No |
Closed (Obligations Met) |
No |
Rejected |
No |
Business Steps
The following business steps are executed during COB for each eligible loan:
| Step Order | Step Name | Description |
|---|---|---|
2 |
|
Generates and evaluates delinquency range periods based on product configuration. |
3 |
|
Assigns or lifts delinquency range tags based on the configured bucket ranges. |
4 |
|
Generates and evaluates breach schedule periods. |
5 |
|
Evaluates near-breach conditions against the configured breach. |
6 |
|
Posts newly recognized discount fee amortization from the projected schedule. |
Inline COB
WC loans also support inline COB processing for individual loans, with the same behavior as batch COB. This is useful for immediate processing without waiting for the scheduled batch run.
COB Catch-Up
When the system has skipped business dates (e.g., after maintenance), the COB catch-up mechanism processes each missed day sequentially until lastClosedBusinessDate reaches currentBusinessDate - 1.
Special Handling
Excess Payments
When actual payment exceeds expected payment for a period:
-
The excess is distributed backward from the last period, reducing forecast payments
-
More origination fee income is recognized (proportional to payment ratio)
-
Loan may mature earlier than originally projected
Shortfall (Underpayment)
When actual payment is less than expected:
-
Less income is recognized for the period
-
Tail periods are appended to the schedule for the remaining shortfall
-
Each tail period forecasts
min(remainingShortfall, expectedPayment)
No Payment
When no payment is received on a given day:
-
No income is recognized (
actualAmortizationAmount = null) -
incomeModification = null -
Deferred balance remains unchanged
Repayment Rate Modification
The repayment percentage can be modified during the loan lifecycle:
-
Affects expected period payment recalculation
-
Triggers full amortization schedule rebuild
-
Does not retroactively change already-recognized income
Re-Age
Re-aging works only for the delinquency range schedule:
-
Does not impact the amortization schedule or income recognition
-
Period payment amount is unchanged
Payment Holidays
Payment holidays only affect the delinquency range schedules:
-
Amortization schedule and income recognition are not directly impacted
Charge-Off
Charge-off support is not fully implemented for Working Capital loan accounting. Discount fee amortization supports a charged-off posting path internally, but standard charge-off transaction flows are not documented here as generally available.
Write-Off
Write-off support is not documented as a generally available Working Capital loan command in the current API surface.
Credit Balance Refund (CBR)
Supported when the loan has a credit balance (overpayment beyond principal). The excess amount can be refunded to the borrower.
Accounting Entries
| Transaction Type | Debit | Credit |
|---|---|---|
Disbursement |
Loan Portfolio (Asset) |
Fund Source (Asset) |
Repayment |
Fund Source (Asset) |
Loan Portfolio (Asset) |
Discount Fee Amortization |
Unrealized Income / Deferred Income (Liability) |
Income from Discount Fee (Income) |
|
Working Capital loans do not generate interest-related journal entries. All revenue recognition flows through discount fee amortization via |
API Endpoints
POST /v1/working-capital-loan-products
Creates a new Working Capital loan product with EIR amortization configuration.
POST /v1/working-capital-loans
Creates a new Working Capital loan application. Required fields include: productId, clientId, principalAmount, totalPaymentVolume, periodPaymentRate, submittedOnDate, and expectedDisbursementDate.
POST /v1/working-capital-loans/{loanId}?command=approve
Approves a submitted loan application. Required fields include approvedOnDate and expectedDisbursementDate; optional overrides include approvedLoanAmount and discountAmount.
POST /v1/working-capital-loans/{loanId}?command=disburse
Disburses an approved loan. Required: actualDisbursementDate, transactionAmount. Triggers amortization schedule generation.
POST /v1/working-capital-loans/{loanId}/transactions?command=repayment
Creates a repayment transaction. Required: transactionDate, transactionAmount.
POST /v1/working-capital-loans/{loanId}/transactions?command=creditBalanceRefund
Creates a credit balance refund transaction for an overpaid loan. Required: transactionDate, transactionAmount.
POST /v1/working-capital-loans/{loanId}/transactions?command=goodwillCredit
Creates a goodwill credit transaction. Required: transactionDate, transactionAmount.
POST /v1/working-capital-loans/{loanId}/transactions?command=discountFee
Creates a discount fee transaction related to a disbursement transaction.
POST /v1/working-capital-loans/{loanId}/transactions?command=discountFeeAdjustment
Creates a discount fee adjustment transaction related to an active discount fee transaction.
PUT /v1/working-capital-loans/{loanId}/discount
Updates the discount (origination fee) amount. Only allowed once after disbursement and only on the disbursement date. Request body uses discountAmount field. Also available via external ID: PUT /v1/working-capital-loans/external-id/{externalId}/discount.
PUT /v1/working-capital-loans/{loanId}/payment-rate
Updates periodPaymentRate for an active loan and rebuilds the schedule from the current business date. Also available via external ID: PUT /v1/working-capital-loans/external-id/{externalId}/payment-rate.
GET /v1/working-capital-loans/{loanId}/rate-changes
Retrieves recorded period payment rate changes for a loan. Also available via external ID: GET /v1/working-capital-loans/external-id/{externalId}/rate-changes.
GET /v1/working-capital-loans/{loanId}/amortization-schedule
Retrieves the projected amortization schedule for a Working Capital loan.
Example Request:
GET /fineract-provider/api/v1/working-capital-loans/1/amortization-schedule
Example Response (first 3 payments shown):
{
"discountFeeAmount": 1000.00,
"netDisbursementAmount": 9000.00,
"totalPaymentVolume": 100000.00,
"periodPaymentRate": 18,
"npvDayCount": 360,
"expectedDisbursementDate": "2019-01-01",
"expectedPaymentAmount": 50.00,
"originalPaymentNumber": 200,
"effectiveInterestRate": 0.0010678144878363462,
"payments": [
{
"paymentNo": 0,
"paymentDate": "2019-01-01",
"expectedPaymentAmount": -9000.00,
"discountFactor": 1,
"npvValue": -9000.00,
"balance": 9000.00,
"expectedAmortizationAmount": null,
"actualPaymentAmount": null,
"actualAmortizationAmount": null,
"incomeModification": null,
"deferredBalance": 1000.00
},
{
"paymentNo": 1,
"paymentDate": "2019-01-02",
"expectedPaymentAmount": 50.00,
"discountFactor": 0.998933324523691,
"npvValue": 49.95,
"balance": 8959.61,
"expectedAmortizationAmount": 9.61,
"actualPaymentAmount": null,
"actualAmortizationAmount": null,
"incomeModification": null,
"deferredBalance": 1000.00
},
{
"paymentNo": 2,
"paymentDate": "2019-01-03",
"expectedPaymentAmount": 50.00,
"discountFactor": 0.9978677868439537,
"npvValue": 49.89,
"balance": 8919.18,
"expectedAmortizationAmount": 9.57,
"actualPaymentAmount": null,
"actualAmortizationAmount": null,
"incomeModification": null,
"deferredBalance": 1000.00
}
]
}
|
In the example above:
|
Business Events
Loan Lifecycle Events
| Event | Trigger |
|---|---|
|
Loan application approved |
|
Loan approval reversed |
|
Loan application rejected |
|
Loan funds disbursed; amortization schedule generated |
|
Disbursement reversed; amortization schedule regenerated |
|
Principal outstanding reaches zero |
|
Repayment-like transaction creates an overpayment |
|
Credit balance refund clears the overpayment on an overpaid loan |
Database
| Column | Type | Description |
|---|---|---|
|
BIGINT |
PK, auto-increment. |
|
INT |
Optimistic lock. |
|
BIGINT |
FK → |
|
LONGTEXT (MySQL/MariaDB) / TEXT (PostgreSQL) |
Serialized model (Gson). |
|
DATE |
Last generated/updated. |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
Audit. MySQL / PostgreSQL. |
|
VARCHAR(10) |
Current model version is |
Transaction Types
Working Capital loans support the following transaction types:
| Transaction Type | Description |
|---|---|
Disbursement |
Initial funding of the loan. Triggers amortization schedule generation and deferred income initialization. |
Repayment |
Merchant payment applied to the loan. Triggers cursor-based amortization and schedule rebuild. Payment allocation follows the product’s |
Discount Fee |
Sets the discount fee as a transaction related to the disbursement. |
Discount Fee Adjustment |
Reduces an active discount fee transaction and regenerates the schedule from the adjusted discount amount. |
Charge / Charge Adjustment |
Fee charges (e.g., NSF fees) added to the loan. Charge adjustments reverse or reduce charges. |
Goodwill Credit |
Credit applied to the loan as a gesture of goodwill. |
Charge-Off |
Writes off the loan as non-performing. Remaining unrealized income is charged off. |
Write-Off |
Writes off outstanding principal and unrealized income. |
Credit Balance Refund |
Refunds excess payment (credit balance) back to the borrower. |
Reversed Transactions |
Undo disbursal reverses disbursement-related transactions by marking them reversed. Triggers schedule rebuild and recalculate balances. |
Constraints and Limitations
|
Current limitations of the Working Capital amortization model:
|
Appendix: TVM Functions
RATE: Newton-Raphson solving pv×(1+r)^n + pmt×((1+r)^n−1)/r = 0. Initial guess: 2×(pmt×n+pv)/(pv×n), fallback 0.01. Tolerance 1E-10, max 100 iterations.
Discount Factor: 1/(1+eir)^days. Returns 1.0 when days=0. Throws IllegalArgumentException for negative days. The model wraps this via safeDiscountFactor() which additionally returns 1.0 when the computed result is ≤ 0.
Working Capital Loan — Discount Fee Transactions
Overview
Discount Fee transactions record the applied Working Capital Loan discount as a monetary transaction. The transaction is linked to the related disbursement and updates the loan balance and projected amortization schedule.
Discount product configuration and staged loan-level override rules are documented separately in working-capital-discount.adoc.
Purpose
The transaction layer ensures that the applied discount is represented as a loan transaction, linked to the disbursement that created it, and available for balance, amortization, accounting, and event processing.
Scope
The scope of this document includes:
-
DISCOUNT_FEEtransaction creation at disbursement and post-disbursement -
DISCOUNT_FEE_ADJUSTMENTtransaction behavior -
Transaction relations and allocations
-
Balance, schedule, accounting, reversal, and business event behavior
-
API endpoints and validation rules
Discount Fee Transaction
Applicability
-
Working Capital Loan only
-
Loan must be ACTIVE for post-disbursement
discountFee -
The related disbursement must not already have an active Discount Fee transaction
-
Post-disbursement
discountFeecan only be run when the business date equals the actual disbursement date
Transaction Creation
A DISCOUNT_FEE transaction can be created in two ways:
-
Automatically during disbursement when the resolved discount amount is greater than zero.
-
Explicitly after disbursement using
POST /transactions?command=discountFee, if no Discount Fee was created for the related disbursement.
Each Discount Fee transaction is linked to its disbursement transaction through m_wc_loan_transaction_relation with relation type RELATED.
Allocation
An allocation row is stored in m_wc_loan_transaction_allocation. The Discount Fee amount is allocated to the principal portion.
Balance and Schedule Impact
On Discount Fee creation:
-
loanProductRelatedDetails.discountis set to the transaction amount. -
totalDiscountFeeand principal balance are increased by the discount amount. -
Overpayment is reset to zero.
-
The projected amortization schedule is regenerated using the updated discount and disbursement data.
Business Event
WorkingCapitalLoanDiscountFeeTransactionBusinessEvent is emitted after a successful DISCOUNT_FEE transaction is created. The event is emitted both for automatic disbursement-time Discount Fee creation and explicit post-disbursement discountFee.
Discount Fee Adjustment
A DISCOUNT_FEE_ADJUSTMENT transaction reduces the loan-level discount that was established by an existing, non-reversed DISCOUNT_FEE transaction.
Applicability
-
Loan must be ACTIVE.
-
relatedResourceIdmust reference an active, non-reversedDISCOUNT_FEEtransaction. -
Multiple adjustments are allowed against the same Discount Fee transaction until the remaining adjustable amount reaches zero.
Transaction Behavior
On Discount Fee Adjustment:
-
A separate
DISCOUNT_FEE_ADJUSTMENTtransaction is created. -
The adjustment is linked to the original Discount Fee transaction through a
RELATEDtransaction relation. -
loanProductRelatedDetails.discountis reduced by the adjustment amount, with a floor of zero. -
totalDiscountFeeAdjustmentis increased and principal balance is reduced by the adjustment amount. -
The projected amortization schedule is regenerated and prior actual repayments are re-applied.
Validation Rules
| Rule | Detail |
|---|---|
Amount |
Mandatory; must be greater than zero |
Maximum amount |
Must not exceed remaining discount, calculated as original Discount Fee amount minus prior non-reversed adjustments |
Transaction date |
Optional; defaults to the parent Discount Fee transaction date if omitted |
Date vs Discount Fee |
Must not be before the parent Discount Fee transaction date |
Backdating |
Not allowed; transaction date must be on or after the current business date |
Loan status |
Adjustment is allowed only for ACTIVE loans |
Parent transaction |
|
Allocation
An allocation row is stored in m_wc_loan_transaction_allocation. The adjustment amount is allocated entirely to the principal portion.
Business Event
WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent is emitted after a successful DISCOUNT_FEE_ADJUSTMENT transaction.
Accounting
The Discount Fee transaction updates the Working Capital Loan balance immediately. Income recognition is handled through Discount Fee Amortization transactions. For cash-based accounting products, Discount Fee Amortization debits Deferred Income Liability and credits Income from Discount Fee.
Reversal Behavior
Discount Fee transactions are reversed through disbursement reversal. Disbursement reversal permits the related Discount Fee, Discount Fee Amortization, and Discount Fee Adjustment transactions to be handled with the disbursement as long as no disallowed active transaction exists after the disbursement.
When disbursement is reversed:
-
The associated Discount Fee transaction is reversed.
-
Related Discount Fee Amortization and Adjustment transactions are handled with the reversal flow.
-
Loan balance and amortization schedule are reset consistently with the disbursement undo.
API Design
Create Discount Fee
POST /v1/working-capital-loans/{loanId}/transactions?command=discountFee
POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command=discountFee
{
"transactionAmount": 5000.00,
"relatedResourceId": 123,
"classificationId": 1,
"externalId": "WC-DISC-001",
"note": "Discount applied",
"paymentDetails": {
"accountNumber": "ACC-001",
"routingCode": "RTG-001",
"receiptNumber": "RCP-001",
"bankNumber": "BNK-001",
"checkNumber": "CHQ-001"
},
"locale": "en",
"dateFormat": "yyyy-MM-dd"
}
-
transactionAmountis optional. If omitted, the service defaults to the loan’s current discount. -
relatedResourceIdis mandatory and must be the related disbursement transaction ID. -
classificationIdis optional and must referenceworking_capital_loan_discount_fee_classificationwhen provided.
Create Discount Fee Adjustment
POST /v1/working-capital-loans/{loanId}/transactions?command=discountFeeAdjustment
POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command=discountFeeAdjustment
{
"transactionAmount": 50.00,
"relatedResourceId": 99,
"transactionDate": "10 January 2026",
"classificationId": 1,
"dateFormat": "dd MMMM yyyy",
"locale": "en",
"note": "Reduce discount fee",
"externalId": "WC-DISC-ADJ-001"
}
-
relatedResourceIdis mandatory and must be the Discount Fee transaction ID. -
transactionAmountis mandatory and capped by the remaining adjustable discount. -
classificationIdis optional and must referenceworking_capital_loan_discount_fee_classificationwhen provided.
Summary
Working Capital Loan Discount Fee Transactions document the monetary transaction behavior for applied discounts. Discount configuration determines the amount; this transaction layer records it, links it to disbursement, updates balances and schedules, supports reductions through adjustments, and emits the relevant transaction business events.
Working Capital Loan Charges
This documentation focuses on Working Capital Loan-related charge changes.
Create/Update Charge
Creates or updates a charge product applicable to Working Capital Loans.
Supported Fields
Mandatory Fields
-
chargeAppliesTo-
Must be set to
5, as theWorkingCapitalLoanenum identifier is5.
-
-
chargeTimeType-
Must be
2(SPECIFIED_DUE_DATE).
-
-
chargeCalculationType-
Must be
1(FLAT).
-
-
name-
Must be unique across all charge products.
-
-
amount -
active-
Boolean value.
-
-
currencyCode-
Refer to the template API for supported currency codes.
-
-
locale-
Refer to the available platform locales.
-
-
penalty-
Boolean value.
-
Optional Fields
-
chargePaymentMode-
Defaults to
regularwhen not provided.
-
Supported Values
chargeAppliesTo
-
WorkingCapitalLoan(5)
|
|
chargeTimeType
-
specified due date(2)
chargeCalculationType
-
flat(1)
Working Capital Loan — Credit Balance Refund (CBR)
Overview
Credit Balance Refund (CBR) is supported for Working Capital loans when a loan has an overpayment balance.
Core Behavior
For Working Capital loans:
-
Overpayment is exposed as
overpaymentAmounton the working capital loan balance -
principalOutstandingis not reduced below zero; excess repayment is tracked as overpayment -
CBR reduces
overpaymentAmountby the refund amount
API
POST /v1/working-capital-loans/{loanId}/transactions?command=creditBalanceRefund
POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command=creditBalanceRefund
Validation
CBR is allowed only when:
-
Loan status is
OVERPAID -
overpaymentAmount > 0 -
Refund amount is positive and does not exceed current
overpaymentAmount -
Transaction date is not in the future, is not before disbursement, and is not before the current business date
Status Handling
After CBR:
-
If
overpaymentAmount > 0, the loan remainsOVERPAID -
If
overpaymentAmount = 0andprincipalOutstanding = 0, status transitions toCLOSED_OBLIGATIONS_MET
Working Capital Loan — Goodwill Credit Transaction
Purpose
The Goodwill Credit transaction represents a credit adjustment provided to the borrower, typically as a gesture of goodwill to address customer grievances, service issues, or operational corrections.
It ensures:
-
Controlled handling of customer compensation
-
Proper adjustment of outstanding dues
-
Accurate accounting of goodwill expenses
Functionality
-
Goodwill Credit is a monetary transaction applied to a loan account.
-
Functionally, it follows the repayment-like transaction flow, but it is recorded as
GOODWILL_CREDIT. -
It is performed when:
-
A lender decides to credit an amount to the borrower without expecting repayment.
-
-
It results in:
-
Reduction in outstanding loan balance
-
Triggering of accounting entries for cash-based accounting products
-
Goodwill Credit Handling
-
It results in:
-
Reduction of outstanding balance
-
Calculation of the actual Amortization amount
-
Posting a Discount Fee Amortization transaction when the loan is closed or overpaid and discount fee income still needs to be recognized
-
Adjustment of the Amortization schedule
-
Allocation of the applied amount to principal outstanding, with any excess tracked as overpayment
-
Triggering of accounting entries for cash-based accounting products
-
Validation Rules
Goodwill credit can be applied only if:
* Loan status is Active, Overpaid, or Closed Obligations Met
Amount validation:
* Must be greater than zero
Allocation Logic
Goodwill Credit is currently applied against principal outstanding. The service records a principal allocation for the amount applied to outstanding principal, and any amount above the outstanding balance becomes overpayment.
Schedule Impact
-
On Goodwill Credit:
-
System will:
-
Adjust outstanding balances
-
Update amortization schedule
-
Recalculate future periods
-
-
-
Transaction date must not be in the future and must not be before the first disbursement date.
API
POST /v1/working-capital-loans/{loanId}/transactions?command=goodwillCredit
POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command=goodwillCredit
{
"transactionDate": "10 January 2026",
"transactionAmount": 100.00,
"classificationId": 1,
"externalId": "WC-GW-001",
"dateFormat": "dd MMMM yyyy",
"locale": "en",
"note": "Customer goodwill credit"
}
Accounting Treatment
Goodwill Credit triggers Journal Entries (JE) when the Working Capital Loan product uses cash-based accounting.
Goodwill Credit
Dr |
Expense from Goodwill Credit |
Expense |
Principal + Excess |
Dr |
Income from Goodwill Credit Fees |
Income |
Fees |
Dr |
Income from Goodwill Credit Penalty |
Income |
Penalty |
Cr |
Loan Portfolio |
Asset |
Principal |
Cr |
Fees Receivable |
Asset |
Fees |
Cr |
Penalty Receivable |
Asset |
Penalty |
Cr |
Overpayment Liability |
Liability |
Excess |
Charge-off accounting for Working Capital Loan Goodwill Credit is not implemented. If the accounting processor is asked to post Goodwill Credit entries with the charged-off flag, it raises a NotImplementedException.
Special Scenarios
-
If the Goodwill Credit produces an overpayment, the excess can later be returned through a Credit Balance Refund transaction.
Working Capital Product Delinquency Management
Overview
Delinquency management for Working Capital Loans provides a dedicated, schedule-based framework to track minimum payment compliance, classify loans into configurable delinquency ranges, and apply corrective actions such as pauses and reschedules. Unlike the standard loan delinquency model — which is installment-driven — the Working Capital model operates on rolling time periods, each with its own expected minimum payment that must be met before the period’s end date is reached.
The feature is implemented in the fineract-working-capital-loan module and is activated through the Working Capital COB (Close of Business) pipeline.
Purpose
This feature enables credit operations teams to:
-
Automatically detect and classify overdue minimum payment obligations per time period.
-
Assign delinquency range tags (e.g., 5–15 days, 15–30 days) per period, independently of other periods.
-
Temporarily pause delinquency evaluation during agreed grace windows.
-
Reschedule the minimum payment amount and/or frequency on active loans.
Scope
The scope of this document includes:
-
Product-level delinquency configuration (grace days, stored start type)
-
Delinquency bucket and minimum payment rule configuration (
m_wc_delinquency_configuration) -
Delinquency range schedule — generation and lifecycle (
m_wc_loan_delinquency_range_schedule) -
Per-period delinquency range tagging (
m_wc_loan_range_delinquency_tag) -
Delinquency actions: PAUSE and RESCHEDULE (
m_wc_loan_delinquency_action) -
COB business steps that drive the delinquency pipeline
-
API endpoints for actions and schedule retrieval
Applicability
-
Active Working Capital Loan accounts only.
-
Loans whose product has a delinquency bucket configured.
-
Loans that have at least one actual disbursement recorded.
Definitions and Key Concepts
Delinquency Range Schedule Period: A time window (identified by a sequential periodNumber) over which the system tracks whether the loan met its minimum payment obligation. Each period has fromDate, toDate, expectedAmount, paidAmount, and outstandingAmount.
Minimum Payment Criteria Met (minPaymentCriteriaMet): A boolean flag set when a period is evaluated after its toDate. It is true if paidAmount >= expectedAmount, false otherwise, and null while the period is open (not yet expired).
Delinquency Range Tag: A tag derived from the bucket’s configured ranges (e.g., 5–15 days overdue) applied per period. The system records a history of tag additions and lifts in m_wc_loan_range_delinquency_tag.
Delinquency Start Type: Stored on the product and copied to the loan. The current range schedule generation anchors the first period to the first actual disbursement date.
Delinquency Grace Days: A product-level integer that is copied to the loan at origination. The COB classification step adds this number of days when determining whether a period is overdue relative to the business date.
PAUSE Action: A loan-level action that extends all open and future schedule periods by the duration of the pause, effectively freezing the delinquency clock during the pause window.
RESCHEDULE Action: A loan-level action that modifies the minimum payment amount and/or frequency for the current open period and all future unevaluated periods.
Design Decisions and Considerations
Period-Level Delinquency, Not Loan-Level
Working Capital loans use a period-based delinquency model rather than the standard installment-based model. Each period is an independently evaluated unit. A loan can have some periods marked delinquent while other periods remain current, which reflects the revolving, cash-flow-oriented nature of working capital credit.
PAUSE Extends Periods, Not Skips Them
When a PAUSE action is recorded, all open and future schedule periods are extended by the exact number of days of the pause (ChronoUnit.DAYS.between(pauseStart, pauseEnd)). This preserves the expected minimum payment schedule while giving the borrower extra time. The pause is applied immediately when the action is created via API — it does not wait for the next COB run.
RESCHEDULE Applies Forward-Only
A RESCHEDULE action modifies only the current open period and future unevaluated periods. Periods already evaluated (minPaymentCriteriaMet != null) are never modified. This ensures historical accuracy of delinquency reporting.
Classification Uses Business Date + 1
The WorkingCapitalLoanDelinquencyClassificationBusinessStep classifies delinquency using businessDate + 1 to evaluate periods whose toDate is strictly before the adjusted date, aligning with the convention that a period ending on day D is evaluated on day D+1.
Database Design
Overview
The delinquency subsystem for Working Capital loans introduces three dedicated tables, and extends the product and loan entities with configuration columns. It reuses the standard m_delinquency_bucket and m_delinquency_range tables from the core loan module.
Existing Tables (Referenced)
m_delinquency_bucket: The delinquency bucket assigned to the Working Capital loan product. Defines which ranges are applicable.
m_delinquency_range: The individual delinquency ranges inside a bucket (e.g., 5–15 days, 15–30 days). Used for tagging periods.
Changes to Existing Tables
m_wc_loan_product and m_wc_loan
New columns added by migrations 0013_add_delinquency_grace_days_to_wc_loan.xml:
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
INT |
nullable |
Number of days after a period’s |
|
VARCHAR(20) |
nullable |
Enum: |
Table: m_wc_delinquency_configuration
The m_wc_delinquency_configuration table stores the minimum payment rule associated with a delinquency bucket. There is exactly one configuration per bucket (unique constraint on bucket_id).
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
PK, not null |
Primary key |
|
BIGINT |
FK to |
The delinquency bucket this configuration applies to |
|
INT |
not null |
Numeric frequency value for the period duration |
|
VARCHAR(50) |
not null |
Enum: |
|
DECIMAL(19,6) |
not null |
Minimum payment amount or percentage |
|
VARCHAR(50) |
not null |
Enum: |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
Table: m_wc_loan_delinquency_range_schedule
The m_wc_loan_delinquency_range_schedule table stores one row per rolling period per loan. Periods are generated dynamically during COB processing.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
PK, not null |
Primary key |
|
BIGINT |
FK to |
Associated Working Capital loan |
|
INT |
not null |
Sequential period number (1-based); unique with |
|
INT |
not null, default 0 |
Optimistic locking version |
|
DATE |
not null |
Start date of the period (inclusive) |
|
DATE |
not null |
End date of the period (inclusive). Extended by pause actions. |
|
DECIMAL(19,6) |
nullable |
Minimum payment required for this period |
|
DECIMAL(19,6) |
nullable |
Total amount paid toward this period |
|
DECIMAL(19,6) |
nullable |
Remaining unpaid amount ( |
|
BOOLEAN |
nullable |
|
|
BIGINT |
nullable |
Number of days past due for this period, as calculated at last COB run |
|
DECIMAL(19,6) |
nullable |
Outstanding amount classified as delinquent for this period |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
Table: m_wc_loan_range_delinquency_tag
The m_wc_loan_range_delinquency_tag table records the history of delinquency range assignments per period. A new row is inserted when a period enters a range, and liftedon_date is set when the period exits the range.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
PK, not null |
Primary key |
|
BIGINT |
FK to |
Associated Working Capital loan |
|
BIGINT |
FK to |
The range schedule period this tag belongs to |
|
BIGINT |
FK to |
The delinquency range classification (e.g., 5–15 days) |
|
DATE |
not null |
Business date when this tag was applied |
|
DATE |
nullable |
Business date when this tag was lifted (null = still active) |
|
DECIMAL(19,6) |
nullable |
Outstanding amount at time of tagging |
|
BIGINT |
nullable |
Optimistic locking version |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
Table: m_wc_loan_delinquency_action
The m_wc_loan_delinquency_action table stores loan-level delinquency control actions: PAUSE and RESCHEDULE.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
PK, not null |
Primary key |
|
BIGINT |
FK to |
Associated Working Capital loan |
|
VARCHAR(128) |
not null |
Enum: |
|
DATE |
not null |
Effective start date of the action |
|
DATE |
nullable |
Required only for PAUSE; end date of the pause window |
|
DECIMAL(19,6) |
nullable |
New minimum payment value (RESCHEDULE only) |
|
VARCHAR(50) |
nullable |
Enum: |
|
INT |
nullable |
New period frequency value (RESCHEDULE only) |
|
VARCHAR(50) |
nullable |
Enum: |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
Configuration
Product-Level Delinquency Configuration
Working Capital loan products support two delinquency configuration fields:
| Field | Type | Description |
|---|---|---|
|
Integer |
Number of days after a period’s end before delinquency is triggered. Default: 0. |
|
Enum |
Determines the anchor date for the first period: |
The product must also reference a Delinquency Bucket (m_delinquency_bucket). Without a bucket, COB steps skip delinquency processing for the loan.
Delinquency Bucket Minimum Payment Rule
Each delinquency bucket used with Working Capital loans must have an entry in m_wc_delinquency_configuration specifying the default minimum payment rule:
{
"frequency": 30, // period length
"frequencyType": "DAYS", // DAYS | WEEKS | MONTHS | YEARS
"minimumPayment": 5.0, // percentage or flat amount
"minimumPaymentType": "PERCENTAGE" // PERCENTAGE | FLAT
}
|
When |
System Configuration
The global configuration flag enable-instant-delinquency-calculation (table c_configuration) controls whether delinquency classification runs immediately after repayment-like monetary transactions in addition to the scheduled COB run. When enabled, the payment is first applied to the range schedule and then classification is recalculated for the transaction date when the loan has a delinquency bucket.
COB Pipeline
Delinquency processing for Working Capital loans is part of the WORKING_CAPITAL_LOAN_CLOSE_OF_BUSINESS batch job, executed in two sequential business steps:
| Step Order | Step Name | Description |
|---|---|---|
2 |
|
Generates the initial period on first run, advances to the next period when needed, and evaluates all expired periods (sets |
3 |
|
Iterates over all periods whose |
Step 2 (DelinquencyRangeScheduleBusinessStep) is skipped for loans that have not yet been disbursed. If no delinquency bucket or minimum payment rule exists, no schedule period is generated. Step 3 (WorkingCapitalLoanDelinquencyClassificationBusinessStep) is skipped when no delinquency bucket is configured on the product.
API Design
Delinquency Actions
Create Delinquency Action
Creates a delinquency action — either a PAUSE or a RESCHEDULE — for an active Working Capital loan.
POST /v1/working-capital-loans/{loanId}/delinquency-actions
POST /v1/working-capital-loans/external-id/{loanExternalId}/delinquency-actions
Required permission: CREATE_WC_DELINQUENCY_ACTION
Request Body — PAUSE:
{
"action": "pause",
"startDate": "2024-03-01", // mandatory — must be after first disbursement date
"endDate": "2024-03-15", // mandatory — must be after startDate
"dateFormat": "yyyy-MM-dd",
"locale": "en"
}
Request Body — RESCHEDULE:
{
"action": "reschedule",
"minimumPayment": 3.5, // optional — new minimum payment value (> 0)
"minimumPaymentType": "PERCENTAGE", // mandatory if minimumPayment is provided: PERCENTAGE | FLAT
"frequency": 14, // optional — new period frequency (> 0)
"frequencyType": "DAYS", // mandatory if frequency is provided: DAYS | WEEKS | MONTHS | YEARS
"locale": "en"
}
|
For RESCHEDULE, at least one of the payment group ( |
Response:
{
"officeId": 1,
"clientId": 42,
"loanId": 100,
"resourceId": 15
}
The resourceId contains the ID of the created m_wc_loan_delinquency_action record.
Retrieve Delinquency Actions
Retrieves all delinquency actions recorded for a Working Capital loan, ordered by creation.
GET /v1/working-capital-loans/{loanId}/delinquency-actions
GET /v1/working-capital-loans/external-id/{loanExternalId}/delinquency-actions
Required permission: READ_WC_DELINQUENCY_ACTION
Response:
[
{
"id": 10,
"action": "PAUSE",
"startDate": "2024-03-01",
"endDate": "2024-03-15",
"minimumPayment": null,
"minimumPaymentType": null,
"frequency": null,
"frequencyType": null
},
{
"id": 11,
"action": "RESCHEDULE",
"startDate": "2024-04-01",
"endDate": null,
"minimumPayment": 3.5,
"minimumPaymentType": "PERCENTAGE",
"frequency": 14,
"frequencyType": "DAYS"
}
]
Delinquency Range Schedule
Retrieve Delinquency Range Schedule
Retrieves all range schedule periods for a Working Capital loan, ordered by periodNumber.
GET /v1/working-capital-loans/{loanId}/delinquency-range-schedule
This endpoint is currently available by loan ID.
Required permission: READ_WORKINGCAPITALLOAN
Response:
[
{
"id": 1,
"loanId": 100,
"periodNumber": 1,
"fromDate": "2024-01-15",
"toDate": "2024-02-14",
"expectedAmount": 500.00,
"paidAmount": 500.00,
"outstandingAmount": 0.00,
"minPaymentCriteriaMet": true,
"delinquentDays": 0,
"delinquentAmount": 0.00
},
{
"id": 2,
"loanId": 100,
"periodNumber": 2,
"fromDate": "2024-02-15",
"toDate": "2024-03-15",
"expectedAmount": 500.00,
"paidAmount": 0.00,
"outstandingAmount": 500.00,
"minPaymentCriteriaMet": false,
"delinquentDays": 10,
"delinquentAmount": 500.00
}
]
Validation Rules
General Rules
-
Delinquency actions can only be created for active Working Capital loan accounts.
-
actionis mandatory; supported values arepauseandreschedule(case-insensitive).
Validation Rules for PAUSE
-
Both
startDateandendDateare mandatory. -
startDatemust be strictly beforeendDate(pause must span at least one day). -
startDatemust be on or after the first actual disbursement date of the loan. -
startDatemust not fall within or before a period that has already been evaluated (minPaymentCriteriaMet != null). -
The pause period must not overlap with any existing PAUSE action for the same loan.
Validation Rules for RESCHEDULE
-
The loan must have at least one actual disbursement recorded.
-
An existing delinquency range schedule must exist for the loan.
-
At least one of the payment group or the frequency group must be provided.
-
If
minimumPaymentis provided, it must be greater than 0 andminimumPaymentTypeis mandatory. -
If
frequencyis provided, it must be greater than 0 andfrequencyTypeis mandatory.
Business Rules
Period Generation
-
The initial period is generated by
DelinquencyRangeScheduleBusinessStepon the first COB run after disbursement, using the bucket’s minimum payment rule (m_wc_delinquency_configuration) to calculateexpectedAmountandtoDate. -
Subsequent periods are generated automatically when the previous period’s
toDateis no longer in the future. Periods are generated with a while-loop until the latest period’stoDateis ahead of the business date. -
If a RESCHEDULE action has been recorded, the effective frequency and minimum payment for new periods are taken from the most recent RESCHEDULE action, overriding the bucket’s configuration.
Period Evaluation and Expiration
-
At each COB run, all periods whose
toDate ⇐ businessDateandminPaymentCriteriaMet IS NULLare evaluated. -
Evaluation checks:
paidAmount >= expectedAmount. If true,minPaymentCriteriaMet = true; otherwiseminPaymentCriteriaMet = false.
Repayment Allocation
-
Repayment amounts are allocated first to the oldest open past-due periods, then to the current period.
-
For each eligible past-due period, the allocation is
min(repaymentAmount, outstandingAmount). -
When a period’s
outstandingAmountreaches zero,minPaymentCriteriaMetis immediately set totrueanddelinquentAmount/delinquentDaysare cleared.
Delinquency Classification
-
The classification step iterates over periods where
toDate < businessDate + 1. -
Delinquent days for a period are calculated as the classification date minus
period.toDate(only ifoutstandingAmount > 0). In COB this classification date isbusinessDate + 1. -
The applicable delinquency range is resolved from the bucket by finding the range where
minimumAgeDays ⇐ delinquentDays ⇐ maximumAgeDays. -
If no range matches (e.g., the period is not overdue), any previously applied tags for that period are lifted.
Effect of PAUSE on Schedule
-
When a PAUSE action is created,
extendPeriodsForPauseextends all open and future periods. -
For periods that have not yet started when the pause begins, both
fromDateandtoDateare shifted forward by the pause duration. -
For the period currently active when the pause begins (if the pause starts mid-period), only
toDateis extended. -
Periods already evaluated are never modified.
Effect of RESCHEDULE on Schedule
-
rescheduleMinimumPaymentmodifies the current open period (not yet evaluated) and all future unevaluated periods. -
The current period’s
expectedAmountis updated;outstandingAmountis recalculated asmax(0, expectedAmount - paidAmount). -
Future periods are recalculated from the day after the current period ends, applying the new frequency to determine new
fromDate/toDateand the newexpectedAmount. -
The most recent RESCHEDULE action (by ID) is always the effective override for future period calculations.
Independence from EIR Amortization
Delinquency management and EIR amortization are fully independent systems at the code level. WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl has no dependency on WorkingCapitalLoanAmortizationScheduleWriteService and makes no call to applyRateChange().
-
A RESCHEDULE action updates only the delinquency period schedule; the amortization model retains its original EIR and payment amounts unchanged.
-
EIR recalculation is triggered exclusively via
PUT /payment-rate, which is a separate, manually initiated operation. -
A loan can therefore have its delinquency terms renegotiated (via RESCHEDULE) without altering how discount fee income is recognized, and vice-versa.
Example Scenarios
Scenario #1: Normal Delinquency Lifecycle
Setup:
-
Loan disbursed on 2024-01-15, approved principal: 10,000
-
Bucket configuration: 30-day periods, 5% minimum payment = 500 per period
Period 1: 2024-01-15 → 2024-02-14, expected: 500
-
On 2024-02-01: borrower pays 500 →
paidAmount = 500,outstandingAmount = 0,minPaymentCriteriaMet = trueon period close.
Period 2: 2024-02-15 → 2024-03-16, expected: 500
-
On 2024-03-17 (COB): period expires,
paidAmount = 0,minPaymentCriteriaMet = false. -
Classification step:
delinquentDays = 1, tag with range 1–5 days. -
On 2024-03-25 (COB):
delinquentDays = 9, tag moves to range 5–15 days.
Scenario #2: PAUSE Extends Periods
Setup: Same loan as Scenario #1. Period 2 is active (2024-02-15 → 2024-03-16).
PAUSE created: startDate = 2024-03-01, endDate = 2024-03-15 (14 days).
Effect:
-
Period 2
toDateextends from 2024-03-16 to 2024-03-30 (+ 14 days). -
Period 3 (if already generated) also shifts forward by 14 days.
The borrower now has until 2024-03-30 to meet the minimum payment before the period is evaluated.
Scenario #3: RESCHEDULE Action Without Affecting EIR
Setup:
-
Loan has daily payments; delinquency bucket configured with monthly periods and a PERCENTAGE minimum payment.
-
At business date 2024-03-01, Period 2 (Feb 2024) is delinquent:
paidAmount = 3,000, minimum was8,750. -
The officer applies a RESCHEDULE reducing
minimumPaymentto FLAT 150.00 with monthly frequency.
Action:
WorkingCapitalLoanDelinquencyActionWriteServiceImpl records the action in m_wc_loan_delinquency_action and calls rangeScheduleService.rescheduleMinimumPayment(). The current open period’s expectedAmount is updated to 150.00, and all future periods are regenerated with the new frequency and expected amount. No call is made to the amortization model.
Expected Behavior:
-
m_wc_loan_delinquency_range_schedulerows from the current open period onward haveexpectedAmount = 150.00. -
Future COB runs evaluate compliance against the new 150.00 threshold.
-
m_wc_loan_amortization_modelis unchanged — EIR, discount factors, and expected payment amounts remain as originally computed. -
To also adjust the amortization model (e.g., because the lender renegotiates the contractual rate), a separate
PUT /payment-ratecall must be made explicitly.
Summary
Working Capital Product Delinquency Management provides a purpose-built, period-based framework for tracking minimum payment compliance on revolving Working Capital loans. Key aspects include:
-
A dedicated COB pipeline with two ordered steps: range schedule generation and delinquency classification.
-
Per-period tracking of expected vs. paid minimum payments, with independent delinquency tags per period.
-
A PAUSE action that extends all open and future periods proportionally, freezing the delinquency clock.
-
A RESCHEDULE action that modifies minimum payment terms for the current and all future periods.
-
A configurable minimum payment rule per delinquency bucket, expressed as either a flat amount or a percentage of principal.
-
Product-level controls for grace days and the stored delinquency start type.
Working Capital Loan EIR Calculation
Overview
Working Capital Loans with amortizationType = EIR derive an Effective Interest Rate (EIR) from the contractual payment structure using a Newton-Raphson solver and project a full amortization schedule with per-payment discount factors, NPV values, and deferred income tracking. The schedule is serialized to JSON and persisted in m_wc_loan_amortization_model for efficient reads. Mid-lifecycle rate changes add a RateSegment covering only the remaining term, preserving historical payment data while recalculating future payments under the new EIR.
Purpose
EIR amortization enables lenders to accurately recognize discount fee income over the Working Capital Loan term using time-value-of-money principles. Each scheduled payment carries a discount factor and NPV value that drive present-value-based income amortization through the deferredBalance mechanism.
Scope
The scope of this document includes:
-
EIR calculation algorithm and inputs
-
Newton-Raphson solver configuration
-
Payment and balance recurrence formulas
-
Discount factor and NPV computation per payment
-
Amortization model persistence and versioning
-
Mid-lifecycle rate changes and rate segments
-
Projected amortization schedule API
-
Rate change management API
Applicability
-
Working Capital Loans with
amortizationType = EIR -
FLATis present as an enum value, but current product validation accepts onlyEIR -
Rate changes apply to active (disbursed) loans only
Definitions and Key Concepts
Effective Interest Rate (EIR): The periodic interest rate that equates the present value of all projected payments to the net disbursement amount. Computed via Newton-Raphson, equivalent to Excel’s RATE(nper, pmt, pv).
Net Disbursement Amount: Principal disbursed after deducting any upfront discount fee: netDisbursement = totalLoanAmount − discountFeeAmount.
Total Payment Value (TPV): The sum of all projected payments over the full loan term, used as the basis for computing the expected daily payment.
Expected Payment: The constant per-period payment: expectedPayment = (TPV × periodPaymentRate / 100) / npvDayCount. periodPaymentRate is stored as a percentage value.
Original Payment Number: ceil((netDisbursementAmount + discountFeeAmount) / expectedPayment) — the total number of payment periods.
Discount Factor: 1 / (1 + EIR)^paymentsLeft — the time-value multiplier applied to a future payment.
NPV Value: The present value of a scheduled payment: npvValue = forecastPayment × discountFactor.
Amortization Amount: The portion of each payment that reduces the outstanding deferred income (deferredBalance).
Deferred Balance: Unrecognized discount fee income remaining at each period: starts at discountFeeAmount, decreases monotonically to zero.
Rate Segment: A contiguous block of periods sharing the same EIR and expected payment amount. Created when a rate change modifies payment terms mid-lifecycle.
Design Decisions and Considerations
Newton-Raphson Solver for EIR
The EIR is solved via TvmFunctions.rate(nper, pmt, pv, mc) which finds r satisfying:
pv × (1+r)^n + pmt × ((1+r)^n − 1) / r = 0
Key solver parameters:
-
Maximum iterations: 500
-
Convergence tolerance:
1E-12 -
Initial guess:
|2 × (pmt × n + pv) / (pv × n)|(linear approximation, absolute value taken)
The initial guess takes the absolute value of the linear approximation to avoid catastrophic divergence when nper is large (e.g., daily-payment loans with thousands of periods), where a fixed default of 0.01 would cause (1.01)^nper to overflow the MathContext.
Mid-Lifecycle Rate Segments
Rather than rebuilding the entire schedule on a rate change, the model appends a RateSegment covering only the remaining term from the change date forward. Each segment stores: startDayIndex, expectedPaymentAmount, segmentTerm, effectiveInterestRate, netDisbursementAtSplit, and discountAtSplit. Historical actuals before the split point are preserved; only future payments are recalculated.
When a new rate change is applied, any existing segment at or after the split point is removed first, making rate changes idempotent on the same date.
Amortization Model Persistence
The full ProjectedAmortizationScheduleModel is serialized to JSON (version "4" as of this writing) and stored in m_wc_loan_amortization_model (one row per loan, uniquely constrained on loan_id). This avoids recomputing the schedule from scratch on every read while still supporting incremental updates via rate segments. Optimistic locking via the version column prevents concurrent overwrites.
Payment Date Normalization
Actual payments are applied by transaction date. A payment dated on or before the disbursement date maps to the first installment date, a payment after the final installment maps to the last installment date, and payments within the schedule range use their actual transaction date. Same-date payments are aggregated before the schedule is rebuilt.
Database Design
Overview
The amortization model is persisted as a JSON snapshot in m_wc_loan_amortization_model. Rate change history is tracked in m_wc_loan_period_payment_rate_change.
Existing Tables
m_wc_loan: The main Working Capital Loan instance table. Referenced by both amortization and rate change tables via foreign key.
m_wc_loan_product: Stores amortization_type, npv_day_count, period_payment_rate, and discount columns used as inputs to the EIR calculation.
Table: m_wc_loan_amortization_model
The m_wc_loan_amortization_model table persists the serialized amortization model for each loan. One row per loan; updated in place when rate segments are added or payments are applied.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
id |
BIGINT |
PK, not null |
Primary key |
loan_id |
BIGINT |
FK to m_wc_loan, unique, not null |
Associated loan (one model per loan) |
version |
INT |
not null |
Optimistic locking version |
json_model |
CLOB |
not null |
Serialized |
business_date |
DATE |
not null |
Business date when the model was last updated |
json_model_version |
VARCHAR(10) |
not null |
Schema version of the JSON model format |
last_modified_on_utc |
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
Table: m_wc_loan_period_payment_rate_change
The m_wc_loan_period_payment_rate_change table records an audit trail of all rate changes applied to a loan. Only one active (non-reversed) entry is expected per loan at any time.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
id |
BIGINT |
PK, not null |
Primary key |
wc_loan_id |
BIGINT |
FK to m_wc_loan, not null |
Associated loan |
effective_date |
DATE |
not null |
Business date the new rate takes effect |
previous_rate |
DECIMAL(19,6) |
not null |
Period payment rate before the change |
new_rate |
DECIMAL(19,6) |
not null |
Period payment rate after the change |
is_reversed |
BOOLEAN |
not null, default false |
Whether this rate change has been superseded by a subsequent change |
reversed_on_date |
DATE |
nullable |
Business date the reversal was applied |
created_by |
BIGINT |
not null |
Audit field |
created_on_utc |
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
last_modified_by |
BIGINT |
not null |
Audit field |
last_modified_on_utc |
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
version |
INT |
not null, default 0 |
Optimistic locking version |
Configuration
Loan Product Configuration
Configure the following fields when creating or updating a Working Capital Loan product to enable EIR amortization:
{
"amortizationType": "EIR", // mandatory — current implementation supports EIR
"npvDayCount": 360, // mandatory for EIR — denominator in expected-payment formula
"periodPaymentRate": 100, // mandatory for EIR — percentage rate applied to TPV
"discount": 5000.00 // optional — upfront discount fee deducted before EIR computation
}
|
|
GL Account Mappings
Required GL account mappings when accounting_type is not NONE:
-
Loan Portfolio (ASSET):
loanPortfolioAccountId— tracks outstanding principal -
Deferred Income Liability (LIABILITY):
deferredIncomeLiabilityAccountId— holds unrecognized discount fee income until amortized -
Income from Discount Fee (INCOME):
incomeFromDiscountFeeAccountId— receives amortized income each period
|
|
API Design
Endpoints
Retrieve Projected Amortization Schedule
Returns the full projected amortization schedule for a Working Capital Loan, including EIR, per-payment discount factors, NPV values, balances, amortization amounts, and deferred balance.
GET /v1/working-capital-loans/{loanId}/amortization-schedule
Response:
{
"discountFeeAmount": 5000.00,
"netDisbursementAmount": 95000.00,
"totalPaymentVolume": 105000.00,
"periodPaymentRate": 27.77,
"npvDayCount": 360,
"expectedDisbursementDate": "2024-01-15",
"expectedPaymentAmount": 291.67,
"originalPaymentNumber": 344,
"effectiveInterestRate": 0.000295,
"payments": [
{
"paymentNo": 1,
"paymentDate": "2024-01-16",
"expectedPaymentAmount": 291.67,
"discountFactor": 0.999705,
"npvValue": 291.58,
"balance": 94709.00,
"expectedAmortizationAmount": 13.89,
"actualPaymentAmount": null,
"actualAmortizationAmount": null,
"incomeModification": null,
"deferredBalance": 5000.00
}
]
}
|
Payment row 0 (disbursement) is also included in the |
Update Period Payment Rate
Modifies the periodPaymentRate for an active Working Capital Loan. The operation reverses any existing active rate change, records a new m_wc_loan_period_payment_rate_change entry, and triggers recalculation of the amortization schedule from the rate change date forward via a new RateSegment.
PUT /v1/working-capital-loans/{loanId}/payment-rate
PUT /v1/working-capital-loans/external-id/{loanExternalId}/payment-rate
Request Body:
{
"periodPaymentRate": 30, // mandatory — new period payment rate, as a percentage value
"note": "Rate renegotiation", // optional — recorded as a loan note
"locale": "en_GB" // optional
}
Response:
{
"officeId": 1,
"clientId": 1,
"resourceId": 123
}
Retrieve Rate Change History
Returns all rate change records for the loan in reverse chronological order (most recent first).
GET /v1/working-capital-loans/{loanId}/rate-changes
GET /v1/working-capital-loans/external-id/{loanExternalId}/rate-changes
Response:
[
{
"id": 5,
"loanId": 123,
"effectiveDate": "2024-06-01",
"previousRate": 27.77,
"newRate": 30,
"reversed": false,
"reversedOnDate": null,
"createdDate": "2024-06-01T10:00:00Z"
}
]
Business Rules
EIR Computation
-
expectedPayment = (totalPaymentVolume × periodPaymentRate / 100) / npvDayCount -
originalPaymentNumber = ceil((netDisbursementAmount + discountFeeAmount) / expectedPayment) -
EIR = TvmFunctions.rate(originalPaymentNumber, −expectedPayment, netDisbursementAmount) -
The Newton-Raphson solver converges to a tolerance of
1E-12with a maximum of 500 iterations. -
Product validation currently rejects non-EIR amortization types.
-
netDisbursementAmountmust be positive;npvDayCountmust be positive.
Balance and Discount Factor Recurrence
For each payment period i (1-based):
-
balance[i] = balance[i−1] × (1 + EIR) − expectedPayment -
discountFactor[i] = 1 / (1 + EIR)^paymentsLeft[i] -
npvValue[i] = forecastPayment[i] × discountFactor[i] -
expectedAmortizationAmount[i] = balance[i] + expectedPayment − balance[i−1](equivalent tobalance[i−1] × EIR) -
deferredBalancedecreases monotonically fromdiscountFeeAmountto 0 over the loan term.
Rate Segments
-
A rate change at date
Dresolves tosplitDayIndex = days(disbursementDate, D). -
The balance at the split is derived from the base schedule recurrence up to
splitDayIndex − 1. -
The new expected payment for the segment:
newPayment = (TPV × newPeriodPaymentRate / 100) / npvDayCount. -
The new segment term:
floor((balanceAtSplit + discountAtSplit) / newPayment). -
The new EIR:
TvmFunctions.rate(segmentTerm, −newPayment, balanceAtSplit). -
The
RateSegmentstores:startDayIndex,expectedPaymentAmount,segmentTerm,effectiveInterestRate,netDisbursementAtSplit,discountAtSplit. -
All payments before
startDayIndexretain their original EIR-based values. -
Any existing segment at or after
splitDayIndexis removed before adding the new one (idempotent overwrite).
Rate Change Reversal
-
Before recording a new rate change, all existing active (non-reversed) entries for the loan are reversed by setting
is_reversed = trueandreversed_on_date = businessDate. -
Only one non-reversed rate change entry is maintained per loan at any time.
Amortization Model Lifecycle
The ProjectedAmortizationScheduleModel progresses through four operations:
-
generate()— creates the initial schedule when the loan reaches approval/disbursement processing with the resolved product and loan parameters. -
regenerate()— recalculates with updated amounts at approval or disbursement, preserving already applied payments. -
applyPayment()— records a payment and rebuilds the payment list using the payment-date mapping rules described above. -
applyRateChange()— adds aRateSegmentand rebuilds the payment list fromstartDayIndexforward.
Example Scenarios
Scenario #1: EIR Schedule for a New Working Capital Loan
Setup:
* Loan amount: 100,000; discount fee: 5,000 (deducted upfront)
* periodPaymentRate = 27.77, npvDayCount = 360, totalPaymentVolume = 105,000
Action:
The system computes expectedPayment = (105,000 × 27 / 100) / 360 ≈ 291.67 and originalPaymentNumber = ceil(100,000 / 291.67) = 343. EIR is solved via Newton-Raphson: EIR = RATE(343, −291.67, 95,000). The schedule is stored in m_wc_loan_amortization_model with one row per day (343 payment rows plus the disbursement row).
Expected Behavior:
-
effectiveInterestRateis returned in the amortization schedule API response. -
Each
payments[]entry includesdiscountFactor,npvValue,expectedAmortizationAmount, anddeferredBalance. -
deferredBalancedecreases from 5,000 to 0 over the 344 periods. -
First payment date is
disbursementDate + 1 day.
Scenario #2: Mid-Lifecycle Rate Change
Setup:
* Loan has 343 periods total; 144 payments have been applied.
* Outstanding balance at period 144: 60,000.
* New periodPaymentRate = 30.
Action:
applyRateChange() is called with newPeriodPaymentRate = 30 and rateChangeDate = businessDate. splitDayIndex = 145. newPayment = (105,000 × 30 / 100) / 360 = 350.00. newDiscount = remainingTotal − balanceAtSplit. segmentTerm = floor((60,000 + newDiscount) / 350). EIR_new = RATE(segmentTerm, −350.00, 60,000). A RateSegment with startDayIndex = 145 is appended. The prior active m_wc_loan_period_payment_rate_change entry is reversed and a new one is saved.
Expected Behavior:
-
Periods 1–144 retain their original EIR-based
discountFactor,npvValue, andexpectedAmortizationAmount. -
Periods 145 onward are recalculated using
EIR_newandnewPayment. -
The rate change history endpoint returns the new entry with
previousRate = 27.77andnewRate = 30.
Summary
Working Capital Loan EIR calculation provides time-value-of-money-based income recognition for revolving credit. Key aspects include:
-
EIR is solved numerically using Newton-Raphson from
totalPaymentVolume,periodPaymentRate,npvDayCount, anddiscountFeeAmount. -
The full amortization schedule is serialized to JSON in
m_wc_loan_amortization_modelfor efficient retrieval without recomputation. -
Each scheduled payment carries a
discountFactorandnpvValue, enabling present-value-based income recognition through thedeferredBalanceamortization mechanism. -
Mid-lifecycle rate changes are handled via
RateSegmentsplits, preserving historical actuals while recalculating only future payments with the new EIR. -
Rate change history is maintained in
m_wc_loan_period_payment_rate_changewith full reversal support.
Working Capital Loan Discount Configuration
Overview
Working Capital Loans support an upfront discount amount that is deducted from the gross disbursement and used as the basis for EIR income amortization. This document covers how the discount is configured on the product and how the value moves through the loan lifecycle: proposed, approved, and applied at disbursement.
Discount Fee transaction creation, adjustment, accounting, and reversal behavior are documented separately in working-capital-discount-fee-txn.adoc.
Purpose
The discount configuration gives lenders a controlled way to price Working Capital credit while still allowing the discount to be reduced during origination when the product allows loan-level overrides.
Scope
The scope of this document includes:
-
Product-level discount default and override configuration
-
Discount values at loan submission, approval, and disbursement
-
Validation rules for staged discount overrides
-
Effective discount amount used by the amortization schedule
Applicability
-
Working Capital Loans with the currently supported
amortizationType = EIR -
Discount configuration is available during product and loan lifecycle operations
-
The discount is only applied to the loan balance after disbursement
Definitions and Key Concepts
Discount (product default): The default discount fee amount defined on the Working Capital Loan product. It is copied to the loan instance when the loan is created.
Discount Proposed (discount_proposed): The discount amount proposed at loan submission time, if the product allows overrides. Stored on m_wc_loan.
Discount Approved (discount_approved): The discount amount set during approval. Cannot exceed discount_proposed. Stored on m_wc_loan. Cleared when approval is undone.
Discount (active, discount): The discount amount applied at disbursement. It drives the discountFeeAmount used in EIR schedule computation.
Discount Default Overridable (discountDefault): A product-level configurable attribute. When true, the loan officer may override the product discount at loan level. When false, the product discount is fixed and override attempts fail with override.not.allowed.by.product.
Design Decisions and Considerations
Staged Discount Override
The discount flows through three stages: proposed (submission) → approved (approval) → applied (disbursement). Each stage can only reduce, not increase, the discount from the prior stage. This keeps the final fee within the product default or the borrower-proposed amount.
Applied Discount Drives EIR
The EIR schedule uses the applied discount value from the loan product related details. Product default, proposed, and approved values are only staging values until disbursement resolves the amount.
Database Design
Overview
The discount lifecycle is tracked on the Working Capital Loan product related details and loan instance. Transaction relation tables are covered in working-capital-discount-fee-txn.adoc.
Existing Tables
m_wc_loan_product: Defines the product default discount.
m_wc_loan: Stores the proposed, approved, and applied discount values embedded from WorkingCapitalLoanProductRelatedDetails.
Changes to Existing Tables
m_wc_loan
| Column Name | Type | Constraints | Description |
|---|---|---|---|
discount |
DECIMAL(19,6) |
nullable |
Active discount amount; set at disbursement; used as |
discount_proposed |
DECIMAL(19,6) |
nullable |
Discount proposed at loan submission; upper bound for approval |
discount_approved |
DECIMAL(19,6) |
nullable |
Discount set during approval; upper bound for disbursement; cleared on undo-approval |
Configuration
Loan Product Configuration
Configure the discount at product level. The allowAttributeOverrides block controls whether loan-level stages can override the product default.
{
"discount": 5000.00,
"allowAttributeOverrides": {
"discountDefault": true
}
}
|
|
API Design
Set Discount at Submission
The discount field is optional when submitting a loan application. It is allowed only when discountDefault = true on the loan product.
POST /v1/working-capital-loans
{
"discount": 4500.00
}
Set Discount at Approval
The discountAmount field is optional during approval. It sets discount_approved and cannot exceed discount_proposed.
POST /v1/working-capital-loans/{loanId}?command=approve
POST /v1/working-capital-loans/external-id/{loanExternalId}?command=approve
{
"discountAmount": 4000.00
}
Set Discount at Disbursement
The discountAmount field at disbursement resolves the applied loan discount. When the resolved amount is greater than zero, a DISCOUNT_FEE transaction is created as part of disbursement processing.
POST /v1/working-capital-loans/{loanId}?command=disburse
POST /v1/working-capital-loans/external-id/{loanExternalId}?command=disburse
{
"discountAmount": 4000.00
}
Validation Rules
Discount at Submission
-
discountmust be zero or positive when provided. -
If
discountDefault = false, providingdiscountfails withoverride.not.allowed.by.product. -
If
discountDefault = true,discountmay be provided and must not exceed the product default discount.
Discount at Approval
-
discountAmountmust be zero or positive when provided. -
If
discountDefault = false, providingdiscountAmountfails withoverride.not.allowed.by.product. -
discountAmountcannot exceeddiscount_proposed. If no proposed override exists, it cannot exceed the product default discount.
Discount at Disbursement
-
discountAmountmust be zero or positive when provided. -
discountAmountcannot exceed the approval-time discount. -
If no approved discount exists,
discountAmountcannot exceed the product default discount.
Business Rules
Discount Lifecycle
-
At loan creation, the product default
discountis copied to the loan’s embedded product related details. -
At submission, if the product allows overrides (
discountDefault = true), the borrower can propose a lower discount. The proposed value is stored asdiscount_proposed. -
At approval, the approver may set
discountAmount. The approved amount is stored asdiscount_approvedand cannot exceed the proposed amount. -
When approval is undone (
undoapproval),discount_approvedis cleared to null. The loan returns to SUBMITTED state. -
At disbursement, the applied discount is stored as
discount. -
If the applied discount is greater than zero, a
DISCOUNT_FEEtransaction is created. Transaction details are covered inworking-capital-discount-fee-txn.adoc.
Effective Discount Amount
The discount used in EIR schedule computation (discountFeeAmount) is the resolved active discount value on the Working Capital Loan at schedule generation time.
Example Scenario
Discount Reduced During Origination
Setup:
* Loan product with discount = 5000.00 and discountDefault = true.
* Borrower submits with discount = 4500.00.
* Approver sets discountAmount = 4000.00.
Action:
Loan is disbursed with discountAmount = 4000.00.
Expected Behavior:
-
discount_proposed = 4500.00,discount_approved = 4000.00, anddiscount = 4000.00onm_wc_loan. -
The amortization schedule uses
discountFeeAmount = 4000.00. -
The borrower receives net disbursement reduced by the applied discount.
Summary
Working Capital Loan Discount Configuration defines the default discount and the allowed staged overrides before disbursement. The applied discount is the value used by the amortization schedule and, when greater than zero, results in a Discount Fee transaction.
Savings Interest Posting
Overview
Apache Fineract supports several interest posting period types for savings and fixed-deposit accounts.
The period type determines the calendar interval at the end of which accrued interest is credited (posted) to the account.
Standard Posting Period Types
| Code | Name | Description |
|---|---|---|
1 |
Daily |
Interest is posted every day. |
4 |
Monthly |
Interest is posted on the first day of each calendar month. |
5 |
Quarterly |
Interest is posted on the first day of each calendar quarter, aligned to the configured financial-year beginning month. |
6 |
Bi-Annual |
Interest is posted twice a year, aligned to the configured financial-year beginning month. |
7 |
Annual |
Interest is posted once a year on the first day of the month that begins the configured financial year. |
Anniversary-Based Posting Period Types
Anniversary posting periods differ from standard ones in that the posting schedule is anchored to the day of the month on which the account was activated, rather than to a fixed calendar boundary (e.g., end of month or end of quarter).
| Code | Name | Description |
|---|---|---|
8 |
Anniversary Monthly |
Interest is posted every month on the same day-of-month as the account activation date. |
9 |
Anniversary Quarterly |
Interest is posted every three months on the same day-of-month as the account activation date. |
10 |
Anniversary Bi-Annual |
Interest is posted every six months on the same day-of-month as the account activation date. |
11 |
Anniversary Annual |
Interest is posted every twelve months on the same day-of-month as the account activation date. |
How Anniversary Posting Works
For all anniversary period types:
-
The anchor day is the day-of-month of the account’s activation (start interest calculation) date.
-
Each subsequent posting date is computed by adding the configured interval (1, 3, 6, or 12 months) to the start of the current period and adjusting the result to the anchor day.
-
If the target month has fewer days than the anchor day, the posting date is adjusted to the last day of that month.
Posting Date Examples
Account opened on January 15 — Anniversary Monthly
| Period Start | Period End | Posting Date |
|---|---|---|
Jan 15 |
Feb 14 |
Feb 15 |
Feb 15 |
Mar 14 |
Mar 15 |
Mar 15 |
Apr 14 |
Apr 15 |
Account opened on January 31 — Anniversary Monthly (short-month adjustment)
| Period Start | Period End | Posting Date |
|---|---|---|
Jan 31 |
Feb 27 |
Feb 28 (last day of Feb) |
Feb 28 |
Mar 30 |
Mar 31 |
Mar 31 |
Apr 29 |
Apr 30 (last day of Apr) |
Apr 30 |
May 30 |
May 31 |
Account opened on February 29 (leap year) — Anniversary Annual
| Period Start | Period End | Posting Date |
|---|---|---|
Feb 29, 2024 |
Feb 27, 2025 |
Feb 28, 2025 (2025 is not a leap year) |
Feb 28, 2025 |
Feb 27, 2026 |
Feb 28, 2026 |
Feb 28, 2026 |
Feb 27, 2027 |
Feb 28, 2027 |
Configuration
Anniversary posting period types are configured at the product level and apply to both Savings Products and Fixed Deposit Products.
API — Create / Update Savings Product
Pass the numeric code in the interestPostingPeriodType field:
{
"interestPostingPeriodType": 8
}
API — Create / Update Fixed Deposit Product
The same interestPostingPeriodType field is used for fixed deposit products.
{
"interestPostingPeriodType": 9
}
|
The financial-year beginning month ( |
Behaviour at Period Boundaries
-
The last posting period of a calculation run may extend beyond the
interestPostingUpToDate. Truncation to the actual balance date is handled downstream byPostingPeriod, not by the period-boundary calculation itself. -
When a manual "post interest as on" date falls within a period, that period is split at the manual date, consistent with the behaviour of all other posting period types.
Working Capital Breach Management and Near Breach
Overview
Breach management for Working Capital (WC) Loans provides a schedule-driven framework to track whether a borrower is meeting minimum periodic payment obligations within each breach period. A breach occurs when the cumulative amount paid within a period falls below the configured minimum by the period’s end date. A near breach is an earlier warning signal: it fires within a period when the cumulative payments received at one or more evaluation checkpoints fall below a percentage-based threshold of the expected cumulative payment.
Both mechanisms operate on the same underlying breach schedule — a series of contiguous time periods generated per-loan by the Close of Business (COB) pipeline. The feature is implemented in the fineract-working-capital-loan module.
Purpose
This feature enables lenders and credit operations teams to:
-
Detect imminent payment shortfalls before a breach actually occurs (near-breach signal).
-
Determine definitively, at the end of each breach period, whether the borrower failed to meet the minimum obligation (breach flag).
-
Report breach and near-breach status per period via the breach schedule API, enabling downstream workflows such as alerts, collection actions, and covenant enforcement.
Scope
The scope of this document includes:
-
System-level breach configuration (
m_wc_breach_configuration) — defines period frequency and minimum payment rule. -
System-level near-breach configuration (
m_wc_near_breach) — defines evaluation checkpoints and threshold percentage. -
Loan product configuration — association of breach and near-breach configurations to a product, controlled by the breach overridable flag.
-
Per-loan breach schedule (
m_wc_loan_breach_schedule) — generated and maintained by the COB pipeline. -
COB business steps:
WC_BREACH_SCHEDULE(step 4) andWC_NEAR_BREACH_EVALUATION(step 5). -
API endpoints for managing breach and near-breach configurations and retrieving a loan’s breach schedule.
-
Validation rules enforced during configuration creation and product setup.
Applicability
-
Breach schedule generation applies to Working Capital Loan accounts with at least one actual disbursement recorded.
-
Breach evaluation applies to loans whose product, or individual loan, has a breach configuration assigned.
-
Near-breach evaluation applies only to active loans that also have a near-breach configuration assigned.
Definitions and Key Concepts
Breach Configuration: A named, reusable set of rules — stored in m_wc_breach_configuration — that defines the breach period frequency and the minimum payment amount. Referenced by loan products and individual loans.
Near-Breach Configuration: A named, reusable set of rules — stored in m_wc_near_breach — that defines evaluation checkpoints within a breach period and the cumulative-payment threshold (as a percentage). When the cumulative paid amount at a checkpoint falls below (checkpointIndex + 1) × threshold% × minPaymentAmount, the period is flagged near-breached.
Breach Schedule Period: A contiguous time window (identified by a sequential periodNumber) that starts on the actual disbursement date offset by delinquency grace days and repeats at the configured frequency. Each period records fromDate, toDate, minPaymentAmount, paidAmount, outstandingAmount, a nearBreach boolean, and a breach boolean.
Breach Amount Calculation Type: Determines how minPaymentAmount is derived per period. FLAT uses the configured breachAmount directly. PERCENTAGE computes it as breachAmount% of approvedPrincipal + discount.
Near-Breach Threshold: A percentage value (0–100). On each evaluation checkpoint inside a breach period, the system computes requiredCumulative = (checkpointIndex + 1) × (threshold / 100) × minPaymentAmount. If paidAmount < requiredCumulative, the period is marked as near-breached.
Breach Overridable: A flag on the loan product’s configurable attributes (breach_overridable, exposed as allowAttributeOverrides.breach) that controls whether the breach and near-breach configurations can be replaced at the individual loan application level.
Design Decisions and Considerations
Separate Configuration Tables for Breach and Near Breach
Breach and near-breach configurations are maintained as independent, named, reusable entities rather than being embedded in the loan product. This allows multiple loan products — or even individual loans — to share the same configuration, and allows configurations to be updated centrally. The constraint that a near-breach configuration can only be used together with a breach configuration is enforced at loan product and loan application level.
Near-Breach Frequency Is Always Shorter Than Breach Frequency
The near-breach configuration defines checkpoints within a breach period. The system enforces that the near-breach frequency (i.e., the interval between checkpoints) is strictly less than the breach period frequency. This is validated through WorkingCapitalNearBreachParseAndValidator.validateNearBreachAgainstBreach when product or loan application breach settings are validated.
Breach Evaluation Is Idempotent
Both evaluateBreach and evaluateNearBreach check whether the relevant boolean flag is already non-null before doing any work. Once a period has been evaluated (flag set to true or false), the COB step does not re-evaluate it. This prevents double-flagging and keeps COB idempotent.
null Means "Not Yet Evaluated"
The breach and nearBreach columns on m_wc_loan_breach_schedule use three-state semantics: null = period still open and not yet evaluated, false = evaluated and the condition was not met, true = condition triggered. This distinction is important: a period with paidAmount = 0 but not yet past its toDate correctly has breach = null.
Minimum Payment Applied at Repayment Time
When a repayment is posted on a WC loan, WorkingCapitalLoanBreachScheduleServiceImpl.applyRepayment immediately updates the paidAmount and outstandingAmount on the current breach period. This means the breach and near-breach evaluations in COB operate on up-to-date repayment data.
Database Design
Overview
The breach subsystem introduces two configuration tables (m_wc_breach_configuration, m_wc_near_breach) and one per-loan schedule table (m_wc_loan_breach_schedule). The existing m_wc_loan_product, m_wc_loan, and m_wc_loan_product_configurable_attributes tables gain foreign-key columns to reference these configurations.
Existing Tables
m_wc_loan_product: Gains breach_id (FK to m_wc_breach_configuration) and near_breach_id (FK to m_wc_near_breach) to associate configurations at the product level.
m_wc_loan: Gains breach_id and near_breach_id columns, copied from the product at loan origination unless overridden by the loan application.
m_wc_loan_product_configurable_attributes: Gains breach_overridable (BOOLEAN, default false) to control whether loan applications may specify different breach and near-breach configurations.
Table: m_wc_breach_configuration
The m_wc_breach_configuration table stores named breach configurations. Each row defines a breach period length and the minimum payment rule.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
PK, auto-increment, not null |
Primary key |
|
VARCHAR(100) |
not null, unique |
Human-readable configuration name |
|
INT |
nullable |
Number of frequency units in one breach period |
|
VARCHAR(50) |
nullable |
Frequency unit: |
|
VARCHAR(50) |
nullable |
|
|
DECIMAL(19,6) |
nullable |
Minimum payment amount or percentage of principal+discount |
Table: m_wc_near_breach
The m_wc_near_breach table stores named near-breach configurations. Each row defines how frequently checkpoints occur within a breach period and the cumulative threshold percentage.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
PK, auto-increment, not null |
Primary key |
|
VARCHAR(100) |
not null, unique |
Human-readable configuration name |
|
INT |
not null |
Number of frequency units between consecutive checkpoints |
|
VARCHAR(50) |
not null |
Frequency unit: |
|
DECIMAL(19,6) |
not null |
Percentage (0–100) used to compute the required cumulative payment at each checkpoint |
Table: m_wc_loan_breach_schedule
The m_wc_loan_breach_schedule table stores one row per breach period per WC loan. It is populated by the COB step WC_BREACH_SCHEDULE and updated during repayment posting.
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
PK, auto-increment, not null |
Primary key |
|
BIGINT |
FK to |
Associated WC loan |
|
INT |
not null, unique with |
Sequential period index (1-based) |
|
DATE |
not null |
Start of the breach period (inclusive) |
|
DATE |
not null |
End of the breach period (inclusive) |
|
INT |
nullable |
|
|
DECIMAL(19,6) |
nullable |
Minimum amount due within this period |
|
DECIMAL(19,6) |
nullable |
Cumulative amount paid within this period so far |
|
DECIMAL(19,6) |
nullable |
|
|
BOOLEAN |
nullable |
|
|
BOOLEAN |
nullable |
|
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
|
BIGINT |
not null |
Audit field |
|
DATETIME(6) / TIMESTAMP WITH TIME ZONE |
not null |
Audit field |
Changes to Existing Tables
m_wc_loan_product
New columns:
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
FK to |
Product-level breach configuration |
|
BIGINT |
FK to |
Product-level near-breach configuration |
m_wc_loan
New columns:
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BIGINT |
FK to |
Per-loan breach configuration (copied from product or overridden) |
|
BIGINT |
FK to |
Per-loan near-breach configuration (copied from product or overridden together with breach) |
m_wc_loan_product_configurable_attributes
New column:
| Column Name | Type | Constraints | Description |
|---|---|---|---|
|
BOOLEAN |
default |
If |
Configuration
Loan Product Configuration
To activate breach management on a Working Capital loan product, include a breachId referencing an existing breach configuration. nearBreachId is optional but requires breachId to be set.
{
"breachId": 1, // mandatory to activate breach schedule generation
"nearBreachId": 2, // optional; requires breachId; near-breach evaluation only runs if set
"allowAttributeOverrides": {
"breach": true // optional; allows per-loan override of breachId and nearBreachId
}
}
API Design
Endpoints
Breach Configuration Template
Returns available option lists for breachFrequencyType and breachAmountCalculationType.
GET /v1/working-capital/breach/template
Response:
{
"breachFrequencyTypeOptions": [
{ "id": "DAYS", "code": "DAYS", "value": "Days" },
{ "id": "WEEKS", "code": "WEEKS", "value": "Weeks" },
{ "id": "MONTHS", "code": "MONTHS", "value": "Months" },
{ "id": "YEARS", "code": "YEARS", "value": "Years" }
],
"breachAmountCalculationTypeOptions": [
{ "id": "PERCENTAGE", "code": "PERCENTAGE", "value": "Percentage" },
{ "id": "FLAT", "code": "FLAT", "value": "Flat" }
]
}
Breach Configuration CRUD
Create, retrieve, update, and delete breach configurations.
GET /v1/working-capital/breach/breaches
GET /v1/working-capital/breach/breaches/{breachId}
POST /v1/working-capital/breach/breaches
PUT /v1/working-capital/breach/breaches/{breachId}
DELETE /v1/working-capital/breach/breaches/{breachId}
Request Body (POST / PUT):
{
"name": "Monthly 20%", // mandatory — unique name, max 100 chars
"breachFrequency": 1, // mandatory — positive integer
"breachFrequencyType": "MONTHS", // mandatory — DAYS | WEEKS | MONTHS | YEARS
"breachAmountCalculationType": "PERCENTAGE", // mandatory — PERCENTAGE | FLAT
"breachAmount": 20.00 // mandatory — zero or positive
}
Response:
{
"resourceId": 1
}
|
A breach configuration cannot be deleted while it is referenced by at least one Working Capital loan product. The delete operation raises a |
Near-Breach Configuration CRUD
Create, retrieve, update, and delete near-breach configurations.
GET /v1/working-capital/near-breach
GET /v1/working-capital/near-breach/{breachId}
POST /v1/working-capital/near-breach
PUT /v1/working-capital/near-breach/{breachId}
DELETE /v1/working-capital/near-breach/{breachId}
Request Body (POST / PUT):
{
"nearBreachName": "Weekly 15%", // mandatory — unique name
"nearBreachFrequency": 1, // mandatory — positive integer
"nearBreachFrequencyType": "WEEKS", // mandatory — DAYS | WEEKS | MONTHS | YEARS
"nearBreachThreshold": 15.00 // mandatory — percentage 0–100
}
Response:
{
"resourceId": 2
}
Breach Schedule Retrieval
Returns the list of breach schedule periods for a given WC loan.
GET /v1/working-capital-loans/{loanId}/breach-schedule
Response:
[
{
"id": 101,
"loanId": 42,
"periodNumber": 1,
"fromDate": "2025-01-15",
"toDate": "2025-02-14",
"numberOfDays": 31,
"minPaymentAmount": 2000.00,
"outstandingAmount": 500.00,
"nearBreach": true,
"breach": null
}
]
Validation Rules
Breach Configuration
-
name— mandatory, not blank, max 100 characters, globally unique. -
breachFrequency— mandatory, must be a positive integer (> 0). -
breachFrequencyType— mandatory, must be one ofDAYS,WEEKS,MONTHS,YEARS. -
breachAmountCalculationType— mandatory, must be one ofPERCENTAGE,FLAT. -
breachAmount— mandatory, must be zero or positive. -
On update, duplicate-name validation excludes the entity being updated (self-update is allowed).
Near-Breach Configuration
-
nearBreachName— mandatory, not null, globally unique. -
nearBreachFrequency— mandatory, must be a positive integer (> 0). -
nearBreachFrequencyType— mandatory, must be one ofDAYS,WEEKS,MONTHS,YEARS. -
nearBreachThreshold— mandatory, must be a valid percentage value (validated by.percentage()).
Loan Product — Cross-Configuration Constraint
-
nearBreachIdmay only be set whenbreachIdis also set. SettingnearBreachIdwithout abreachIdfails withcannot.enable.near.breach.without.breach. -
The near-breach frequency must be strictly lower than the breach period frequency. Validation is performed by
WorkingCapitalNearBreachParseAndValidator.validateNearBreachAgainstBreach. Failure code:near.breach.frequency.must.be.lower.than.breach.frequency.
Business Rules
Breach Schedule Generation
-
The first breach period starts on
actualDisbursementDate + delinquencyGraceDays. If no actual disbursement has been recorded, generation is deferred to the next COB run. -
Each period ends inclusively:
DAYSusesfromDate + frequency - 1 day;WEEKS,MONTHS, andYEARSadd the configured number of units and then subtract one day. -
Periods are generated lazily: the initial period is created once on the first COB run after disbursement; subsequent periods are generated until the latest period’s
toDateis after the current business date. -
minPaymentAmountis calculated from the breach configuration:-
FLAT: usesbreachAmountdirectly. -
PERCENTAGE: appliesbreachAmount%toapprovedPrincipal + discount(rounded to currency precision).
-
Breach Evaluation
-
On each COB run the
WC_BREACH_SCHEDULEstep callsevaluateBreachfor the period that covers the current business date. -
A period is breached (
breach = true) ifoutstandingAmount > 0ANDbusinessDate >= toDate. -
A period with
outstandingAmount = 0is markedbreach = falseimmediately when fully paid. -
Once
breachis non-null the COB step skips re-evaluation of that period.
Near-Breach Evaluation
-
Triggered by the
WC_NEAR_BREACH_EVALUATIONCOB step (order 5), which runs afterWC_BREACH_SCHEDULE. -
The step is skipped for loans that are not active or have no near-breach configuration.
-
Evaluation checkpoints within a period are computed as multiples of the near-breach frequency starting from
fromDate. Checkpoints that fall on or aftertoDateare excluded. -
On each checkpoint date
dthe required cumulative payment is:requiredCumulative = (checkpointIndex + 1) × (threshold / 100) × minPaymentAmount -
If
paidAmount < requiredCumulativeon a checkpoint, the period is flaggednearBreach = true. -
If the business date reaches
toDateand no checkpoint was triggered, the period is markednearBreach = false. -
Once
nearBreachis non-null, the COB step skips further evaluation for that period.
Repayment Application
-
On every repayment transaction,
WorkingCapitalLoanBreachScheduleServiceImpl.applyRepaymentidentifies the breach period covering the transaction date, adds the payment topaidAmount, and recomputesoutstandingAmount(floored at zero). If this reducesoutstandingAmountto zero andbreachhas not yet been evaluated, the period is markedbreach = false. This ensures COB evaluations operate on current repayment data.
Example Scenarios
Scenario #1: Near Breach Triggered Mid-Period
Setup:
-
Breach configuration:
breachFrequency = 1,breachFrequencyType = MONTHS,breachAmountCalculationType = FLAT,breachAmount = 1000. -
Near-breach configuration:
nearBreachFrequency = 2,nearBreachFrequencyType = WEEKS,nearBreachThreshold = 50. -
Period 1:
fromDate = 2025-01-15,toDate = 2025-02-14,minPaymentAmount = 1000.
Action:
During COB on 2025-01-29 (two weeks after fromDate), the first checkpoint is reached. The borrower has paid 400 to date.
Expected Behavior:
-
requiredCumulative = (0+1) × 0.50 × 1000 = 500 -
paidAmount (400) < requiredCumulative (500)→nearBreach = trueset on period 1. -
The
breachflag remainsnull— period has not yet ended.
Scenario #2: Full Period Paid — No Breach
Setup:
Same breach configuration. The borrower makes a 1000 payment on 2025-01-20.
Action:
applyRepayment sets paidAmount = 1000, outstandingAmount = 0, breach = false.
Expected Behavior:
-
During COB on
2025-02-14theWC_BREACH_SCHEDULEstep findsbreachis already non-null and skips evaluation. -
During COB on
2025-01-29theWC_NEAR_BREACH_EVALUATIONstep computesrequiredCumulative = 500;paidAmount (1000) >= 500, sonearBreachis not triggered (the period would eventually resolve tofalseattoDate).
Summary
Working Capital Breach Management provides a periodic, schedule-driven mechanism to flag payment shortfalls on WC loans. Key aspects include:
-
Independent, reusable breach and near-breach configurations managed at the system level and assigned to loan products.
-
A per-loan breach schedule generated by the COB pipeline — one period per breach frequency interval — starting from the disbursement date offset by delinquency grace days.
-
A two-stage COB evaluation:
WC_BREACH_SCHEDULE(step 4) generates periods and evaluates end-of-period breaches;WC_NEAR_BREACH_EVALUATION(step 5) assesses mid-period cumulative-payment checkpoints. -
Minimum payment amounts computed as either a flat amount or a percentage of
approvedPrincipal + discount. -
Idempotent evaluation: once a breach or near-breach flag is resolved (
trueorfalse), subsequent COB runs leave it unchanged.
Fineract Development Environment
TBD
Git
TBD
GPG
TBD
Committers
Please make sure to provide your GPG fingerprint in your Apache committer profile at id.apache.org.
Gradle
TBD
Custom Modules
| Currently, modules are a proof of concept feature in Fineract. |
Introduction
Creating customizations for Fineract services is easy. The method described here will work both with our future module guidelines (aka "clean room" modules) and with the intermediary solution we will put in place to avoid major refactorings.
The folder structure for modules is based on a convention that ensures that your extensions don’t clash with Fineract’s internals. This is to make sure that your downstream forks of Fineract are easy to sync. In the past we had all kinds of strategies to add custom code - including editing existing sources in fineract-provider. This is not recommended.
At the moment the only service(s) we prepared to be overridden/replaced are org.apache.fineract.portfolio.note.service.NoteReadPlatformService and org.apache.fineract.portfolio.note.service.NoteWritePlatformService. Please reach out on the developer mailing list if you need other services.
|
The recommended folder structure is very simple. If you follow this recommendation you’ll get some additional benefits, e. g. you don’t even have to edit settings.gradle to include your new custom modules. Your modules will also be automatically included in a custom Fineract Docker image build that you can use for your production deployments.
Let’s assume your company/org is called "ACME Inc." and you are trying to (fully/partially) replace an existing Fineract service, let’s say those in org.apache.fineract.portfolio.note. The recommended folder structure would then look something like this:
As soon as we can publish Fineract module JARs to Maven Central you’ll have more freedom to setup your projects (including to setup separate Git repos). But for now please follow these instructions:
-
Create a folder under
customand name it according to your company/organisation (e. g.acmeif your company isACME Inc.); this way your custom modules can’t clash even with other companies' modules -
Under your company folder create a folder for the
categoryordomainyour module is targeting; e. g. "loan", "client", "account" etc. -
Finally, setup
libraryfolders for the actual modules you want to create; usually that will be to replace/extend some existing service, so there could be aservicefolder, maybe even acorefolder, e. g. if you want to add additional DTOs etc.; we have also an example for COB business steps -
Per
category/domainyou should have astarterlibrary; means: a Spring Boot auto-configuration setup that makes including your module in Fineract easier ("hands-free"); the necessary parts for a auto-configuration library are a Spring Java configuration class (annotated with@Configuration) and a text file atMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsin your starter resource folder:com.acme.fineract.portfolio.note.starter.AcmeNoteAutoConfigurationPlease make sure that your module libraries have proper
build.gradlefiles:description = 'ACME Fineract Note Service' group = 'com.acme.fineract' base { archivesName = 'acme-fineract-note-service' } apply from: 'dependencies.gradle'You don’t need to edit settings.gradleto add your modules/libraries. If you follow above convention they’ll get included automatically. -
The dependency.gradle file could look something like this:
dependencies { implementation(project(':fineract-core')) implementation(project(':fineract-provider')) compileOnly('org.springframework.boot:spring-boot-autoconfigure') }
| We’ve included by default some basic and useful dependencies for all custom modules, like Slf4j, Lombok, the usual testing frameworks (JUnit, Cucumber, Mockito etc.) |
| Do not include your custom module in `fineract-provider’s dependency.gradle file. This creates a circular dependency and will fail your build. |
Custom Services
| We are still trying to figure out which internal services make most sense to be pluggable. Please join the discussion and let us know if you have a specific requirement. |
Note Service
The Note service is responsible for … TBD
| We chose the note service because it’s interface is very simple and has not many cross dependencies. |
Interfaces
package org.apache.fineract.portfolio.note.service;
import java.util.List;
import org.apache.fineract.portfolio.note.data.NoteData;
public interface NoteReadPlatformService {
NoteData retrieveNote(Long noteId, Long resourceId, Integer noteTypeId);
List<NoteData> retrieveNotesByResource(Long resourceId, Integer noteTypeId);
}
package org.apache.fineract.portfolio.note.service;
import java.util.List;
import org.apache.fineract.portfolio.note.data.NoteData;
public interface NoteReadPlatformService {
NoteData retrieveNote(Long noteId, Long resourceId, Integer noteTypeId);
List<NoteData> retrieveNotesByResource(Long resourceId, Integer noteTypeId);
}
Configuration
The rules to replace the Note services are very simple. If you provide an alternative implementation of the services then the default implementations will not be loaded.
Custom Business Steps
It is very easy to add your own business steps to Fineract’s default steps:
-
Create a custom module (e. g.
custom/acme/steps, follow the instructions on how to create a custom module) -
Create a class that implements interface
org.apache.fineract.cob.COBBusinessStep -
Provide the custom database migration to add the necessary information about your business step in table
m_batch_business_steps
package org.apache.fineract.cob;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
public interface COBBusinessStep<T extends AbstractPersistableCustom<Long>> {
T execute(T input);
String getEnumStyledName();
String getHumanReadableName();
}
Business Step Implementation
package com.acme.fineract.loan.cob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.cob.loan.LoanCOBBusinessStep;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class AcmeNoopBusinessStep implements LoanCOBBusinessStep, InitializingBean {
private static final String ENUM_STYLED_NAME = "ACME_LOAN_NOOP";
private static final String HUMAN_READABLE_NAME = "ACME Loan Noop";
// NOTE: just to demonstrate that dependency injection is working
private final LoanAccountDomainService loanAccountDomainService;
@Override
public void afterPropertiesSet() throws Exception {
log.warn("Acme COB Loan: '{}'", getClass().getCanonicalName());
}
@Override
public Loan execute(Loan input) {
return input;
}
@Override
public String getEnumStyledName() {
return ENUM_STYLED_NAME;
}
@Override
public String getHumanReadableName() {
return HUMAN_READABLE_NAME;
}
}
As you can see this implementation is very simple and doesn’t do much. There are some simple conventions though that you should follow implementing your own business steps:
-
Make sure the value returned by method
getEnumStyledName()is unique; it’s a good idea to choose a prefix that reflects the name of your organization (in this exampleACME_) -
You have more freedom for the value returned by
getHumanReadableName(), but it’s a good idea to keep this value as unique as possible
Business Step Database Migration
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="acme" id="1">
<insert tableName="m_batch_business_steps">
<column name="job_name" value="LOAN_CLOSE_OF_BUSINESS"/>
<column name="step_name" value="ACME_LOAN_NOOP"/>
<column name="step_order" value="5"/>
</insert>
</changeSet>
</databaseChangeLog>
| See also chapter about batch jobs in this documentation. |
Custom Loan Transaction Processors
Fineract has 7 built-in loan transaction processors:
-
org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor -
org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor -
org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor -
org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor -
org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor -
org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.PrincipalInterestPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor -
org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.RBILoanRepaymentScheduleTransactionProcessor
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.apache.fineract.portfolio.loanaccount.service.LoanChargeService;
import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanInterestRefundServiceImpl;
import org.apache.fineract.portfolio.loanaccount.service.schedule.LoanScheduleComponent;
import org.apache.fineract.portfolio.loanproduct.calc.EMICalculator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
@Configuration
public class LoanAccountAutoStarter {
@Bean
@Conditional(CreocoreLoanRepaymentScheduleTransactionProcessorCondition.class)
public CreocoreLoanRepaymentScheduleTransactionProcessor creocoreLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
final LoanBalanceService loanBalanceService) {
return new CreocoreLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Bean
@Conditional(EarlyRepaymentLoanRepaymentScheduleTransactionProcessorCondition.class)
public EarlyPaymentLoanRepaymentScheduleTransactionProcessor earlyPaymentLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
final LoanBalanceService loanBalanceService) {
return new EarlyPaymentLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Bean
@Conditional(MifosStandardLoanRepaymentScheduleTransactionProcessorCondition.class)
public FineractStyleLoanRepaymentScheduleTransactionProcessor fineractStyleLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
final LoanBalanceService loanBalanceService) {
return new FineractStyleLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Bean
@Conditional(HeavensFamilyLoanRepaymentScheduleTransactionProcessorCondition.class)
public HeavensFamilyLoanRepaymentScheduleTransactionProcessor heavensFamilyLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
final LoanBalanceService loanBalanceService) {
All default processor implementations are enabled by default, but can also be prevented from being loaded into memory by a simple configuration in application.properties. Use the environment variables you see below in your Kubernetes and Docker Compose deployments to override the default behavior.
fineract.tenant.config.max-pool-size=${FINERACT_CONFIG_MAX_POOL_SIZE:-1}
fineract.tenant.config.rounding-mode=${FINERACT_CONFIG_ROUNDING_MODE:6}
fineract.mode.read-enabled=${FINERACT_MODE_READ_ENABLED:true}
fineract.mode.write-enabled=${FINERACT_MODE_WRITE_ENABLED:true}
fineract.mode.batch-worker-enabled=${FINERACT_MODE_BATCH_WORKER_ENABLED:true}
fineract.mode.batch-manager-enabled=${FINERACT_MODE_BATCH_MANAGER_ENABLED:true}
Implement Processors
package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public interface LoanRepaymentScheduleTransactionProcessor {
String getCode();
String getName();
boolean accept(String s);
/**
* Provides support for processing the latest transaction (which should be the latest transaction) against the loan
* schedule.
*
* @return ChangedTransactionDetail
*/
ChangedTransactionDetail processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx);
/**
* Provides support for passing all {@link LoanTransaction}'s so it will completely re-process the entire loan
* schedule. This is required in cases where the {@link LoanTransaction} being processed is in the past and falls
* before existing transactions or and adjustment is made to an existing in which case the entire loan schedule
* needs to be re-processed.
*/
ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursementDate, List<LoanTransaction> repaymentsOrWaivers,
MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> repaymentScheduleInstallments, Set<LoanCharge> charges);
Money handleRepaymentSchedule(List<LoanTransaction> transactionsPostDisbursement, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> loanCharges);
/**
* Used in interest recalculation to introduce new interest only installment.
*/
boolean isInterestFirstRepaymentScheduleTransactionProcessor();
}
package com.acme.fineract.loan.processor;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
import org.springframework.stereotype.Component;
@Component
public class AcmeLoanRepaymentScheduleTransactionProcessor extends FineractStyleLoanRepaymentScheduleTransactionProcessor {
public static final String STRATEGY_CODE = "acme-standard-strategy";
public static final String STRATEGY_NAME = "ACME Corp.: standard loan transaction processing strategy";
public AcmeLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
public String getCode() {
return STRATEGY_CODE;
}
@Override
public String getName() {
return STRATEGY_NAME;
}
}
The example implementation doesn’t do much. We are just overriding one of the default processor implementations org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor and give the custom processor it’s own lookup code and name (descriptive text for display in UIs, e. g. when configuring a loan product). As usual it is a good idea to follow some simple conventions:
-
Make sure the value returned by
getCode()is unique. Prefixing it with characters that reflect your organization name (hereacme-) is a good idea. -
You have more freedom for the descriptive test returned by
getName(), but it is still a good idea to keep the value unique to avoid confusion.
Method getCode()
Lookup value that is used to pick a loan transaction processor (see processor factory).
Method getName()
Descriptive text about the loan transaction processor that is mostly used in user interfaces.
Override Processor Factory
The processor factory has no reference to any specific implementation of the loan transaction processor interface. All available implementations will be injected here (internal default and custom implementations). Processor instances can be looked up via method determineProcessor(). You can pass either the code of the processor or the processor’s name to look it up. If a matching processor can’t be found then the factory function will either return the default instance or fails with an exception depending on the configuration in application.properties.
| It is preferable to use the processor code to lookup processor instances. Lookups via processor names are only done in the import service via Excel sheets (should be fixed). |
package org.apache.fineract.portfolio.loanaccount.domain;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionProcessingStrategyNotFoundException;
import org.apache.fineract.portfolio.loanproduct.data.TransactionProcessingStrategyData;
import org.springframework.beans.factory.annotation.Value;
@RequiredArgsConstructor
public class LoanRepaymentScheduleTransactionProcessorFactory {
private final LoanRepaymentScheduleTransactionProcessor defaultLoanRepaymentScheduleTransactionProcessor;
private final List<LoanRepaymentScheduleTransactionProcessor> processors;
@Value("${fineract.loan.transactionprocessor.error-not-found-fail}")
private Boolean errorNotFoundFail;
public LoanRepaymentScheduleTransactionProcessor determineProcessor(final String transactionProcessingStrategy) {
Optional<LoanRepaymentScheduleTransactionProcessor> processor = processors.stream()
.filter(p -> p.accept(transactionProcessingStrategy)).findFirst();
if (processor.isEmpty() && Boolean.TRUE.equals(errorNotFoundFail)) {
throw new LoanTransactionProcessingStrategyNotFoundException(transactionProcessingStrategy);
} else {
return processor.orElse(defaultLoanRepaymentScheduleTransactionProcessor);
}
}
public List<TransactionProcessingStrategyData> getStrategies() {
return processors.stream().map(p -> new TransactionProcessingStrategyData(null, p.getCode(), p.getName())).toList();
}
}
This is the default factory auto-configuration.
return new HeavensFamilyLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Bean
@Conditional(InterestPrincipalPenaltiesFeesLoanRepaymentScheduleTransactionProcessorCondition.class)
public InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor interestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
If you need then you can override this, e.g. because you want to set a different default processor then you can do so in your custom module’s auto-configuration.
public LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory(
AcmeLoanRepaymentScheduleTransactionProcessor defaultLoanRepaymentScheduleTransactionProcessor,
List<LoanRepaymentScheduleTransactionProcessor> processors) {
return new LoanRepaymentScheduleTransactionProcessorFactory(defaultLoanRepaymentScheduleTransactionProcessor, processors);
}
}
Custom Batch Jobs
Fineract provides extension points to define custom batch jobs using module system. Using this approach custom batch jobs can be defined and configured along with Fineract’s default batch jobs to extend or customize batch processing.
The batch jobs in Fineract are implemented using Spring Batch. In addition to the Spring Batch ecosystem, automatic scheduling is done by Quartz Scheduler but it’s also possible to trigger batch jobs via regular APIs.
For defining custom job:
-
Create custom module (e. g.
custom/acme/loan/job), follow the instructions on how to create a custom module. -
Create job configuration to register job, job steps, tasklet with job builder factory. (e. g.
com.acme.fineract.loan.job.AcmeNoopJobConfiguration) -
Create tasklet for job execution functionality. (e.g.
com.acme.fineract.loan.job.AcmeNoopJobTasklet) -
Provide the custom database migration to add necessary information about your job in table
job. (e.g.custom/acme/loan/job/src/main/resources/db/custom-changelog/0001_acme_loan_job.xml) -
New job name should be registered along with default jobs so that it can be scheduled at startup. For registering job name with Fineract job scheduler, create an enum with job name details (e.g.
com.acme.fineract.loan.job.AcmeJobName) and a job name provider configuration which is accessed by Fineract job scheduler at startup to retrieve job name (e.g.com.acme.fineract.loan.job.AcmeJobNameConfig).
Job Configuration
package com.acme.fineract.loan.job;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
@RequiredArgsConstructor
public class AcmeNoopJobConfiguration {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final AcmeNoopJobTasklet tasklet;
@Bean
protected Step acmeNoopJobStep() {
return new StepBuilder(AcmeJobName.ACME_NOOP_JOB.name(), jobRepository).tasklet(tasklet, transactionManager).build();
}
@Bean
public Job acmeNoopJob() {
return new JobBuilder(AcmeJobName.ACME_NOOP_JOB.name(), jobRepository).start(acmeNoopJobStep()).incrementer(new RunIdIncrementer())
.build();
}
}
Tasklet Definition
package com.acme.fineract.loan.job;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class AcmeNoopJobTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
log.info("Acme custom job execution");
return RepeatStatus.FINISHED;
}
}
Database Migration Script for Job
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="acme" id="1">
<insert tableName="job">
<column name="name" value="Acme Noop Job"/>
<column name="display_name" value="Acme Noop Job"/>
<column name="cron_expression" value="0 1 0 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Acme Noop Job _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="false"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="0"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="acme" id="2">
<update tableName="job">
<column name="short_name" value="ACM_NOOP"/>
<where>name='Acme Noop Job'</where>
</update>
</changeSet>
</databaseChangeLog>
Job Name Configuration
package com.acme.fineract.loan.job;
public enum AcmeJobName {
ACME_NOOP_JOB("Acme Noop Job"); //
private final String name;
AcmeJobName(final String name) {
this.name = name;
}
@Override
public String toString() {
return this.name;
}
}
package com.acme.fineract.loan.job;
import java.util.List;
import org.apache.fineract.infrastructure.jobs.service.jobname.JobNameData;
import org.apache.fineract.infrastructure.jobs.service.jobname.JobNameProvider;
import org.apache.fineract.infrastructure.jobs.service.jobname.SimpleJobNameProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AcmeJobNameConfig {
@Bean
public JobNameProvider acmeJobNameProvider() {
return new SimpleJobNameProvider(List.of(new JobNameData(AcmeJobName.ACME_NOOP_JOB.name(), AcmeJobName.ACME_NOOP_JOB.toString())));
}
}
Gradle Build Files
Please make sure that your module libraries have proper build.gradle and dependencies.gradle files:
build.gradle)description = 'ACME Fineract Loan Job'
group = 'com.acme.fineract'
base {
archivesName = 'acme-fineract-loan-job'
}
apply from: 'dependencies.gradle'
dependencies.gradle)dependencies {
implementation(project(':fineract-core'))
implementation(project(':fineract-loan'))
implementation(project(':fineract-provider'))
implementation('org.springframework.batch:spring-batch-integration')
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
}
Deployment
Custom modules can be deployed using docker image. See chapter about deploying custom modules in this documentation.
./gradlew :custom:docker:jibDockerBuild
| See also chapter about batch jobs in this documentation. |
Custom Database Migration
If database migrations are needed as part of your customizations then you can add your own migration scripts. This is again based on conventions:
-
Create folders
db/custom-changelogin one of yourresourcesfolders; we recommend using the resources folder in your starter library, but actually any of your custom libs will do. -
Under
db/custom-changelogcreate an XML changelog file, e. g.changelog-acme-note.xml; you are free to choose a name for this file, but we recommend being consistent to avoid classpath conflicts. -
Under
db/custom-changelogcreate a folderpartsfor your specific changelogs
And here an example migration script:
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="acme" id="1">
<createTable tableName="acme_note_dummy">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="name" type="VARCHAR(100)">
<constraints unique="true"/>
</column>
<column name="description" type="VARCHAR(500)"/>
</createTable>
</changeSet>
</databaseChangeLog>
By default, custom database migration changelogs are executed in context tenant_db. That makes sure your changes will be applied to the tenant database (read: main database and not the tenant store database). In theory you could also target the tenant configuration database, but it’s not recommended to do that.
|
Deploying Custom Modules
Custom modules (better: the JAR files) only need to be dropped in Fineract’s libs folder if you run Fineract from the Spring Boot JAR file. Dynamic loading of external JARs is provided since Fineract version 1.5.0. For your convenience we’ve created a separate Docker image module that automatically includes your custom modules (see custom/docker). You can build this Docker image with
./gradlew :custom:docker:jibDockerBuild
The Docker image with included custom modules is called fineract-custom.
| We’ll provide soon a way to customize the Docker image parameters (image name, JVM implementation, JVM args, ports etc.). |
Outlook
If this proof of concept is accepted we could prepare more of Fineract’s internal services to be replaceable. This approach works already very well even if we don’t have proper JAR libraries published on Maven Central. It’s an important goal to separate customized code from Fineract’s internals to have soon real modules.
Resilience
Introduction Resilience
Fineract had handcrafted retry loops in place for the longest time. A typical retry code would have looked like this:
@Override
@SuppressWarnings("AvoidHidingCauseException")
@SuppressFBWarnings(value = {
"DMI_RANDOM_USED_ONLY_ONCE" }, justification = "False positive for random object created and used only once")
public CommandProcessingResult logCommandSource(final CommandWrapper wrapper) {
boolean isApprovedByChecker = false;
// check if is update of own account details
if (wrapper.isUpdateOfOwnUserDetails(this.context.authenticatedUser(wrapper).getId())) {
// then allow this operation to proceed.
// maker checker doesn't mean anything here.
isApprovedByChecker = true; // set to true in case permissions have
// been maker-checker enabled by
// accident.
} else {
// if not user changing their own details - check user has
// permission to perform specific task.
this.context.authenticatedUser(wrapper).validateHasPermissionTo(wrapper.getTaskPermissionName());
}
validateIsUpdateAllowed();
final String json = wrapper.getJson();
CommandProcessingResult result = null;
JsonCommand command;
int numberOfRetries = 0; (1)
int maxNumberOfRetries = ThreadLocalContextUtil.getTenant().getConnection().getMaxRetriesOnDeadlock();
int maxIntervalBetweenRetries = ThreadLocalContextUtil.getTenant().getConnection().getMaxIntervalBetweenRetries();
final JsonElement parsedCommand = this.fromApiJsonHelper.parse(json);
command = JsonCommand.from(json, parsedCommand, this.fromApiJsonHelper, wrapper.getEntityName(), wrapper.getEntityId(),
wrapper.getSubentityId(), wrapper.getGroupId(), wrapper.getClientId(), wrapper.getLoanId(), wrapper.getSavingsId(),
wrapper.getTransactionId(), wrapper.getHref(), wrapper.getProductId(), wrapper.getCreditBureauId(),
wrapper.getOrganisationCreditBureauId(), wrapper.getJobName());
while (numberOfRetries <= maxNumberOfRetries) { (2)
try {
result = this.processAndLogCommandService.executeCommand(wrapper, command, isApprovedByChecker);
numberOfRetries = maxNumberOfRetries + 1; (3)
} catch (CannotAcquireLockException | ObjectOptimisticLockingFailureException exception) {
log.debug("The following command {} has been retried {} time(s)", command.json(), numberOfRetries);
/***
* Fail if the transaction has been retired for maxNumberOfRetries
**/
if (numberOfRetries >= maxNumberOfRetries) {
log.warn("The following command {} has been retried for the max allowed attempts of {} and will be rolled back",
command.json(), numberOfRetries);
throw exception;
}
/***
* Else sleep for a random time (between 1 to 10 seconds) and continue
**/
try {
int randomNum = RANDOM.nextInt(maxIntervalBetweenRetries + 1);
Thread.sleep(1000 + (randomNum * 1000));
numberOfRetries = numberOfRetries + 1; (4)
} catch (InterruptedException e) {
throw exception;
}
} catch (final RollbackTransactionAsCommandIsNotApprovedByCheckerException e) {
numberOfRetries = maxNumberOfRetries + 1; (3)
result = this.processAndLogCommandService.logCommand(e.getCommandSourceResult());
}
}
return result;
}
| 1 | counter |
| 2 | while loop |
| 3 | increment to abort |
| 4 | increment |
For better code quality and readability we introduced Resilience4j:
private final CommandSourceRepository commandSourceRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandProcessingService processAndLogCommandService;
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
private final ConfigurationDomainService configurationService;
private final List<CleanupService> cleanupServices;
@Override
public CommandProcessingResult logCommandSource(final CommandWrapper wrapper) {
boolean isApprovedByChecker = false;
// check if is update of own account details
if (wrapper.isChangeOfOwnUserDetails(this.context.authenticatedUser(wrapper).getId())) {
// then allow this operation to proceed.
// maker checker doesnt mean anything here.
isApprovedByChecker = true; // set to true in case permissions have
// been maker-checker enabled by
// accident.
} else {
// if not user changing their own details - check user has
// permission to perform specific task.
this.context.authenticatedUser(wrapper).validateHasPermissionTo(wrapper.getTaskPermissionName());
}
validateIsUpdateAllowed();
final String json = wrapper.getJson();
final JsonElement parsedCommand = this.fromApiJsonHelper.parse(json);
JsonCommand command = JsonCommand.from(json, parsedCommand, this.fromApiJsonHelper, wrapper.getEntityName(), wrapper.getEntityId(),
Command
CommandProcessingService
TBD
executeCommand public static final String IDEMPOTENCY_KEY_STORE_FLAG = "idempotencyKeyStoreFlag";
public static final String IDEMPOTENCY_KEY_ATTRIBUTE = "IdempotencyKeyAttribute";
public static final String COMMAND_SOURCE_ID = "commandSourceId";
private final PlatformSecurityContext context;
private final ApplicationContext applicationContext;
private final ToApiJsonSerializer<Map<String, Object>> toApiJsonSerializer;
private final ToApiJsonSerializer<CommandProcessingResult> toApiResultJsonSerializer;
private final ConfigurationDomainService configurationDomainService;
private final CommandHandlerProvider commandHandlerProvider;
private final IdempotencyKeyResolver idempotencyKeyResolver;
private final CommandSourceService commandSourceService;
private final RetryConfigurationAssembler retryConfigurationAssembler;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
private CommandProcessingResult retryWrapper(Supplier<CommandProcessingResult> supplier) {
try {
if (!BatchRequestContextHolder.isEnclosingTransaction()) {
return retryConfigurationAssembler.getRetryConfigurationForExecuteCommand().executeSupplier(supplier);
}
return supplier.get();
} catch (RuntimeException e) {
return fallbackExecuteCommand(e);
}
}
@Override
public CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command,
final boolean isApprovedByChecker) {
return retryWrapper(() -> {
// Do not store the idempotency key because of the exception handling
setIdempotencyKeyStoreFlag(false);
Long commandId = (Long) fineractRequestContextHolder.getAttribute(COMMAND_SOURCE_ID, null);
boolean isRetry = commandId != null;
boolean isEnclosingTransaction = BatchRequestContextHolder.isEnclosingTransaction();
CommandSource commandSource = null;
String idempotencyKey;
if (isRetry) {
commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey();
} else if ((commandId = command.commandId()) != null) { // action on the command itself
commandSource = commandSourceService.getCommandSource(commandId);
idempotencyKey = commandSource.getIdempotencyKey();
} else {
idempotencyKey = idempotencyKeyResolver.resolve(wrapper);
}
exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry);
AppUser user = context.authenticatedUser(wrapper);
if (commandSource == null) {
if (isEnclosingTransaction) {
commandSource = commandSourceService.getInitialCommandSource(wrapper, command, user, idempotencyKey);
} else {
commandSource = commandSourceService.saveInitialNewTransaction(wrapper, command, user, idempotencyKey);
commandId = commandSource.getId();
}
}
if (commandId != null) {
storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
setIdempotencyKeyStoreFlag(true);
return executeCommand(wrapper, command, isApprovedByChecker, commandSource, user, isEnclosingTransaction);
});
}
private CommandProcessingResult executeCommand(final CommandWrapper wrapper, final JsonCommand command,
final boolean isApprovedByChecker, CommandSource commandSource, AppUser user, boolean isEnclosingTransaction) {
final CommandProcessingResult result;
try {
result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker);
} catch (Throwable t) { // NOSONAR
RuntimeException mappable = ErrorHandler.getMappable(t);
fallbackExecuteCommand }
Retry persistenceRetry = retryConfigurationAssembler.getRetryConfigurationForCommandResultPersistence();
try {
CommandSource finalCommandSource = commandSource;
AtomicInteger attemptNumber = new AtomicInteger(0);
CommandSource savedCommandSource = persistenceRetry.executeSupplier(() -> {
// Critical: Refetch on retry attempts (not on first attempt)
executeCommandfineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_INTEREST_PRINCIPAL_PENALTIES_FEES_ENABLED:true}
fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_PRINCIPAL_INTEREST_PENALTIES_FEES_ENABLED:true}
fineract.loan.transactionprocessor.rbi-india.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_RBI_INDIA_ENABLED:true}
fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_ENABLED:true}
fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_ENABLED:true}
fineract.loan.transactionprocessor.advanced-payment-strategy.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_ADVANCED_PAYMENT_STRATEGY_ENABLED:true}
Jobs
SchedularWritePlatformService
This service has a typo and should be called SchedulerWritePlatformService.
|
TBD
processJobDetailForExecution @Transactional
@Override
@Retry(name = "processJobDetailForExecution", fallbackMethod = "fallbackProcessJobDetailForExecution")
public boolean processJobDetailForExecution(final String jobKey, final String triggerType) {
boolean isStopExecution = false;
final ScheduledJobDetail scheduledJobDetail = this.scheduledJobDetailsRepository.findByJobKeyWithLock(jobKey);
if (scheduledJobDetail.isCurrentlyRunning() || (triggerType.equals(SchedulerServiceConstants.TRIGGER_TYPE_CRON)
&& scheduledJobDetail.getNextRunTime().after(new Date()))) {
isStopExecution = true;
}
final SchedulerDetail schedulerDetail = retriveSchedulerDetail();
if (triggerType.equals(SchedulerServiceConstants.TRIGGER_TYPE_CRON) && schedulerDetail.isSuspended()) {
scheduledJobDetail.setTriggerMisfired(true);
isStopExecution = true;
} else if (!isStopExecution) {
scheduledJobDetail.setCurrentlyRunning(true);
scheduledJobDetail.setMismatchedJob(false);
}
this.scheduledJobDetailsRepository.save(scheduledJobDetail);
return isStopExecution;
}
fallbackProcessJobDetailForExecution @SuppressWarnings("unused")
public boolean fallbackProcessJobDetailForExecution(Exception e) {
return false;
processJobDetailForExecutionfineract.loan.transactionprocessor.error-not-found-fail=${FINERACT_LOAN_TRANSACTIONPROCESSOR_ERROR_NOT_FOUND_FAIL:true}
# Comma separated list of loan statuses which will be recorded on change. There are two extra values: "NONE" and "ALL".
# "NONE" disables the feature and no entries will be created, "ALL" enables the feature for all loan statuses.
fineract.loan.status-change-history-statuses=${FINERACT_LOAN_STATUS_CHANGE_HISTORY_STATUSES:NONE}
Loan
LoanWritePlatformService
TBD
recalculateInterest final LoanRepaymentScheduleProcessingWrapper wrapper = new LoanRepaymentScheduleProcessingWrapper();
wrapper.reprocess(loan.getCurrency(), loan.getDisbursementDate(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges());
loanBalanceService.refreshSummaryAndBalancesForDisbursedLoan(loan);
}
private void reverseExistingTransactions(final Loan loan) {
final Collection<LoanTransaction> retainTransactions = new ArrayList<>();
for (final LoanTransaction transaction : loan.getLoanTransactions()) {
loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(transaction.getLoan(), transaction, "reversed");
transaction.reverse();
journalEntryPoster.postJournalEntriesForLoanTransaction(transaction, false, false);
if (transaction.getId() != null) {
retainTransactions.add(transaction);
}
}
loan.getLoanTransactions().retainAll(retainTransactions);
}
private Optional<LoanTransaction> closeAsWrittenOff(final Loan loan, final JsonCommand command, final Map<String, Object> changes,
final AppUser currentUser, final ScheduleGeneratorDTO scheduleGeneratorDTO) {
closeDisbursements(loan, scheduleGeneratorDTO);
final LocalDate writtenOffOnLocalDate = command.localDateValueOfParameterNamed(TRANSACTION_DATE);
loan.setClosedOnDate(writtenOffOnLocalDate);
loan.setWrittenOffOnDate(writtenOffOnLocalDate);
loan.setClosedBy(currentUser);
final LoanStatus statusEnum = loanLifecycleStateMachine.dryTransition(LoanEvent.WRITE_OFF_OUTSTANDING, loan);
if (statusEnum.hasStateOf(loan.getStatus())) {
return Optional.empty();
fallbackRecalculateInterest externalId = ExternalId.generate();
}
changes.put(CLOSED_ON_DATE, command.stringValueOfParameterNamed(TRANSACTION_DATE));
changes.put(WRITTEN_OFF_ON_DATE, command.stringValueOfParameterNamed(TRANSACTION_DATE));
changes.put("externalId", externalId);
if (DateUtils.isBefore(writtenOffOnLocalDate, loan.getDisbursementDate())) {
final String errorMessage = "The date on which a loan is written off cannot be before the loan disbursement date: "
+ loan.getDisbursementDate().toString();
throw new InvalidLoanStateTransitionException("writeoff", "cannot.be.before.submittal.date", errorMessage,
writtenOffOnLocalDate, loan.getDisbursementDate());
recalculateInterestfineract.content.regex-whitelist-enabled=${FINERACT_CONTENT_REGEX_WHITELIST_ENABLED:true}
fineract.content.regex-whitelist=${FINERACT_CONTENT_REGEX_WHITELIST:.*\\.pdf$,.*\\.doc,.*\\.docx,.*\\.xls,.*\\.xlsx,.*\\.jpg,.*\\.jpeg,.*\\.png}
fineract.content.mime-whitelist-enabled=${FINERACT_CONTENT_MIME_WHITELIST_ENABLED:true}
fineract.content.mime-whitelist=${FINERACT_CONTENT_MIME_WHITELIST:application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/jpeg,image/png}
fineract.content.default-buffer-size=${FINERACT_CONTENT_DEFAULT_BUFFER_SIZE:8192}
Savings
SavingsAccountWritePlatformService
TBD
postInterest .withOfficeId(account.officeId()) //
.withClientId(account.clientId()) //
.withGroupId(account.groupId()) //
.withSavingsId(savingsId) //
.build();
}
@Transactional
@Override
public void postInterest(final SavingsAccount account, final boolean postInterestAs, final LocalDate transactionDate,
final boolean backdatedTxnsAllowedTill) {
postInterest(account, postInterestAs, transactionDate, backdatedTxnsAllowedTill, ExternalId.empty());
}
private void postInterest(final SavingsAccount account, final boolean postInterestAs, final LocalDate transactionDate,
final boolean backdatedTxnsAllowedTill, final ExternalId externalId) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
if (account.getNominalAnnualInterestRate().compareTo(BigDecimal.ZERO) > 0
|| (account.allowOverdraft() && account.getNominalAnnualInterestRateOverdraft().compareTo(BigDecimal.ZERO) > 0)) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
if (backdatedTxnsAllowedTill) {
updateSavingsTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
} else {
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode());
boolean isInterestTransfer = false;
LocalDate postInterestOnDate = null;
if (postInterestAs) {
postInterestOnDate = transactionDate;
}
boolean postReversals = false;
account.postInterest(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth,
postInterestOnDate, backdatedTxnsAllowedTill, postReversals);
updateManualInterestPostingExternalId(account, externalId, transactionDate, backdatedTxnsAllowedTill);
fallbackPostInterest public CommandProcessingResult deleteSavingsAccountCharge(final Long savingsAccountId, final Long savingsAccountChargeId,
@SuppressWarnings("unused") final JsonCommand command) {
this.context.authenticatedUser();
final SavingsAccount savingsAccount = this.savingAccountAssembler.assembleFrom(savingsAccountId, false);
checkClientOrGroupActive(savingsAccount);
final SavingsAccountCharge savingsAccountCharge = this.savingsAccountChargeRepository
.findOneWithNotFoundDetection(savingsAccountChargeId, savingsAccountId);
savingsAccount.removeCharge(savingsAccountCharge);
this.savingAccountRepositoryWrapper.saveAndFlush(savingsAccount);
return new CommandProcessingResultBuilder() //
postInterestfineract.content.filesystem.enabled=${FINERACT_CONTENT_FILESYSTEM_ENABLED:true}
fineract.content.filesystem.rootFolder=${FINERACT_CONTENT_FILESYSTEM_ROOT_FOLDER:${user.home}/.fineract}
fineract.content.s3.enabled=${FINERACT_CONTENT_S3_ENABLED:false}
fineract.content.s3.bucketName=${FINERACT_CONTENT_S3_BUCKET_NAME:}
fineract.content.s3.accessKey=${FINERACT_CONTENT_S3_ACCESS_KEY:}
fineract.content.s3.secretKey=${FINERACT_CONTENT_S3_SECRET_KEY:}
Security
Fineract is secure by design. It is designed and built from the ground up to accept, manage, and present data securely. This chapter will detail its various security-related features and settings, along with best practices for secure deployment.
If you believe you have found a security vulnerability in Fineract itself, let us know privately. Report security issues in third party code (for example, the Mifos X Web UI) to the appropriate third party, not Fineract.
Your task as bank CTO, sysadmin, vendor, or other entity responsible for hosting Fineract securely is to thoroughly consider these sections and thoughtfully apply them in your work. While a Fineract release is secure by design, it is not sufficient for a sysadmin to simply start it up and hope for the best. Careful steps must be taken to ensure a deployment is and remains secure despite software environment changes, attacks, staff transitions, and anything else that may arise.
We’ll first cover the various supported authentication schemes and will continue on to recommendations for securing a Fineract deployment.
| The HTTP Basic and OAuth authentication schemes are mutually exclusive. You can’t enable them both at the same time. Fineract checks these settings on startup and will fail if more than one authentication scheme is enabled. |
HTTP Basic Authentication
By default Fineract is configured with a HTTP Basic Authentication scheme, so you actually don’t have to do anything if you want to use it. But if you would like to explicitly choose this authentication scheme then there are two ways to enable it:
-
Use environment variables (best choice if you run with Docker Compose):
FINERACT_SECURITY_BASICAUTH_ENABLED=true FINERACT_SECURITY_OAUTH_ENABLED=false -
Use JVM parameters (best choice if you run the Spring Boot JAR):
java -Dfineract.security.basicauth.enabled=true -Dfineract.security.oauth2.enabled=false -jar fineract-provider.jar
OAuth
Fineract has basic OAuth support based on Spring Boot Security.
This can be enabled at runtime in one of two ways:
-
Use environment variables (best choice if you run with Docker Compose):
FINERACT_SECURITY_BASICAUTH_ENABLED=false FINERACT_SECURITY_OAUTH_ENABLED=true FINERACT_SERVER_OAUTH_RESOURCE_URL=http://localhost:9000/realms/fineract -
Use JVM parameters (best choice if you run the Spring Boot JAR):
java -Dfineract.security.basicauth.enabled=false -Dfineract.security.oauth2.enabled=true -jar fineract-provider.jar
Here’s how to test OAuth with Keycloak.
The steps required for other OAuth providers will be similar.
Set up Keycloak
-
From terminal, run:
docker run -p 9000:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.2.5 start-dev -
Go to URL 'http://localhost:9000/admin' and login with admin/admin
-
Click 'Manage realms', then 'Create realm'
-
Enter name
fineractfor the realm name -
Click on tab 'Users' on the left, then 'Create new user' with username
mifos, emailtest@example.com, First nameMifos, Last nameUser -
Click on tab 'Credentials' at the top, and set password to
password, turning 'temporary' setting to off -
Click on tab 'Clients' on the left, and create client with ID
community-app -
In Settings tab, set 'Valid redirect URIs' to
localhost, enable 'Client authentication', check 'Direct access grants' -
Click 'Save' and a 'Credentials' tab will appear
-
In Credentials tab, copy string in field 'secret' as this will be needed in the step to request the access token
Finally we need to change Keycloak configuration so that it uses the username as a subject of the token:
-
Choose client
community-appin the tab 'Clients' -
Click on tab 'Client scopes', then
community-app-dedicated -
Go to tab 'Mappers', click 'Configure a new mapper' and choose 'User Property'
-
Enter
usernameInSubas 'Name' -
Enter
usernameinto the field 'Property' andsubinto the field 'Token Claim Name'
You are now ready to test out OAuth:
Retrieve an access token from Keycloak
curl --request POST \
"$FINERACT_SERVER_OAUTH_RESOURCE_URL/protocol/openid-connect/token" \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=mifos' \
--data-urlencode 'password=password' \
--data-urlencode 'client_id=community-app' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_secret=<enter the client secret from credentials tab>'
The reply should contain a field access_token. Copy the field’s value and use it in the API call below:
Invoke APIs and pass Authorization: bearer … header
curl --insecure \
'https://localhost:8443/fineract-provider/api/v1/offices' \
--header 'Fineract-Platform-TenantId: default' \
--header 'Authorization: bearer <enter the value of the access_token field>'
Two-factor authentication
You can enable 2FA authentication. Depending on how you start Fineract add the following:
-
Use environment variables (best choice if you run with Docker Compose):
FINERACT_SECURITY_2FA_ENABLED=true -
Use JVM parameter (best choice if you run the Spring Boot JAR):
java -Dfineract.security.2fa.enabled=true -jar fineract-provider.jar
Securing Fineract
This section covers best practices in securing the use of Fineract.
Security hardening is a continuum and Fineract is adaptable to your security needs. There’s no one way to correctly deploy it and the open source project offers no warranty. It’s up to you to deploy and maintain it carefully, according to your organization’s needs and compliance requirements.
Since Fineract is a financial application with PII (personally identifiable information), it is vital that it is secured in production whenever it is setup. If you are a small financial entity, bank, credit union, microfinance organization, or non-banking financial institution, the project urges you to identify and work with the vendors that work regularly with Fineract and are regular contributors to the security fixes. In this way, we encourage a community of contributions that keep the overall solution secure. Open source gets the benefit of many people reviewing the code and suggesting issues and solutions - let’s ensure that virtuous cycle can work by supporting those working on security at Fineract.
Members of the Security team can be reached at security AT fineract.apache.org. The reporting mechanisms for vulnerabilities and exploits are there, not on the public dev list.
See apache security practices for more information. security.apache.org
Also, we recommend you familiarize yourself with the OWASP foundation and the "Cheat Sheet" series cheatsheetseries.owasp.org
Tips for securing the Fineract infrastructure
Pay attention to your logs
View, review, and continuously monitor your server logs. Heed DO NOT USE THIS IN PRODUCTION! warnings.
Do not enable Spring profiles
Spring profiles such as test must never be enabled in production environments. test enables insecure API endpoints only meant for dev/test.
See Kubernetes for a valid use of the liquibase-only Spring profile.
Run it isolated and/or disconnected
In the world of Microfinance or small banking operations (in some geographies), it is possible that you can run Fineract on a private network, or isolated from the internet by being hosted locally and securing all connections. This could involve establishing a VPN with limited ports open, and only accepting connections within that VPN. At the far end of this spectrum, is running it isolated and air-gapped as a backend accounting system, where there is no internet connection on that device. In such scenarios, you are limiting the vectors of attack to just those employees you give access to. You are also limiting the functionality to accounting and basic operations, so this is rarely appropriate. Even in these scenarios, it is important that you establish reviews of logs and accounts on a periodic basis to determine if any internal fraud is occurring. Such things should be part of your operational manual. There are a number of resources available for this topic, please find them online. For Fineract in particular, be mindful of the set up of approvals and and the access you give to each person or role in your organization.
Running it connected but behind a firewall
It should be clear that running it on the internet directly, without API monitoring and filtering, is a bad idea. This is especially true if your Fineract instance is connected to a payment mechanism of any kind. Imagine an exploit being used to gain access and then to send funds from an account to an outside merchant or bank. An attacker could drain an account before you can detect the issue. And, then it will depend on the payment scheme rules whether any of those funds are recoverable.
There are multiple ways to enhance the security that is built into Fineract, but none of them are bulletproof and so you must have defense in depth. One key thing is to run the Fineract instance behind an API gateway, and to prevent certain API patterns or calls that are likely to be fraudulent. The important thing is to recognize that while you may not be a target institution now, at any moment this can change, and your IP will be listed on the dark web as a potential target for exploit. Your IT team must also have ways to quickly turn off services to limit the damage.
It is recommended to run it with at least API Gateway, WAF (Web Application Firewall) and SQL Injection filtering tool if connecting to the internet. Fineract must be hardened to run in production.
Fraud prevention
Even if you have secured your infrastructure, you will need the ability to monitor fraudulent traffic, and then to stop that fraud in real time. Fraud can occur even when your infrastructure is good, but a user account has been accessed through a phishing attack or similar vector. And, in the world of real time payments and multiple payment types and channels, there is a need for additional real time monitoring, and inline processing of potential fraudulent transactions. Detection and blocking needs to occur in real time and then resolution can occur more leisurely with additional manual and help desk interventions.
If you are a small institution and you are getting into this situation, you should consider having holds on all payment and transfers built into your process, until you can enable effective tools.
There are a number of fraud prevention tools that are available in market from fraud prevention vendors. When looking for solution providers, the ideal scenario is a vendor with longevity and a track record in your market for detecting recent fraudulent activities. Pattern detection is a key part of this, and for that, it is important to be able to get enough data from your systems to identify the anomalies.
There are a number of GitHub projects that cover algorithms for conducting ML on transactions to detect anomalies. There is also an open source project, Tazama, which provides a kind of framework for your own logic and algorithms: github.com/frmscoe/docs.
Fraud exploits are on the rise, supercharged by AI tooling. AI tooling can also be used to fight these trends, but it is an ever escalating area of concern for financial providers globally.
Self-service APIs
It is recommended that you leave the Self Service APIs disabled to avoid any potential exploits there. Apps should not be developed to use those APIs.
There is a way to run those APIs endpoint (re-written but consistent) in a separate isolated component, where there is a way to control the ingress and egress of data. Once that component is linked up with authenticated users with a fully designed authorization scheme, then the APIs can be accessed. This is an area of exploration by the project. Currently, Fineract should not be run in a way that allows access to those APIs. We strongly advise against using any APP that connects to those APIs without revising the architecture as described, except in a test or demo environment.
User Education and Training
Educating and training your team is another limb of your organizational cybersecurity defense. Equipped with engaging security awareness training sessions, end-users can be prepared with both knowledge and skills on how to identify potential security threats and react to them. You can get more information from some of the resources offered in the course during CISA Training: www.cisa.gov
Regular Security Audits and Compliance Checks
Know your compliance surface. Regularly conduct routine security audits and compliance checks. This can be helpful in finding all the vulnerabilities and their fix prior to exploitation, thereby helping to reduce the exposure window. A combined automated tool with manual expert reviews provides complete coverage. There are multiple vendors available that scan for compliance with existing security standards. We don’t recommend any vendor in particular, but for pointers you can look at owasp.org/www-community/Vulnerability_Scanning_Tools
Key Management and Data Encryption Strategies
Implement strong data encryption strategies to protect sensitive information. Key management should be something that your IT team does, utilizing best practices. Just like a physical key, you should keep it in a secure location with limited access and take special care not to copy it to digital locations that can be scanned or found, including email systems. Make sure you have procedures in place.
You would probably want to encrypt the data at rest with AES-256 and in transit via TLS 1.3. Create and maintain binding standards for encryption in your organization. And remember, key management to encryption is the key. Every cloud providers provides key management services that help you manage and secure your keys.
Examples:
Secure Coding Practices
Secure code by following secure coding practices and standards, such as OWASP’s top ten, for any kind of vulnerability at the code level. Use tools like SonarQube for finding security problems in your source code through static application security testing (SAST) prior to deploying an application. Note that SonarQube has already been integrated into our automation build process.
Apache Software Foundation has an account with SonarQube and Fineract scans can be found in that account.
Multi-factor Authentication (MFA)
Enhance your security layers with MFA (or 2FA: two-factor authentication). One such approach, built on three things: something the user knows (like a password), something the user has (like a security token), and something the user is (biometric verification, for example). When MFA is used, it adds another layer of security. Solutions such as Duo Security may be a good implementation for MFA.
Leverage Community Support
You should stay engaged with the Fineract community to stay on top of security updates, patches, and best practices. Also, look for the possibility of collaboration with cybersecurity firms that would help you increase the capability of your threat detection and response system. Such relationships may avail specialized skills, technologies, and intelligence that may strengthen the security posturing of your organization.
Testing
TBD
Cucumber E2E Tests
Apache Fineract’s E2E test suite provides comprehensive coverage of business functionality using Cucumber BDD (Behavior-Driven Development) framework. These tests serve as both functional validation and living documentation of the system’s capabilities.
Overview
Architecture
-
fineract-e2e-tests-runner: Contains all Cucumber feature files and test scenarios
-
fineract-e2e-tests-core: Contains step definitions, test utilities, and supporting code
-
Framework: Cucumber with Java, using Gherkin syntax for readable test specifications
-
Prerequisites: Running Apache Fineract instance (typically on port 8443 with HTTPS)
Test Organization
-
Feature files located in:
fineract-e2e-tests-runner/src/test/resources/features/ -
Step definitions in:
fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/ -
Tests are tagged with TestRail IDs for traceability (e.g.,
@TestRailId:C16) -
Special tags include
@Smokefor quick validation tests
Prerequisites
Required Software
-
Java 21: Apache Fineract requires Java 21 (Azul Zulu JDK recommended)
-
Database: MariaDB 12.2, PostgreSQL 18.3, or MySQL 9.1
-
Git: For source code management
-
Gradle 8.14.3: Included via wrapper
Database Setup
Before running E2E tests, ensure the databases are created:
# Create required databases
./gradlew createDB -PdbName=fineract_tenants
./gradlew createDB -PdbName=fineract_default
Configuration
Connection Configuration
E2E tests connect to the running Fineract instance. Default configuration in fineract-e2e-tests-core/src/test/resources/fineract-test-application.properties:
# Connection details to running backend
fineract-test.api.base-url=${BASE_URL:https://localhost:8443}
fineract-test.api.username=${TEST_USERNAME:mifos}
fineract-test.api.password=${TEST_PASSWORD:password}
fineract-test.api.tenant-id=${TEST_TENANT_ID:default}
To override defaults, use environment variables:
export BASE_URL=http://localhost:8080
export TEST_USERNAME=mifos
export TEST_PASSWORD=password
export TEST_TENANT_ID=default
Test Data Initialization
Many E2E tests require pre-configured test data (loan products, charges, configurations). This initialization is controlled by the fineract-test.initialization.enabled property.
|
Default Behavior:
-
By default, initialization is DISABLED (
fineract-test.initialization.enabled=false) -
Without initialization, tests will fail with errors like:
java.lang.IllegalArgumentException: Loan product [LP2_ADV_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL] not found
How to Enable Initialization:
Method 1 - Environment Variable (Recommended):
cd fineract-e2e-tests-runner
INITIALIZATION_ENABLED=true ../gradlew cucumber
Method 2 - System Property:
cd fineract-e2e-tests-runner
../gradlew cucumber -DINITIALIZATION_ENABLED=true
Method 3 - Modify Properties File:
Edit fineract-e2e-tests-core/src/test/resources/fineract-test-application.properties:
fineract-test.initialization.enabled=true
What Initialization Creates:
-
100+ loan products with specific configurations
-
Various charge types (NSF fees, processing fees, etc.)
-
Payment allocation rules
-
Interest calculation configurations
-
Advanced payment allocation strategies
-
Progressive loan schedule configurations
When to Use Initialization:
-
First-time test execution on a fresh database
-
After database reset/recreation
-
When running tests that require specific loan products
-
Testing new features that depend on pre-configured products
| Initialization takes additional time (2-5 minutes) as it creates extensive test data. Consider running it once and reusing the database for multiple test runs. |
Business Date Configuration
CRITICAL: The Business Date feature must be enabled in the database for many E2E tests to function correctly.
Default Behavior:
-
Business Date is DISABLED by default in fresh Fineract installations
-
Without Business Date enabled, tests fail with:
{"errors":[{
"defaultUserMessage":"Business date functionality is not enabled",
"developerMessage":"Business date functionality is not enabled",
"userMessageGlobalisationCode":"business.date.is.not.enabled"
}]}
How to Enable Business Date:
Method 1 - Via SQL (Direct Database):
mysql -u root -pmysql fineract_default -e \
"UPDATE c_configuration SET enabled = 1 WHERE name = 'enable-business-date';"
Method 2 - Via API (After Fineract is Running):
curl -k -X PUT https://localhost:8443/fineract-provider/api/v1/configurations/name/enable-business-date \
-H "Authorization: Basic bWlmb3M6cGFzc3dvcmQ=" \
-H "Fineract-Platform-TenantId: default" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
The Fineract-Platform-TenantId header is required. Without it, the request can fail with HTTP 400 because tenant context is missing.
|
Verification:
mysql -u root -pmysql fineract_default -e \
"SELECT * FROM c_configuration WHERE name LIKE '%business%';"
Running E2E Tests
Complete Workflow
Step 1: Start Fineract
# Start Fineract with test profile enabled for E2E
./gradlew bootRun -Dspring.profiles.active=test
When running E2E tests that hit endpoints/APIs backed by beans annotated with @Profile(FineractProfiles.TEST), the provider startup must include -Dspring.profiles.active=test. Without this, test-profile-only components are not loaded.
|
Wait for Fineract to be fully started. You can verify by checking:
curl -k https://localhost:8443/actuator/health
Step 2: Enable Business Date (if needed)
mysql -u root -pmysql fineract_default -e \
"UPDATE c_configuration SET enabled = 1 WHERE name = 'enable-business-date';"
Step 3: Run E2E Tests
Navigate to the E2E tests module:
cd fineract-e2e-tests-runner
Run All E2E Tests:
# First run with initialization
INITIALIZATION_ENABLED=true ../gradlew cucumber
# Subsequent runs without initialization (faster)
../gradlew cucumber
Run Specific Feature File:
../gradlew cucumber -Pcucumber.features="src/test/resources/features/Loan.feature"
Run Tests by Tag:
# Run only smoke tests
../gradlew cucumber -Pcucumber.tags="@Smoke"
# Run specific TestRail test
../gradlew cucumber -Pcucumber.tags="@TestRailId:C16"
# Run multiple tags
../gradlew cucumber -Pcucumber.tags="@Smoke and @TestRailId:C16"
Run Tests with Custom Configuration:
BASE_URL=http://localhost:8080 \
TEST_USERNAME=admin \
TEST_PASSWORD=admin123 \
INITIALIZATION_ENABLED=true \
../gradlew cucumber
Gradle Command Options
Basic Cucumber Task
../gradlew cucumber
Feature File Selection
# Single feature
../gradlew cucumber -Pcucumber.features="src/test/resources/features/Client.feature"
# Multiple features
../gradlew cucumber -Pcucumber.features="src/test/resources/features/Client.feature:src/test/resources/features/Loan.feature"
# Specific scenario by line number
../gradlew cucumber -Pcucumber.features="src/test/resources/features/Loan.feature:45"
Tag-Based Execution
# Single tag
../gradlew cucumber -Pcucumber.tags="@Smoke"
# Multiple tags (AND)
../gradlew cucumber -Pcucumber.tags="@Smoke and @TestRailId:C16"
# Multiple tags (OR)
../gradlew cucumber -Pcucumber.tags="@Smoke or @TestRailId:C16"
# Exclude tags
../gradlew cucumber -Pcucumber.tags="not @ignore"
# Complex tag expression
../gradlew cucumber -Pcucumber.tags="@Smoke and not @ignore"
Report Generation
# Generate HTML report
../gradlew cucumber -Dcucumber.plugin="pretty,html:build/cucumber-reports/cucumber.html"
# Generate JSON report
../gradlew cucumber -Dcucumber.plugin="json:build/cucumber-reports/cucumber.json"
# Multiple report formats
../gradlew cucumber -Dcucumber.plugin="pretty,html:build/cucumber-reports/cucumber.html,json:build/cucumber-reports/cucumber.json"
# Generate Allure report (comprehensive visual reporting)
../gradlew cucumber allureReport
After running tests with Allure, the report is available at:
fineract-e2e-tests-runner/build/reports/allure-report/index.html
Allure provides rich visual reports with test history, statistics, and detailed execution information. Open the index.html file in a browser to view the interactive report.
|
Clean and Run
# Clean previous test results and run
../gradlew clean cucumber
Advanced Execution Scenarios
Running Against Different Environment
# Against staging environment
BASE_URL=https://staging.example.com:8443 \
TEST_USERNAME=staging_user \
TEST_PASSWORD=staging_pass \
../gradlew cucumber
Running with External Event Verification
# Enable external event verification (requires ActiveMQ)
ACTIVEMQ_BROKER_URL=tcp://localhost:61616 \
ACTIVEMQ_BROKER_USERNAME=admin \
ACTIVEMQ_BROKER_PASSWORD=admin \
ACTIVEMQ_TOPIC_NAME=fineract-events \
EVENT_VERIFICATION_ENABLED=true \
../gradlew cucumber
Running with TestRail Integration
TESTRAIL_ENABLED=true \
TESTRAIL_BASEURL=https://testrail.example.com \
TESTRAIL_USERNAME=test@example.com \
TESTRAIL_PASSWORD=testrail_password \
TESTRAIL_RUN_ID=123 \
../gradlew cucumber
Test Development
Feature Coverage
The E2E tests cover the following functional domains:
Client Management
-
Client creation and management
-
Address management
-
Document management
-
Family member tracking
Loan Management
-
Loan application and approval
-
Disbursement
-
Repayment processing
-
Charges and fees
-
Advanced features: chargeback, charge-off, re-aging, re-amortization
-
Specialized loans: down payment, merchant-issued refund
Savings Account Management
-
Account opening and activation
-
Deposits and withdrawals
-
Interest calculation
-
Account closure
Accounting
-
Journal entry validation
-
Asset externalization
-
GL account mapping
Operational Processes
-
Close of Business (COB)
-
Inline COB
-
Business date management
-
Batch API operations
Writing New E2E Tests
When writing new Cucumber tests:
-
Create Feature File: Add new
.featurefile infineract-e2e-tests-runner/src/test/resources/features/ -
Use Gherkin Syntax:
Feature: Loan Disbursement Scenario: Successful loan disbursement Given A client named "John Doe" When Admin creates a loan for client with amount "1000" And Admin approves the loan And Admin disburses the loan on business date Then Loan status is "Active" And Loan outstanding balance is "1000" -
Implement Step Definitions: Add corresponding step definitions in
fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/ -
Add Tags: Tag scenarios appropriately:
@TestRailId:C1234 @Smoke Scenario: Critical loan test -
Verify with Gradle:
cd fineract-e2e-tests-runner ../gradlew cucumber -Pcucumber.features="src/test/resources/features/YourNewFeature.feature"
Troubleshooting
Common Test Failures
Connection Issues
Symptom: Tests fail with connection refused errors
Solutions:
# Verify Fineract is running
curl -k https://localhost:8443/actuator/health
# Check if port is in use
netstat -tulpn | grep 8443
# Check logs (logs go to console/stdout)
# If running in background, redirect output:
# ./gradlew bootRun > build/bootRun.log 2>&1 &
# tail -f build/bootRun.log
Business Date Issues
Symptom: Tests fail with "Business date functionality is not enabled"
Solutions:
# Enable Business Date
mysql -u root -pmysql fineract_default -e \
"UPDATE c_configuration SET enabled = 1 WHERE name = 'enable-business-date';"
# Verify
mysql -u root -pmysql fineract_default -e \
"SELECT * FROM c_configuration WHERE name = 'enable-business-date';"
Data Dependencies
Symptom: Tests fail due to missing products or charges
Solutions:
# Run with initialization enabled
cd fineract-e2e-tests-runner
INITIALIZATION_ENABLED=true ../gradlew cucumber
Authentication Failures
Symptom: 401 or 403 errors
Solutions:
# Verify credentials
TEST_USERNAME=mifos TEST_PASSWORD=password ../gradlew cucumber
# Check user permissions in database
mysql -u root -pmysql fineract_default -e \
"SELECT * FROM m_appuser WHERE username = 'mifos';"
Debugging Tips
Enable Detailed Logging
# Run with verbose output
../gradlew cucumber -Dcucumber.plugin="pretty" --info
# Save output to file
../gradlew cucumber > test-output.log 2>&1
Check Database State
# Check loan products after initialization
mysql -u root -pmysql fineract_default -e \
"SELECT id, product_name FROM m_product_loan LIMIT 20;"
# Check configurations
mysql -u root -pmysql fineract_default -e \
"SELECT name, enabled FROM c_configuration WHERE name LIKE '%enable%';"
View Test Reports
After test execution, reports are available in:
fineract-e2e-tests-runner/build/cucumber-reports/
fineract-e2e-tests-runner/build/reports/tests/
Best Practices
Test Organization
-
Keep feature files focused on specific business domains
-
Use descriptive scenario names
-
Include business context in scenario descriptions
-
Tag tests appropriately for organization and execution
Test Isolation
-
Each test should be independent
-
Don’t rely on state from other tests
-
Clean up test data in
@Afterhooks -
Use unique identifiers for test entities
Test Data Management
-
Use initialization for complex test data setup
-
Create minimal required data in test scenarios
-
Document test data dependencies
-
Reuse database for multiple test runs when possible
Performance Optimization
-
Run initialization once per database
-
Use tags to run subset of tests during development
-
Run full suite in CI/CD pipelines
-
Consider parallel execution for large test suites
Cucumber Cheatsheet
Cucumber is a test framework based on Behavior-Driven Development (BDD). Tests are written in plain text with very basic syntax rules. These rules form a mini language that is called Gherkin.
A specification resembles spoken language. This makes it ideal for use with non-technical people that have domain specific knowledge. The emphasis of Cucumber lies on finding examples to describe your test cases. The few keywords and language rules are easy to explain to anyone (compared JUnit for example).
Keywords
The Gherkin language has the following keywords:
-
Feature -
Rule -
Scenario OutlineorScenario Template -
ExampleorScenario -
ExamplesorScenarios -
Background -
Given -
And -
But -
When -
Then
There are a couple of additional signs used in Gherkin:
-
|is as column delimiters inExamplestables -
with
@you can assign any kind of tags to categorize the specs (or e.g. relate them to certain Jira tickets) -
#is used to indicate line comments
The tag @ignore is used to skip tests. This is a somewhat arbitrary choice (we could use any other tag to indicate temporarily disabled tests).
|
Each non-empty line of a test specification needs to start with one of these keywords. The text blocks that follows the keywords are mapped to so called step definitions that contain the actual test code.
A typical Cucumber test specification written in Gherkin looks like this:
Feature: Template Service
@template
Scenario Outline: Verify that mustache templates have expected results
Given A mustache template file <template>
Given A JSON data file <json>
When The user merges the template with data
Then The result should match the content of file <result>
Examples:
| template | json | result |
| hello.mustache | hello.json | hello.txt |
| loan.mustache | loan.json | loan.html |
| array.loop.mustache | array.json | array.loop.txt |
| array.index.mustache | array.json | array.index.txt |
The corresponding step definitions would look like this:
package org.apache.fineract.template.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.google.common.reflect.TypeToken;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import io.cucumber.java8.En;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.apache.commons.io.IOUtils;
import org.apache.fineract.template.data.TemplateData;
import org.springframework.beans.factory.annotation.Autowired;
public class TemplateServiceStepDefinitions implements En {
@Autowired
private TemplateMergeService tms;
private String template;
private Map<String, Object> data;
private String result;
public TemplateServiceStepDefinitions() {
Given("/^A mustache template file (.*)$/", (String file) -> {
template = IOUtils.resourceToString("templates/" + file, StandardCharsets.UTF_8,
TemplateServiceStepDefinitions.class.getClassLoader());
});
Given("/^A JSON data file (.*)$/", (String file) -> {
data = parse(IOUtils.resourceToString("templates/" + file, StandardCharsets.UTF_8,
TemplateServiceStepDefinitions.class.getClassLoader()));
});
When("The user merges the template with data", () -> {
result = compile(template, data);
});
Then("/^The result should match the content of file (.*)$/", (String file) -> {
String expected = IOUtils.resourceToString("results/" + file, StandardCharsets.UTF_8,
TemplateServiceStepDefinitions.class.getClassLoader());
assertEquals(expected, result);
});
}
private String compile(String templateText, Map<String, Object> scope) {
return tms.compile(TemplateData.builder().name("TemplateName").text(templateText).mappers(List.of()).build(), scope);
}
private Map<String, Object> parse(String data) {
Gson gson = new Gson();
Type ssMap = new TypeToken<Map<String, Object>>() {}.getType();
JsonElement json = JsonParser.parseString(data);
return gson.fromJson(json, ssMap);
}
}
This example is an actual test specification that you can find in the fineract-provider module.
|
Feature
This keyword is used to group scenarios and to group related scenarios. All Gherkin specifications must start with the word Feature.
Descriptions
A description is any non-empty line that doesn’t start with a keyword. Descriptions can be placed under the keywords:
-
Feature -
Rule -
Background -
Example/Scenario -
Scenario Outline
Rule
Rule is used to group multiple related scenarios together.
Example/Scenario
This is the important part of the specification as it should describe the business logic in more detail with the usage of steps (usually Given, When, Then)
Steps
TBD
Given
TBD
When
TBD
Then
TBD
And, But
TBD
Background
TBD
Scenario Outline
TBD
Examples/Tables
TBD
Outlook
As a proof of concept we’ve converted all unit tests in fineract-provider into Cucumber tests. The more interesting part starts when we’ll attack the integration tests with over 400 mostly business logic related tests. These tests fit very well in Cucumber’s test specification structure (a lot of if-then-else or in Gherkin: Given-When-Then). Migrating all tests will take a while, but we would already recommend trying to implement tests as Cucumber specifications. It should be relatively easy to convert these tests into the new syntax.
Hopefully this will motivate even more people from the broader Fineract community to participate in the project by sharing their domain specific knowledge as Cucumber specifications. Specifications are written in English (although not a technical requirement).
Have a look at the specifications in fineract-provider for an initial inspiration. For more information please see cucumber.io/docs
|
Unit Testing
TBD
Integration Testing
Integration tests in Apache Fineract validate the complete API layer and business logic by making HTTP calls to a running Fineract instance. These tests ensure that different components work together correctly and that the API behaves as expected.
Overview
Architecture
-
Location:
integration-tests/src/test/java/org/apache/fineract/integrationtests -
Framework: JUnit 5 with REST Assured for HTTP communication
-
Base Class: Most tests extend
BaseLoanIntegrationTestorIntegrationTest -
Client Library: Uses Fineract client library for type-safe API interactions
-
Prerequisites: Running Apache Fineract instance (default: localhost:8443)
Key Characteristics
-
Tests run against a live Fineract instance
-
Validates end-to-end API functionality
-
Tests business logic, validation rules, and workflows
-
Includes accounting verification and data integrity checks
-
Uses real database transactions
-
Tests can be run individually or as a suite
Prerequisites
Required Software
-
Java 21: Apache Fineract requires Java 21 (Azul Zulu JDK recommended)
-
Database: MariaDB 12.2, PostgreSQL 18.3, or MySQL 9.1
-
Git: For source code management
-
Gradle 8.14.3: Included via wrapper
-
12GB RAM: Recommended for test execution
Database Setup
Before running integration tests, ensure the databases are created:
# Create required databases
./gradlew createDB -PdbName=fineract_tenants
./gradlew createDB -PdbName=fineract_default
Fineract Instance
Integration tests run fineract instance from cargo plugin by default.
Configuration
Default Connection Settings
Integration tests use the following default connection settings:
BACKEND_PROTOCOL=https
BACKEND_HOST=localhost
BACKEND_PORT=8443
BACKEND_USERNAME=mifos
BACKEND_PASSWORD=password
BACKEND_TENANT=default
Override Configuration
To override default values, set environment variables:
# Set custom connection details
export BACKEND_PROTOCOL=http
export BACKEND_HOST=localhost
export BACKEND_PORT=8080
export BACKEND_USERNAME=admin
export BACKEND_PASSWORD=admin123
export BACKEND_TENANT=default
Running Integration Tests
Complete Workflow
Step 1: Start Fineract
# Start Fineract in background
./gradlew bootRun &
# Wait for startup (manual check)
curl -k https://localhost:8443/actuator/health
Expected response:
{"status":"UP"}
Step 2: Run Integration Tests
Navigate to the project root and execute tests:
# Run all integration tests
./gradlew :integration-tests:test
# Run with clean build
./gradlew clean :integration-tests:test
Running Specific Tests
Run Single Test Class
# Run entire test class
./gradlew :integration-tests:test --tests ClientLoanIntegrationTest
# Run with verbose output
./gradlew :integration-tests:test --tests ClientLoanIntegrationTest --info
Run Specific Test Method
# Run single test method
./gradlew :integration-tests:test --tests ClientLoanIntegrationTest.testLoanSchedule
# Run multiple specific tests
./gradlew :integration-tests:test --tests ClientLoanIntegrationTest.testLoanSchedule \
--tests ClientLoanIntegrationTest.testLoanRepayment
Run Tests by Pattern
# Run all loan-related tests
./gradlew :integration-tests:test --tests "*Loan*"
# Run all client-related tests
./gradlew :integration-tests:test --tests "*Client*"
# Run all accounting tests
./gradlew :integration-tests:test --tests "*Accounting*"
# Run all COB tests
./gradlew :integration-tests:test --tests "*COB*"
Advanced Test Execution
Run with Test Filtering
# Run tests excluding specific packages
./gradlew :integration-tests:test --tests "*" \
--exclude "*Deprecated*"
# Run only fast tests (custom tag)
./gradlew :integration-tests:test --tests "*Fast*"
Parallel Execution
# Run tests in parallel
./gradlew :integration-tests:test --parallel --max-workers=4
# Set custom thread count
./gradlew :integration-tests:test --parallel --max-workers=8
| Some integration tests may have dependencies on shared state. Use parallel execution carefully and ensure tests are properly isolated. |
Run with Custom JVM Arguments
# Increase heap size for large test suites
./gradlew :integration-tests:test -Xmx4g
# Enable debugging
./gradlew :integration-tests:test --debug-jvm
Generate Test Reports
# Run tests and generate HTML reports
./gradlew :integration-tests:test
# Reports are generated at:
# integration-tests/build/reports/tests/test/index.html
Continuous Execution
# Watch for changes and re-run tests
./gradlew :integration-tests:test --continuous
# Run specific test continuously
./gradlew :integration-tests:test --continuous --tests ClientLoanIntegrationTest
Test Execution Examples
Basic Loan Workflow Test
# Test complete loan lifecycle
./gradlew :integration-tests:test --tests LoanApplicationTest
Progressive Loan Tests
# Run all progressive loan tests
./gradlew :integration-tests:test --tests "*Progressive*"
Accounting Integration Tests
# Run accounting-related tests
./gradlew :integration-tests:test --tests AccountingScenarioIntegrationTest
Business Date Tests
# Run business date functionality tests
./gradlew :integration-tests:test --tests BusinessDateTest
Charge-Off Tests
# Run charge-off related tests
./gradlew :integration-tests:test --tests "*ChargeOff*"
Test Structure
BaseLoanIntegrationTest Overview
BaseLoanIntegrationTest is the comprehensive base test class for loan-related integration tests. It provides:
Pre-configured Loan Product Creation
// Create standard loan products
createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct()
create4IProgressive() // Progressive loan products
create4IProgressiveWithCapitalizedIncome() // With capitalized income
createOnePeriod30DaysPeriodicAccrualProductWithAdvancedPaymentAllocation()
Transaction Management
// Validate loan transactions
verifyTransactions(loanId,
transaction(100.0, "Disbursement", "01 January 2024"),
transaction(50.0, "Repayment", "15 January 2024")
);
// Verify accounting journal entries
verifyJournalEntries(loanId, expectedEntries);
// Create transaction test data
Transaction txn = transaction(amount, type, date);
Loan Lifecycle Operations
// Disburse loan
disburseLoan(loanId, BigDecimal.valueOf(100), "01 January 2024");
// Undo disbursement
undoDisbursement(loanId);
// Re-age loan
reAgeLoan(loanId, reAgeRequest);
// Re-amortize loan
reAmortizeLoan(loanId, reAmortizeRequest);
// Execute Close of Business
executeInlineCOB(loanId);
Business Date Management
// Execute code at specific business date
runAt("01 January 2024", () -> {
Long loanId = applyAndApproveProgressiveLoan(...);
disburseLoan(loanId, BigDecimal.valueOf(100), "01 January 2024");
});
// Execute over date range
runFromToInclusive("01 January 2024", "31 January 2024", () -> {
// Operations for each date in the range
});
// Execute without bypass privileges
runAsNonByPass(() -> {
// Test operations with regular user permissions
});
Verification Methods
// Validate repayment schedule
verifyRepaymentSchedule(loanId, expectedSchedule);
// Check loan status
verifyLoanStatus(loanId, "ACTIVE");
// Verify outstanding amounts
verifyOutstanding(loanId, expectedOutstanding);
// Check arrears status
verifyArrears(loanId, expectedArrears);
Common Test Patterns
Test Setup Pattern
@BeforeEach
public void setup() {
Utils.initializeRESTAssured();
this.requestSpec = new RequestSpecBuilder()
.setContentType(ContentType.JSON)
.build();
this.requestSpec.header("Authorization", "Basic " +
Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
this.responseSpec = new ResponseSpecBuilder()
.expectStatusCode(200)
.build();
}
Date-Specific Operations
runAt("01 January 2024", () -> {
// Create client
Long clientId = clientHelper.createClient(...);
// Apply for loan
Long loanId = applyLoan(clientId, productId, amount);
// Approve loan
approveLoan(loanId, "01 January 2024");
// Disburse loan
disburseLoan(loanId, amount, "01 January 2024");
});
Structured Verification
// Verify transactions
verifyTransactions(loanId,
transaction(100.0, "Disbursement", "01 January 2024"),
transaction(50.0, "Capitalized Income", "01 January 2024"),
transaction(0.55, "Capitalized Income Amortization", "01 January 2024")
);
// Verify journal entries using convenience methods
verifyJournalEntries(loanId,
debit(account.getLoansReceivable(), 100.0),
credit(account.getSuspenseClearingAccount(), 100.0)
);
// Or using full journalEntry method with Account objects
verifyJournalEntries(loanId,
journalEntry(100.0, account.getLoansReceivable(), "DEBIT"),
journalEntry(100.0, account.getSuspenseClearingAccount(), "CREDIT")
);
Progressive Loan Testing
// Create progressive loan product
Long productId = create4IProgressive();
// Apply and approve
Long loanId = applyAndApproveProgressiveLoan(clientId, productId,
amount, numberOfRepayments, interestRate);
// Test advanced features
testCapitalizedIncome(loanId);
testDownPayment(loanId);
testAdvancedPaymentAllocation(loanId);
Writing Integration Tests
Test Development Guidelines
1. Create Test Class
Create a new test class in integration-tests/src/test/java/org/apache/fineract/integrationtests/:
package org.apache.fineract.integrationtests;
import org.apache.fineract.integrationtests.common.Utils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
public class MyNewFeatureIntegrationTest extends BaseLoanIntegrationTest {
@BeforeEach
public void setup() {
Utils.initializeRESTAssured();
this.requestSpec = new RequestSpecBuilder()
.setContentType(ContentType.JSON)
.build();
this.requestSpec.header("Authorization", "Basic " +
Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
this.responseSpec = new ResponseSpecBuilder()
.expectStatusCode(200)
.build();
}
@Test
public void testMyNewFeature() {
// Test implementation
}
}
2. Use Helper Classes
Leverage existing helper classes:
// Client operations
ClientHelper clientHelper = new ClientHelper(requestSpec, responseSpec);
Long clientId = clientHelper.createClient(...);
// Loan operations
LoanTransactionHelper loanHelper = new LoanTransactionHelper(requestSpec, responseSpec);
Long loanId = loanHelper.applyLoan(...);
// Account operations
AccountHelper accountHelper = new AccountHelper(requestSpec, responseSpec);
// Business date operations
BusinessDateHelper businessDateHelper = new BusinessDateHelper();
businessDateHelper.updateBusinessDate(...);
// COB operations
InlineLoanCOBHelper cobHelper = new InlineLoanCOBHelper(requestSpec, responseSpec);
cobHelper.executeInlineCOB(loanId);
3. Follow Best Practices
-
Self-Contained Tests: Each test should be independent
-
Clear Setup: Use
@BeforeEachfor test initialization -
Date Management: Use
runAt()for consistent date-based testing -
Comprehensive Verification: Verify transactions, schedules, and accounting
-
Helper Methods: Use provided helper classes rather than direct API calls
-
Error Testing: Test both positive and negative scenarios
-
Cleanup: Clean up test data when necessary
4. Test Complex Scenarios
@Test
public void testLoanWithMultipleDisbursements() {
runAt("01 January 2024", () -> {
// Create client
Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest());
// Create multi-disbursement loan product
Long productId = createMultiDisbursementProduct();
// Apply for loan
Long loanId = applyAndApproveProgressiveLoan(clientId, productId,
BigDecimal.valueOf(1000), 12, BigDecimal.valueOf(10));
// First disbursement
disburseLoan(loanId, BigDecimal.valueOf(500), "01 January 2024");
// Verify first disbursement
verifyTransactions(loanId,
transaction(500.0, "Disbursement", "01 January 2024")
);
});
runAt("15 January 2024", () -> {
// Second disbursement
disburseLoan(loanId, BigDecimal.valueOf(500), "15 January 2024");
// Verify both disbursements
verifyTransactions(loanId,
transaction(500.0, "Disbursement", "01 January 2024"),
transaction(500.0, "Disbursement", "15 January 2024")
);
// Verify outstanding balance
verifyOutstanding(loanId, BigDecimal.valueOf(1000));
});
}
5. Run and Verify
# Run your new test
./gradlew :integration-tests:test --tests MyNewFeatureIntegrationTest
# Run with verbose output for debugging
./gradlew :integration-tests:test --tests MyNewFeatureIntegrationTest --info
# Run specific test method
./gradlew :integration-tests:test --tests MyNewFeatureIntegrationTest.testMyNewFeature
Troubleshooting
Common Test Failures
Connection Issues
Symptom: Tests fail with connection refused errors
Solutions:
# Verify Fineract is running
curl -k https://localhost:8443/actuator/health
# Check if port is available
netstat -tulpn | grep 8443
# Check Fineract logs (logs go to console/stdout)
# If running in background with output redirection:
# tail -f build/bootRun.log
# Restart Fineract if needed
pkill -f bootRun
./gradlew bootRun &
Authentication Failures
Symptom: Tests fail with 401 or 403 errors
Solutions:
# Check default credentials
mysql -u root -pmysql fineract_default -e \
"SELECT username, password FROM m_appuser WHERE username = 'mifos';"
# Reset credentials if needed
mysql -u root -pmysql fineract_default -e \
"UPDATE m_appuser SET password = '5jdQ3dNQXHPzCuBbZVdQZ2XnVlPc3l2l' \
WHERE username = 'mifos';"
# Verify connection settings
echo "Protocol: ${BACKEND_PROTOCOL:-https}"
echo "Host: ${BACKEND_HOST:-localhost}"
echo "Port: ${BACKEND_PORT:-8443}"
Data Inconsistency
Symptom: Tests fail due to unexpected data state
Solutions:
# Reset database
mysql -u root -pmysql -e "DROP DATABASE fineract_default;"
mysql -u root -pmysql -e "DROP DATABASE fineract_tenants;"
# Recreate databases
./gradlew createDB -PdbName=fineract_tenants
./gradlew createDB -PdbName=fineract_default
# Restart Fineract
pkill -f bootRun
./gradlew bootRun &
Test Timeout
Symptom: Tests hang or timeout
Solutions:
# Increase test timeout
./gradlew :integration-tests:test -Dtest.timeout=600
# Check for database locks
mysql -u root -pmysql fineract_default -e "SHOW PROCESSLIST;"
# Kill long-running queries
mysql -u root -pmysql fineract_default -e "KILL <process_id>;"
Memory Issues
Symptom: OutOfMemoryError during test execution
Solutions:
# Increase heap size
./gradlew :integration-tests:test -Xmx4g -Xms2g
# Run fewer tests in parallel
./gradlew :integration-tests:test --max-workers=2
# Clean build directory
./gradlew clean
Debugging Tips
Enable Detailed Logging
# Run with debug output
./gradlew :integration-tests:test --debug
# Run with info level
./gradlew :integration-tests:test --info
# Save output to file
./gradlew :integration-tests:test --info > test-output.log 2>&1
Check Test Reports
After test execution, detailed reports are available:
# HTML report
integration-tests/build/reports/tests/test/index.html
# XML reports (for CI/CD)
integration-tests/build/test-results/test/
# Gradle scan (upload for detailed analysis)
Generate Gradle build scan:
./gradlew :integration-tests:test --scan
Database State Verification
# Check loan status
mysql -u root -pmysql fineract_default -e \
"SELECT id, account_no, loan_status_id, principal_amount \
FROM m_loan ORDER BY id DESC LIMIT 10;"
# Check transactions
mysql -u root -pmysql fineract_default -e \
"SELECT loan_id, transaction_type_enum, amount, transaction_date \
FROM m_loan_transaction WHERE loan_id = <loan_id>;"
# Check journal entries
mysql -u root -pmysql fineract_default -e \
"SELECT entry_date, account_id, type_enum, amount \
FROM acc_gl_journal_entry WHERE loan_id = <loan_id>;"
# Check configurations
mysql -u root -pmysql fineract_default -e \
"SELECT name, enabled FROM c_configuration \
WHERE name LIKE '%business%' OR name LIKE '%enable%';"
API Response Debugging
Add logging to test methods:
Response response = loanHelper.applyLoan(...);
System.out.println("Response: " + response.asString());
System.out.println("Status Code: " + response.getStatusCode());
// Or use logger
log.info("Response: {}", response.asString());
Isolate Failing Tests
# Run only the failing test
./gradlew :integration-tests:test --tests FailingTest --info
# Run with rerun-tasks option
./gradlew :integration-tests:test --tests FailingTest --rerun-tasks
# Run with fail-fast to stop on first failure
./gradlew :integration-tests:test --fail-fast
Best Practices
Test Organization
-
Extend appropriate base classes (
BaseLoanIntegrationTest,IntegrationTest) -
Use descriptive test method names that explain what is being tested
-
Group related tests in the same test class
-
Use
@BeforeEachfor setup and@AfterEachfor cleanup -
Follow existing naming conventions
Test Isolation
-
Each test should be independent and not rely on other tests
-
Create fresh test data for each test
-
Clean up test data after test execution
-
Use unique identifiers to avoid conflicts
-
Don’t share mutable state between tests
Performance Optimization
-
Reuse Fineract instance across test runs
-
Use
runAt()for efficient date management -
Minimize unnecessary API calls
-
Use bulk operations when appropriate
-
Consider parallel execution for independent tests
-
Run subset of tests during development
Code Quality
-
Follow existing code patterns and conventions
-
Use helper methods instead of duplicating code
-
Add comments for complex business logic
-
Verify both positive and negative scenarios
-
Include edge cases in test coverage
-
Document test assumptions and prerequisites
Comprehensive Verification
-
Always verify transaction creation
-
Check accounting journal entries
-
Validate repayment schedules
-
Verify loan status transitions
-
Test charge applications
-
Validate business date handling
-
Check error messages for validation failures
Maintenance
-
Update tests when API changes
-
Remove deprecated test methods
-
Keep test data realistic
-
Document complex test scenarios
-
Review and refactor tests regularly
-
Keep tests aligned with current best practices
Fineract Documentation Guide
TBD
File and Folder Layout
- The general rules are
-
-
keep things as flat as possible (avoid sub-folders as much as possible)
-
DRY (don’t repeat yourself): don’t copy and paste code pieces, use AsciiDoc’s include feature and reference files/-sections from the project folder
-
images are located in
fineract-doc/src/docs/en/images(or sub-folders) -
diagrams are located in
fineract-doc/src/docs/en/diagrams(or sub-folders) -
specific chapters are located in
fineract-doc/src/docs/en/chapters -
every chapter has its own folder and at least one
index.adocfile -
it’s recommended to keep the chapters flat (i. e. no sub-folders in the chapter folders)
-
it’s recommended to create one file per chapter section; like that you can re-arrange sections very easily in the
index.adocfile
-
| These rules are not entirely set in stone and could be modified if necessary. If you see any issues then please report them on the mailing list or open a Jira ticket. |
AsciiDoc
Cheatsheet
You can find the definitive manual on AsciiDoc syntax at AsciiDoc documentation. To help people get started, however, here is a simpler cheat sheet.
AsciiDoc vs Asciidoctor (format vs tool)
When we refer to AsciiDoc then we mean the language or format that this documentation is written in. AsciiDoc is a markup language similar to Markdown (but more powerful and expressive) designed for technical documentation. You don’t need necessarily any specialized editors or tools to write your documentation in AsciiDoc, a plain text editor will do, but there are plenty of choices that give you a better experience (in this documentation we describe the basic usage with AsciiDoc plugins for IntelliJ, Eclipse and VSCode).
Asciidoctor on the other hand is the command line tool we use to transform documents written in AsciiDoc into HTML and PDF (Epub3 and Docbook are also available). There are three variants available:
-
Asciidoctor (written in Ruby)
-
Asciidoctor.js (written in JavaScript, often used for browser previews)
-
AsciidoctorJ (Java lib that integrates the Ruby implementation via JRuby, e. g. the Asciidoctor Gradle plugin is based on that)
| Sometimes you will still find documentation related to the original incarnation of AsciiDoc/tor (written in Python). The format evolved quite a bit since then and the tools try to maintain a certain degree of backward compatibility, but there is no guarantee. We prefer to use the latest language specs as documented here. |
Basic AsciiDoc Syntax
Bold
Put asterisks around text to make it bold.
| More info at docs.asciidoctor.org/asciidoc/latest/text/bold |
Italics
Use underlines on either side of a string to put text into italics.
| More info at docs.asciidoctor.org/asciidoc/latest/text/italic |
Headings
Equal signs (=) are used for heading levels. Each equal sign is a level. Each page can only have one top level (i.e., only one section with a single =).
Levels should be appropriately nested. During the build, validation occurs to ensure that level 3s are preceded by level 2s, level 4s are preceded by level 3s, etc. Including out-of-sequence heading levels (such as a level 3 then a level 5) will not fail the build, but will produce an error.
Code Examples
Use backticks ` for text that should be monospaced, such as code or a class name in the body of a paragraph.
| More info at docs.asciidoctor.org/asciidoc/latest/text/monospace/ |
Longer code examples can be separated from text with source blocks.
These allow defining the syntax being used so the code is properly highlighted.
[source,xml]
<field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false" />
If your code block will include line breaks, put 4 hyphens (----) before and after the entire block.
Source Block Syntax Highlighting
The HTML output uses Rouge to add syntax highlighting to code examples. This is done by adding the language of the code block after the source, as shown in the above example source block (xml in that case).
Rouge has a long selection of lexers available. You can see the full list at github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers. Use one of the valid short names to get syntax highlighting for that language.
Ideally, we will have an appropriate lexer to use for all source blocks, but that’s not possible.
When in doubt, choose text, or leave it blank.
Importing Code Snippets from Other Files
The build system has the ability to "include" snippets located in other files — even non-AsciiDoc files such as *.java source code files.
We’ve configured a global attribute called {rootdir} that you can use to reference these files consistently from Fineract’s project root folder.
Snippets are bounded by tag comments placed at the start and end of the section you would like to import. Opening tags look like: // tag::snippetName[]. Closing tags follow the format: // end::snippetName[].
Snippets can be inserted into an .adoc file using an include directive, following the format: include::{rootdir}/<directory-under-root-folder>/<file-name>[tag=snippetName].
| You could also use relative paths to reference include files, but it is preferred to always use the root folder as a starting point. Like this you can be sure that the preview in your editor of choice works. |
For example, if we wanted to highlight a specific section of the following Cucumber test definition (more on that in section Cucumber Testing) ClasspathDuplicatesStepDefinitions.java file located under fineract-provider/src/test/java/org/apache/fineract/infrastructure/classpath/.
[source,java,indent=0]
----
include::{rootdir}/fineract-provider/src/test/java/org/apache/fineract/infrastructure/classpath/ClasspathDuplicatesStepDefinitions.java[tag=then]
----
For more information on the include directive, see the documentation at docs.asciidoctor.org/asciidoc/latest/directives/include.
Block Titles
Titles can be added to most blocks (images, source blocks, tables, etc.) by simply prefacing the title with a period (.). For example, to add a title to the source block example above:
.Example ID field
[source,xml]
<field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false" />
Links
Link to Sites on the Internet
When converting content to HTML, Asciidoctor will automatically render many link types (such as http: and mailto:) without any additional syntax. However, you can add a name to a link by adding the URI followed by square brackets:
http://fineract.apache.org/[Fineract Website]
Link to Other Pages/Sections of the Guide
A warning up front, linking to other pages can be a little painful. There are slightly different rules depending on the type of link you want to create, and where you are linking from. The build process includes a validation for internal or inter-page links, so if you can build the docs locally, you can use that to verify you constructed your link properly. With all the below examples, you can add text to display as the link title by putting the display text in brackets after the link, as in:
xref:indexing-guide:schema-api.adoc#modify-the-schema[Modify the Schema]
You can also use the title of the Page or Section you are linking to by using an empty display text.
This is useful in case the title of the page or section changes. In that case you won’t need to change the display text for every link that refers to that page/section.
See an example below:
xref:indexing-guide:schema-api.adoc#modify-the-schema[]
To link to an anchor (or section title) on the same page, you can simply use double angle brackets (<< >>) around the anchor/heading/section title you want to link to. Any section title (a heading that starts with equal signs) automatically becomes an anchor during conversion and is available for deep linking.
- Example
-
If I have a section on a page that looks like this (from
process.adoc):== Steps Common parameters for all steps are:To link to this section from another part of the same
process.adocpage, I simply need to put the section title in double angle brackets, as in:See also the <<Steps>> section.The section title will be used as the display text; to customize that add a comma after the the section title, then the text you want used for display.
When linking to any section (on the same page or another one), you must also be aware of any pre-defined anchors that may be in use (these will be in double brackets, like [[ ]]).
When the page is converted, those will be the references your link needs to point to.
- Example
-
Take this example from
configsets-api.adoc:[[configsets-create]] == Create a ConfigSetTo link to this section, there are two approaches depending on where you are linking from:
-
From the same page, simply use the anchor name:
<<configsets-create>>. -
From another page, use the page name and the anchor name:
xref:configuration-guide:configsets-api.adoc#configsets-create[].
-
To link to another page or a section on another page, you must refer to the full filename and refer to the section you want to link to.
When you want to refer the reader to another page without deep-linking to a section, Asciidoctor allows this by merely omitting the # and section id.
- Example
-
To construct a link to the
process.adocpage, we need to refer to the file name (process.adoc), as well as the module that the file resides in (release/).It’s preferred to also always use the page name to give the reader better context for where the link goes.
As in:For more about upgrades, see xref:release:process.adoc[Fineract Release Process].
If the page that contains the link and the page being linked to reside in the same module, there is no need to include the module name after xref:
- Example
-
To construct a link to the
process-step01.adocpage fromprocess.adocpage, we do not need to include the module name because they both reside in theupgrade-notesmodule.For more information on the first step of the release process, see the section \xref:process-step01.adoc[].
Linking to a section is the same conceptually as linking to the top of a page, you just need to take a little extra care to format the anchor ID in your link reference properly.
When you link to a section on another page, you must make a simple conversion of the title into the format of the section ID that will be created during the conversion. These are the rules that transform the sections:
- Example
-
TBD
Ordered and Unordered Lists
AsciiDoc supports three types of lists:
-
Unordered lists
-
Ordered lists
-
Labeled lists
Each type of list can be mixed with the other types. So, you could have an ordered list inside a labeled list if necessary.
Unordered Lists
Simple bulleted lists need each line to start with an asterisk (*). It should be the first character of the line, and be followed by a space.
| More info at docs.asciidoctor.org/asciidoc/latest/lists/unordered |
Ordered Lists
Numbered lists need each line to start with a period (.). It should be the first character of the line, and be followed by a space. This style is preferred over manually numbering your list.
| More info at docs.asciidoctor.org/asciidoc/latest/lists/ordered |
Description Lists
These are like question & answer lists or glossary definitions.
Each line should start with the list item followed by double colons (::), then a space or new line. Labeled lists can be nested by adding an additional colon (such as :::, etc.). If your content will span multiple paragraphs or include source blocks, etc., you will want to add a plus sign (+) to keep the sections together for your reader.
| We prefer this style of list for parameters because it allows more freedom in how you present the details for each parameter. For example, it supports ordered or unordered lists inside it automatically, and you can include multiple paragraphs and source blocks without trying to cram them into a smaller table cell. |
Images
There are two ways to include an image: inline or as a block. Inline images are those where text will flow around the image. Block images are those that appear on their own line, set off from any other text on the page. Both approaches use the image tag before the image filename, but the number of colons after image define if it is inline or a block. Inline images use one colon (image:), while block images use two colons (image::). Block images automatically include a caption label and a number (such as Figure 1). If a block image includes a title, it will be included as the text of the caption. Optional attributes allow you to set the alt text, the size of the image, if it should be a link, float and alignment. We have defined a global attribute {imagesdir} to standardize the location for all images (fineract-doc/src/docs/en/images).
| More info at docs.asciidoctor.org/asciidoc/latest/macros/images |
Tables
Tables can be complex, but it is pretty easy to make a basic table that fits most needs.
Basic Tables
The basic structure of a table is similar to Markdown, with pipes (|) delimiting columns between rows:
|===
| col 1 row 1 | col 2 row 1|
| col 1 row 2 | col 2 row 2|
|===
Note the use of |=== at the start and end. For basic tables that’s not exactly required, but it does help to delimit the start and end of the table in case you accidentally introduce (or maybe prefer) spaces between the rows.
Header Rows
To add a header to a table, you need only set the header attribute at the start of the table:
[options="header"]
|===
| header col 1 | header col 2|
| col 1 row 1 | col 2 row 1|
| col 1 row 2 | col 2 row 2|
|===
Defining Column Styles
If you need to define specific styles to all rows in a column, you can do so with the attributes.
This example will center all content in all rows:
[cols="2*^" options="header"]
|===
| header col 1 | header col 2|
| col 1 row 1 | col 2 row 1|
| col 1 row 2 | col 2 row 2|
|===
Alignments or any other styles can be applied only to a specific column. For example, this would only center the last column of the table:
[cols="2*,^" options="header"]
|===
| header col 1 | header col 2|
| col 1 row 1 | col 2 row 1|
| col 1 row 2 | col 2 row 2|
|===
|
Many more examples of formatting:
|
More Options
Tables can also be given footer rows, borders, and captions. You can determine the width of columns, or the width of the table as a whole.
CSV or DSV can also be used instead of formatting the data in pipes.
Admonitions (Notes, Warnings)
AsciiDoc supports several types of callout boxes, called "admonitions":
-
NOTE
-
TIP
-
IMPORTANT
-
CAUTION
-
WARNING
It is enough to start a paragraph with one of these words followed by a colon (such as NOTE:). When it is converted to HTML, those sections will be formatted properly - indented from the main text and showing an icon inline.
You can add titles to admonitions by making it an admonition block. The structure of an admonition block is like this:
.Title of Note
[NOTE]
====
Text of note
====
In this example, the type of admonition is included in square brackets ([NOTE]), and the title is prefixed with a period. Four equal signs give the start and end points of the note text (which can include new lines, lists, code examples, etc.).
STEM Notation Support
We have set up the Ref Guide to be able to support STEM notation whenever it’s needed.
The AsciiMath syntax is supported by default, but LaTeX syntax is also available.
To insert a mathematical formula inline with your text, you can simply write:
stem:[a//b]
MathJax.js will render the formula as proper mathematical notation when a user loads the page. When the above example is converted to HTML, it will look like this to a user: \$a//b\$
To insert LaTeX, preface the formula with latexmath instead of stem:
latexmath:[tp \leq 1 - (1 - sim^{rows})^{bands}]
Long formulas, or formulas which should to be set off from the main text, can use the block syntax prefaced by stem or latexmath:
[stem]
++++
sqrt(3x-1)+(1+x)^2 < y
++++
or for LaTeX:
[latexmath]
++++
[tp \leq 1 - (1 - sim^{rows})^{bands}]
++++
| More info at docs.asciidoctor.org/asciidoc/latest/stem/stem |
Releases
This chapter explains how we make the source code into an official release available on fineract.apache.org.
Be release ready and reduce release time by weeks. Keep incomplete features off develop and fix the build as soon as it breaks. If develop is kept release ready, this 17 day timeline can be reduced to about a week: Skip the "ca. 2 weeks" indicated in Step 3: Create Release Branch.
|
Configuration
Before you can start using the Fineract release plugin to create releases you have to configure and setup a couple of things first.
-
All official communication concerning releases happens on the mailing list. Every release manager needs to be a member of and engaging on the mailing list for credibility.
-
Make sure you have edit permissions on the Apache Confluence Wiki
-
You need full permissions on Apache JIRA to be able to move issues to the next release
-
Git committer privileges to be allowed to create tags and the release branch, and to upload release candidates to ASF’s distribution dev (staging) area
-
Familiarity with building Fineract locally and creating release distributions is required
-
You need to be a member of the PMC to be able to upload release artifacts to ASF’s distribution release area; this task can be delegated though
-
A general Familiarity with PGP/GPG is recommended (at least to setup your keypairs), but the release plugin does most of the heavy lifting
-
Make sure to read the release plugin documentation for troubleshooting
-
Read, understand, and follow everything listed at www.apache.org/dev/#releases. It helps to pair with someone who has previously done a release.
Secrets
TBD
Infrastructure Team
A couple of secrets for third party services are automatically configured by the infrastructure team at The Apache Foundation for the Fineract Github account. At the moment this includes environment variables for:
-
Github token (e. g. to publish Github Pages, use the Github API in Github Actions)
-
Docker Hub token (to publish our Docker images)
-
Sonar Cloud token (for our code quality reports)
See also:
Lastpass
It seems that Apache has some kind of org account or similar. Popped up a couple of times in the infrastructure documentation.
TBD
1Password
Other Fineract development related secrets, e. g. for deployments of demo systems on Google Cloud, AWS etc. are managed in a team account at 1Password. At the moment the following committers are members of the 1Password team account:
| If you need access or have any questions related to those secrets then please reach out to one of the team members. |
GPG
Generate GPG key pairs if you don’t already have them and publish them. Please use your Apache email address when creating your GPG keypair. If you already have configured GPG and associated your keypair with a non-Apache email address then please consider creating a separate one just for all things related to Fineract (or Apache in general).
Instructions:
-
Check your GPG version:
Input GPG versiongpg --versionOutput GPG versiongpg (GnuPG) 2.4.4 libgcrypt 1.10.3 Copyright (C) 2024 g10 Code GmbH License GNU GPL-3.0-or-later <https://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Home: /home/aleks/.gnupg Supported algorithms: Pubkey: RSA, ELG, DSA, ECDH, ECDSA, EDDSA Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH, CAMELLIA128, CAMELLIA192, CAMELLIA256 Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224 Compression: Uncompressed, ZIP, ZLIB, BZIP2The insecure hash algorithm SHA1 is still supported in version 2.4.4. SHA1 is obsolete and you don’t want to use it to generate your signature. -
Generate your GPG key pair:
Input generate GPG key pairgpg --full-gen-keyOutput generate GPG key pair (step 1: key type selection)Please select what kind of key you want: (1) RSA and RSA (2) DSA and Elgamal (3) DSA (sign only) (4) RSA (sign only) (9) ECC (sign and encrypt) *default* (10) ECC (sign only) (14) Existing key from card Your selection?Choose the default.
Output generate GPG key pair (step 2: elliptic curve selection)Please select which elliptic curve you want: (1) Curve 25519 *default* (4) NIST P-384 (6) Brainpool P-256 Your selection?Again, choose the default.
Output generate GPG key pair (step 3: validity selection)Please specify how long the key should be valid. 0 = key does not expire <n> = key expires in n days <n>w = key expires in n weeks <n>m = key expires in n months <n>y = key expires in n years Key is valid for? (0) 2y2 years for the validity of your keys should be fine. You can always update the expiration time later on.
Output generate GPG key pair (step 4: confirmation)Key expires at Sun 16 Apr 2024 08:10:24 PM UTC Is this correct? (y/N) yConfirm if everything is correct.
Output generate GPG key pair (step 5: provide user details)GnuPG needs to construct a user ID to identify your key. Real name: Aleksandar Vidakovic Email address: aleks@apache.org Comment:Provide your user details for the key. This is important because this information will be included in our key. It’s one way of indicating who is owner of this key. The email address is a unique identifier for a person. You can leave Comment blank.
Output generate GPG key pair (step 6: user ID selection)You selected this USER-ID: "Aleksandar Vidakovic <aleks@apache.org>" Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? OSelect
Okay.After the selection of your user ID GPG will ask for a passphrase to protect your private key. Maybe time to open your password manager and generate a secure one and save it in your vault. Once you’ve confirmed your password GPG will start to generate your keys.
Don’t lose your private key password. You won’t be able to unlock and use your private key without it. Output generate GPG key pair (step 7: gpg key pair generation)We need to generate a lot of random bytes. It is a good idea to perform some other action (type on the keyboard, move the mouse, utilize the disks) during the prime generation; this gives the random number generator a better chance to gain enough entropy.Generating the GPG keys will take a while.
Output generate GPG key pair (step 8: gpg key pair finished)gpg: key 7890ABCD marked as ultimately trusted (1) gpg: directory '/home/aleks/.gnupg/openpgp-revocs.d' created gpg: revocation certificate stored as '/home/aleks/.gnupg/openpgp-revocs.d/ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCD.rev' (2) public and secret key created and signed. gpg: checking the trustdb gpg: marginals needed: 3 completes needed: 1 trust model: PGP gpg: depth: 0 valid: 1 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 1u gpg: next trustdb check due at 2024-04-16 pub ed25519/7890ABCD 2022-04-16 [SC] [expires: 2024-04-16] (3) Key fingerprint = ABCD EFGH IJKL MNOP QRST UVWX YZ12 3456 7890 ABCD (4) uid [ultimate] Aleksandar Vidakovic <aleks@apache.org> (5) sub cv25519/4FGHIJ56 2022-04-16 [E] [expires: 2024-04-16] (6)1 GPG created a unique identifier in hexadecimal format for your public key. When someone wants to download your public key, they can refer to it either with your email address or this hex value. The hex value is sometimes prefixed with 0xas is commonly done with hexadecimal numbers.2 GPG created a revocation certificate and its directory. If your private key is compromised, you need to use your revocation certificate to revoke your key. 3 The public key uses the Ed25519 ECC (Elliptic Curve Cryptography) algorithm and shows the expiration date of 16 Apr 2024. The public key ID 7890ABCDmatches the last 8 characters of key fingerprint. The[SC]indicates this key is used to sign (prove authorship) and certify (issue subkeys for encryption, signature and authentication operations).4 The key fingerprint ( ABCD EFGH IJKL MNOP QRST UVWX YZ12 3456 7890 ABCD) is a hash of your public key.5 Your name and your email address are shown with information about the subkey. 6 This Curve25519 subkey is used for encryption. Now you can find that there are two files created under ~/.gnupg/private-keys-v1.d/ directory. These two files are binary files with .key extension.
-
Export your public key:
gpg --armor --export aleks@apache.org > pubkey.asc -
Export Your Private Key:
gpg --export-secret-keys --armor aleks@apache.org > privkey.asc -
Protect Your Private Key and Revocation Certificate
Your private key should be kept in a safe place, like an encrypted flash drive. Treat it like your house key. Only you can have it and don’t lose it. And you must remember your passphrase, otherwise you can’t unlock your private key.
You should protect your revocation certificate. Anyone in possession of your revocation certificate, could immediately revoke your public/private key pair and generate fake ones.
Please contact a PMC member to add your GPG public key in Fineract’s Subversion repository. This is necessary to be able to validate published releases. -
Upload your GPG key to a keyserver:
gpg --send-keys 0xYZ1234567890ABCDBefore doing this, make sure that your default keyserver is hkp://keyserver.ubuntu.com/. You can do this by changing the default keyserver in ~/.gnupg/dirmngr.conf:
keyserver hkp://keyserver.ubuntu.com/Alternatively you can provide the keyserver with the send command:
gpg --keyserver keyserver.ubuntu.com --send-keys 0xYZ1234567890ABCDAnother option to publish your key is to submit an armored public key directly at keyserver.ubuntu.com/. You can create the necessary data with this command by providing the email address that you used when you created your key pair:
gpg --armor --export aleks@apache.orgOutput:
-----BEGIN PGP PUBLIC KEY BLOCK----- mQINBF8iGq0BEADGRqeSsOoNDc1sV3L9sQ34KhmoQrACnMYGztx33TD98aWplul+ jm8uGtMmBus4DJJJap1bVQ1oMehw2mscmDHpfJjLNZ/q+vUqbExx1/CER7XvLryN <--- snip ---> 2nHBuBftxDRpDHQ+O5XYwSDSTDMmthPjx0vJGBH4K1kO8XK99e01A6/oYLV2SMKp gXXeWjafxBmHT1cM8hoBZBYzgTu9nK5UnllWunfaHXiCBG4oQQ== =85/F -----END PGP PUBLIC KEY BLOCK-----
Official communication related to releases needs to be done with an Apache email address. The Apache Foundation doesn’t provide any real email inboxes anymore and just relays emails to your configured private account (GMail etc.).
| At the moment we are supporting only GMail accounts. Please let us know if you have other configuration recipes for other email providers. |
GMail
You can configure your GMail account and add another profile to use the Apache relay server if you need to send official messages. Please follow these instructions:
TBD.
To be able to send emails via SMTP with your GMail account you probably need to create an app password. Please follow these instructions:
-
Go to your Google Account.
-
Select Security.
-
Under "Signing in to Google," select App Passwords. You may need to sign in. If you don’t have this option, it might be because:
-
2-Step Verification is not set up for your account.
-
2-Step Verification is only set up for security keys.
-
Your account is through work, school, or other organization.
-
You turned on Advanced Protection.
-
At the bottom, choose Select app and choose the app you’re using and then Select device and choose the device you’re using and then Generate.
-
Follow the instructions to enter the App Password. The App Password is the 16-character code in the yellow bar on your device.
-
Tap Done.
See also: Google Support: Sign in with App Passwords for more details.
Gradle
TBD
User Properties
There are a couple of properties that contain committer/release manager related secrets. Please add the following properties to your personal global Gradle properties (you will find them at ~/.gradle/gradle.properties in your home folder).
fineract.config.gnupg.keyName=ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCD(1)
fineract.config.gnupg.password=******
fineract.config.gnupg.publicKeyring=~/.gnupg/pubring.kbx(2)
fineract.config.gnupg.secretKeyring=~/.gnupg/secring.gpg
fineract.config.smtp.username=aleks@gmail.com (3)
fineract.config.smtp.password=******
fineract.config.name=Aleksandar Vidakovic
fineract.config.email=aleks@apache.org
fineract.config.username=aleks (4)
fineract.config.password=******
| 1 | Make sure you use the full GPG key name (you can list yours via gpg --list-secret-keys --keyid-format=long) |
| 2 | GnuPG has its own kbx format to store the public key ring. At the moment we are only supporting this format |
| 3 | Currently we only have instructions for GMail |
| 4 | Apache committer credentials |
| Never add any personal secrets in the project gradle.properties. Double check that you are not accidentally committing them to Git! |
Release Plugin
Creating Apache Fineract releases was a very manual and tedious procedure before we created the Gradle release plugin. It was easy - even with documentation - to forget a detail. Some ideas are borrowed from the excellent JReleaser tool. Unfortunately at the moment we can’t use it for the full release process. Being an Apache project we have certain requirements that are not fully covered by JReleaser.
Release Plugin Configuration
config {
username = "${findProperty('fineract.config.username')}"
password = "${findProperty('fineract.config.password')}"
doc {
url = 'git@github.com:apache/fineract-site.git'
directory = "${System.getProperty("java.io.tmpdir")}/fineract-site"
branch = "asf-site"
}
git {
dir = "${projectDir.absolutePath}/.git"
sections = [
[
section: "user",
name: "name",
value: "${findProperty('fineract.config.name')}",
],
[
section: "user",
name: "email",
value: "${findProperty('fineract.config.email')}",
],
[
section: "user",
name: "signingkey",
value: "${findProperty('fineract.config.gnupg.keyName')}",
],
[
section: "commit",
name: "gpgsign",
value: "true",
],
]
}
template {
templateDir = "${projectDir}/buildSrc/src/main/resources"
}
gpg {
keyName = "${findProperty('fineract.config.gnupg.keyName')}"
publicKeyring = "${findProperty('fineract.config.gnupg.publicKeyring')}"
secretKeyring = "${findProperty('fineract.config.gnupg.secretKeyring')}"
password = "${findProperty('fineract.config.gnupg.password')}"
}
smtp {
host = 'smtp.gmail.com'
username = "${findProperty('fineract.config.smtp.username')}"
password = "${findProperty('fineract.config.smtp.password')}"
tls = true
ssl = true
}
subversion {
username = "${findProperty('fineract.config.username')}"
password = "${findProperty('fineract.config.password')}"
revision = 'HEAD'
}
jira {
url = 'https://issues.apache.org/jira/rest/api/2/'
username = "${findProperty('fineract.config.username')}"
password = "${findProperty('fineract.config.password')}"
}
confluence {
url = 'https://cwiki.apache.org/confluence/rest/api/'
username = "${findProperty('fineract.config.username')}"
password = "${findProperty('fineract.config.password')}"
}
}
Release Process
| Fineract release plugin Gradle tasks are experimental and incomplete. |
Step 1: Heads-Up Email
Description
The RM should, if one doesn’t already exist, first create a new release umbrella issue in JIRA. This issue is dedicated to tracking (a summary of) any discussion related to the planned new release. An example of such an issue is FINERACT-873.
Next, the RM logs in to the ATR (ASF Trusted Releases) tool and clicks "+ Start a new release", naming the release 1.15.0-SNAPSHOT.
The RM then creates a list of resolved issues & features through an initial check in JIRA for already resolved issues for the release, and then setup a timeline for release branch point. The time for the day the issue list is created to the release branch point must be at least two weeks in order to give the community a chance to prioritize and commit any last minute features and issues they would like to see in the upcoming release.
The RM must then send the pointer to the umbrella issue along with the tentative timeline for branch point to the developer lists. Any work identified as release related that needs to be completed should be added as a sub tasks of the umbrella issue to allow all developers and users to see the overall release progress in one place. The umbrella issue shall also link to any issues still requiring clarification whether or not they will make it into the release.
The RM should then inform users when the git branch is planned to be created, by sending an email based on this template:
[FINERACT] [PROPOSAL] 📦 New release ${project['fineract.release.version']}
Hello everyone,
... based on our release process (https://fineract.apache.org/docs/current/#_releases), I will create a release/${project['fineract.release.version']} branch off develop in our git repository at https://github.com/apache/fineract on ${project['fineract.releaseBranch.date']}.
The release tracking umbrella issue for tracking all activity in JIRA is FINERACT-${project['fineract.release.issue']!'0000'} (https://issues.apache.org/jira/browse/FINERACT-${project['fineract.release.issue']!'0000'}).
If you have any work in progress that you would like to see included in this release, please add "blocking" links to the release JIRA issue.
I am the release manager for this release.
Cheers,
${project['fineract.config.name']}
🎉 Powered by Fineract Release Plugin 🎊
Gradle Task
./gradlew fineractReleaseStep1 -Pfineract.release.issue=1234 -Pfineract.releaseBranch.date="Monday, April 25, 2022" -Pfineract.release.version=1.15.0-SNAPSHOT
Step 2: Clean Up JIRA
Description
Before a release is done, make sure that any issues that are fixed have their fix version setup correctly.
project = FINERACT and resolution = fixed and fixVersion is empty
Move all unresolved JIRA issues which have this release as Fix Version to the next release
project = FINERACT and fixVersion = 1.15.0-SNAPSHOT and status not in ( Resolved, Done, Accepted, Closed )
You can also run the following query to make sure that the issues fixed for the to-be-released version look accurate:
project = FINERACT and fixVersion = 1.15.0-SNAPSHOT
Finally, check out the output of the JIRA release note tool to see which tickets are included in the release, in order to do a sanity check.
Gradle Task
./gradlew fineractReleaseStep2 -Pfineract.release.version=1.15.0-SNAPSHOT
| This task is not yet automated! |
Step 3: Create Release Branch
Description
Communicate with the community. You do not need to start a new email thread on the developer mailing list to notify that you are about to branch, just do it ca. 2 weeks after the initial email, or later, based on the discussion on the initial email.
You do not need to ask committers to hold off any commits until you have branched finished, as it’s always possible to fast-forward the branch to latest develop, or cherry-pick last minute changes to it. People should be able to continue working on the develop branch on bug fixes and great new features for the next release while the release process for the current release is being worked through.
-
Clone fresh repository copy
git clone git@github.com:apache/fineract.git cd fineract -
Check that current HEAD points to commit on which you want to base new release branch. Checkout a particular earlier commit if not.
git log (1)1 Check current branch history. HEAD should point to commit that you want to be base for your release branch -
Create a new release branch using the version number
git checkout -b release/1.15.0-SNAPSHOT -
Push new branch to Apache Fineract repository
git push origin release/1.15.0-SNAPSHOT -
Start new release notes page under Fineract Releases. The change list can be swiped from the JIRA release note tool (use the "text" format for the change log). See JIRA Cleanup above to ensure that the release notes generated by this tool are what you are expecting.
-
Send en email announcing the new release branch on the earlier email thread
[FINERACT] [ANNOUNCE] 🔀 ${project['fineract.release.version']} release branch Hello everyone, ... as previously announced, I've created the branch for our upcoming ${project['fineract.release.version']} release. The branch name is release/${project['fineract.release.version']}. You can continue working and merging PRs into the develop branch for future releases, as always. I started the DRAFT release notes at https://cwiki.apache.org/confluence/display/FINERACT/${project['fineract.release.version']}+-+Apache+Fineract . Please help me by filling in "Summary of changes". Does anyone see anything else missing? Does anyone have any last minute changes for the release branch, or are we good to go and actually cut the release based on this branch as it is? I'll initiate the final stage of actually creating the release on ${project['fineract.release.date']} if nobody objects. Cheers, ${project['fineract.config.name']}
Gradle Task
./gradlew fineractReleaseStep3 -Pfineract.release.date="Monday, May 10, 2022" -Pfineract.release.version=1.15.0-SNAPSHOT
Step 4: Freeze JIRA
Description
You first need to close the release in JIRA so that the about to be released version cannot be used as "fixVersion" for new bugs anymore. Go to JIRA "Administer project" page and follow "Versions" in left menu. Table with list of all releases should appear, click on additional menu on the right of your release and choose "Release" option. Submit release date and you’re done.
Step 5: Create Release Tag
Description
Next, you create a git tag from the HEAD of the release’s git branch.
git checkout -b release/1.15.0-SNAPSHOT (1)
git tag -a 1.15.0-SNAPSHOT -m "Fineract 1.15.0-SNAPSHOT release" -s (2)
git push origin tag 1.15.0-SNAPSHOT
| 1 | Ensure all tests pass for this commit both in CI and locally. |
| 2 | -s is optional but recommended: GPG signatures on tags are useful for trust and integrity. |
| It is important to create so called annotated tags (vs. lightweight) for releases. |
Gradle Task
./gradlew fineractReleaseStep5 -Pfineract.release.version=1.15.0-SNAPSHOT
Step 6: Create Distribution
Description
Create source and binary tarballs.
./gradlew clean
./gradlew generateLicenseReport
./gradlew srcDistTar binaryDistTar
Check that fineract-provider/build/generated-resources/git/git.properties exists. If so, continue. If not, you’re likely encountering this bug, and you need to re-run the command above to create proper source and binary tarballs. That git.properties file is supposed to end up at BOOT-INF/classes/git.properties in fineract-provider-1.15.0-SNAPSHOT.jar in the binary release tarball. Its contents are displayed at the /fineract-provider/actuator/info endpoint. It may be possible to fix this heisenbug entirely by modifying our git properties gradle plugin config in fineract-provider/build.gradle, perhaps by changing where git.properties is written.
Look in fineract-war/build/distributions/ for the tarballs.
Do some sanity checks. The source tarball and the code in the release branch (at the commit with the release tag) should match.
cd /fineract-release-preparations
tar -xvzf path/to/apache-fineract-src-1.15.0-SNAPSHOT.tar.gz
git clone git@github.com:apache/fineract.git
cd fineract/
git checkout tags/1.15.0-SNAPSHOT
cd ..
diff -r fineract apache-fineract-src-1.15.0-SNAPSHOT
Make sure the code compiles and tests pass on the uncompressed source. You should at the very least do exactly what you will ask the community to do in Step 9: Verify Distribution Staging.
Ideally you’d build code and docs and run every possible test and check, but running everything has complex dependencies, caches, and takes many hours. It is rarely done in practice offline / local / on developer machines. But please, go ahead and run the test and doc tasks, and more! Grab a cup of coffee and run everything you can. See the various builds in .github/workflows/ and try the same things on your own. We should all hammer on a release candidate as much as we can to see if it breaks and fix it if so. All that of course improves our final release.
| We don’t release any artifacts to Apache’s Maven repository. |
Gradle Task
./gradlew fineractReleaseStep6
| This task doesn’t work. Build release artifacts manually as indicated above. |
Step 7: Sign Distribution
Description
Release source and binary tarballs must be checksummed and signed. In order to sign a release you will need a PGP key. You should get your key signed by a few other people. You will also need to receive their keys from a public key server. See the Apache release policy for more details.
# sign
gpg --armor --output apache-fineract-src-1.15.0-SNAPSHOT.tar.gz.asc \
--detach-sig apache-fineract-src-1.15.0-SNAPSHOT.tar.gz
gpg --armor --output apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz.asc \
--detach-sig apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz
# hash
gpg --print-md SHA512 apache-fineract-src-1.15.0-SNAPSHOT.tar.gz \
> apache-fineract-src-1.15.0-SNAPSHOT.tar.gz.sha512
gpg --print-md SHA512 apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz \
> apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz.sha512
Gradle Task
./gradlew fineractReleaseStep7
Step 8: Upload Distribution Staging
Description
Next we’ll stage the release candidate. Create a new folder and add these files:
-
apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz
-
apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz.sha512
-
apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz.asc
-
apache-fineract-src-1.15.0-SNAPSHOT.tar.gz
-
apache-fineract-src-1.15.0-SNAPSHOT.tar.gz.sha512
-
apache-fineract-src-1.15.0-SNAPSHOT.tar.gz.asc
These files (or "artifacts") comprise the release candidate.
First, upload these files to release-test.apache.org/compose/fineract/1.15.0-SNAPSHOT. This tool will initiate automated checks on the files. We’ll come back to those in next step and ensure they pass.
Next, upload these files to ASF’s distribution dev/staging area like so:
# this is a remote operation
svn mkdir -m "Create Fineract 1.15.0-SNAPSHOT staging area 🏗️" \
https://dist.apache.org/repos/dist/dev/fineract/1.15.0-SNAPSHOT
# create local svn-tracked folder "1.15.0-SNAPSHOT"
svn checkout https://dist.apache.org/repos/dist/dev/fineract/1.15.0-SNAPSHOT
# prepare to upload
cp path/to/new/folder/* 1.15.0-SNAPSHOT/
cd 1.15.0-SNAPSHOT/
# actual upload occurs here
svn add * && svn commit -m "Stage Fineract 1.15.0-SNAPSHOT 🎭"
You will need your ASF Committer credentials to be able to access the Subversion host at dist.apache.org.
|
Gradle Task
./gradlew fineractReleaseStep8 -Pfineract.release.version=1.15.0-SNAPSHOT
Gradle task 8 is inefficient. We recommend svn mkdir and other manual steps above.
|
Step 9: Verify Distribution Staging
Description
Following are the typical things we need to verify before voting on a release candidate. And the release manager should verify them too before calling out a vote. Some of these checks are duplicated by the ATR (ASF Trusted Releases) tool, and will need to continue to be duplicated until we get more comfortable with that tool.
Make sure release artifacts are hosted at both release-test.apache.org/compose/fineract/1.15.0-SNAPSHOT AND dist.apache.org/repos/dist/dev/fineract
-
Release candidate files should match filenames mentioned earlier, and will be moved without renaming if/when the release vote passes.
-
Verify signatures and hashes. You may have to import the public key of the release manager to verify the signatures. (
gpg --import KEYSorgpg --recv-key <key id>) -
Git tag matches the released bits (
diff -rf) -
Can compile docs and code successfully from source
-
Verify DISCLAIMER, NOTICE and LICENSE (year etc)
-
All files have correct headers (Rat check should be clean -
./gradlew rat) -
No jar files in the source artifacts
-
All tests pass both in CI and locally
-
All ATR checks pass
Artifact verification
# source tarball signature and checksum verification steps
# we'll check the source tarball first
src=apache-fineract-src-1.15.0-SNAPSHOT.tar.gz
# upon success: prints "Good signature" and returns successful exit code
# upon failure: prints "BAD signature" and returns error exit code
gpg --verify $src.asc
# upon success: prints nothing and returns successful exit code
# upon failure: prints checksum differences and returns error exit code
gpg --print-md SHA512 $src | diff - $src.sha512
# binary tarball signature and checksum verification steps and outputs are similar
bin=apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz
gpg --verify $bin.asc
gpg --print-md SHA512 $bin | diff - $bin.sha512
Look for Good signature in the gpg output:
$ gpg --verify $bin.asc
gpg: assuming signed data in 'apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz'
gpg: Signature made Sat 11 Oct 2025 05:46:42 PM PDT
gpg: using EDDSA key 250775BDB5FE7D53E4AF95C00E895A1A7A090CFC
gpg: Good signature from "Adam Monsen <haircut@gmail.com>" [unknown]
That’s the most important part.
You may see this warning:
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
You may choose to ignore it. To squelch this warning, you must extend your web of trust, by, for example, signing the release manager’s key.
Now it’s time to build and run the release candidate.
Build from source
tar -xzf $src
cd apache-fineract-src-1.15.0-SNAPSHOT
./gradlew build -x test -x doc
cd ..
Run from binary
Before running Fineract you must first start a supported relational database server and ensure the fineract_default and fineract_tenants databases exist. Detailed steps for database preparation are left as an exercise for the reader. You can find ideas on how to prepare your database in the build-mariadb.yml, build-mysql.yml, and build-postgresql.yml files in source control, and in Database Setup.
Finally, start your Fineract server:
tar -xvzf apache-fineract-bin-1.15.0-SNAPSHOT.tar.gz
cd apache-fineract-bin-1.15.0-SNAPSHOT
export FINERACT_SERVER_SSL_ENABLED=false
export FINERACT_SERVER_PORT=8080
export BACKEND_PROTOCOL=http
export BACKEND_PORT=$FINERACT_SERVER_PORT
# assumes reachable, healthy mariadb with default username, password, and port
java -jar fineract-provider-1.15.0-SNAPSHOT.jar
Alternatively, you can run it in Tomcat:
cat << 'EndOfRcenv' >> rcenv
FINERACT_SERVER_SSL_ENABLED=false
FINERACT_SERVER_PORT=8080
BACKEND_PROTOCOL=http
BACKEND_PORT=$FINERACT_SERVER_PORT
EndOfRcenv
# assumes reachable, healthy mariadb with default username, password, and port
docker run --rm -it -v "$(pwd):/usr/local/tomcat/webapps" \
--net=host --env-file=rcenv tomcat:jre21
Confirm the following:
-
localhost:8080/fineract-provider/actuator/info displays the expected information
-
API calls work against localhost:8080/fineract-provider/api/v1
Gradle Task
./gradlew fineractReleaseStep9 -Pfineract.release.version=1.15.0-SNAPSHOT
| This task is not yet automated! |
Step 10: Start Vote
Description
Initiate voting with the ATR (ASF Trusted Releases) tool.
Step 11: Finish Vote
Description
Conclude voting with the ATR (ASF Trusted Releases) tool.
Step 12: Upload Distribution Release
Description
Move the release candidate from the dev area to the release area using a Subversion server-side copy.
# this is a remote operation
svn mv -m "Release Fineract 1.15.0-SNAPSHOT 🚢" \
https://dist.apache.org/repos/dist/dev/fineract/1.15.0-SNAPSHOT \
https://dist.apache.org/repos/dist/release/fineract/
You will now get an automated email from the Apache Reporter Service (no-reply@reporter.apache.org), subject "Please add your release data for 'fineract'" to add the release data (version and date) to the database on reporter.apache.org/addrelease.html?fineract (requires PMC membership).
Gradle Task
./gradlew fineractReleaseStep12 -Pfineract.release.version=1.15.0-SNAPSHOT
| This task is not yet automated! |
Step 13: Close Release Branch
Description
As discussed in FINERACT-1154, now that everything is final, please do the following to remove the release branch (and just keep the tag), and make sure that everything on the release tag is merged to develop and that e.g. git describe works:
git checkout develop
git merge release/1.15.0-SNAPSHOT (1)
git push origin develop
git branch -D release/1.15.0-SNAPSHOT
git push origin :release/1.15.0-SNAPSHOT
git describe (2)
| 1 | This merge is necessary for posterity: It’s how we’re able to preserve and trace lineage from releases to descendent commit. |
| 2 | The output must refer to the most recent release. For example, if your working copy is checked out to the develop branch, the current commit is 0762a012e, and the latest release tag (28 commits ago) was 1.12.1, the output of git describe would be 1.12.1-28-g0762a012e. |
Gradle Task
./gradlew fineractReleaseStep13 -Pfineract.release.version=1.15.0-SNAPSHOT
| This task is not yet automated! |
Step 14: Update website
Description
Finally update the fineract.apache.org website with the latest release details. The website’s HTML source code is available at github.com/apache/fineract-site.
| This step is not yet automated. We are working on a static site generator setup. |
Gradle Task
./gradlew fineractReleaseStep14 (1)
| 1 | Currently doing nothing. Will trigger in the future the static site generator and publish on Github. |
| This task is not yet automated! |
Step 15: Announcement Email
Description
Manually draft an email using your Apache ID. This works best if you use plain text since the Apache announcements list rejects HTML. Use format=flowed for readability, following instructions at useplaintext.email. Follow a recent example, substituting:
Send the email to dev@fineract.apache.org and announce@apache.org.
Maintenance Release Process
| This is a first attempt to introduce maintenance releases. Some details might change as soon as we get more experience with the process and feedback from the community. The numbers here are still more or less arbitrary, and we’ll adapt as necessary. |
Rules
-
hotfix releases are reserved for critical (BLOCKER) bugs and security issues. Probably we’ll have some kind of voting process in place, e. g. "minimum 3 x +1 votes from PMC members"
-
we will support (for now to start) two minor versions back counting from the last release; this would mean that once 1.8.0 is out we would support 1.8.x and 1.7.x, but not 1.6.x and older; this rule is tentative, we’ll see then what we do in the future when we have more feedback.
-
guaranteed backward compatibility with the last minor release; i. e. "1.6.1" is a drop-in replacement for "1.6.0"
-
NO new features, tables, data, REST endpoints
-
NO major (or "minor" framework upgrades); i. e. if we used Spring Boot "2.6.1" in version "1.6.0" of Fineract we can upgrade dependencies to "2.6.10" (unless it breaks something of course), but not to "2.7.2" of Spring Boot
| The rest of the release process is the same as for normal releases. In the future we might have smaller time windows for reviews. |
JIRA
-
Continuously update the JIRA umbrella issue to make sure we catch all ticket changes.
-
List tickets that have discrepancies, e. g. tickets still open while associated PR merged, ticket on wrong version (i. e. associated PR already merged before with another release).
Publish Release Artifacts
| More on releases at the ASF see www.apache.org/legal/release-policy.html#distribute-raw-artifact |
Requirements
You need to have your GPG keypairs properly set up. The JAR release artifacts (currently only fineract-client) are signed with a Gradle plugin just before being uploaded to the Maven repository. Please make sure that the following properties are set in your private gradle.properties file in your home folder:
signing.keyId=7890ABCD
signing.password=*****
signing.secretKeyRingFile=~/.gnupg/secring.gpg
This is quite similiar to the Fineract release plugin properties for GPG. In one of the next release we’ll merge these two setups to avoid this duplicated configuration.
Maven Repository
We are using the ASF’s official Nexus Maven repository to publish our snapshot and release artifacts.
| Find more information at infra.apache.org/publishing-maven-artifacts.html |
NPM Registry
For convenience we will be using Github Packages to publish Fineract’s Typescript API client.
TBD
Docker Hub
TBD
Fineract SDKs
TBD
Generate Apache Fineract API Client
Apache Fineract supports client code generation using OpenAPI Generator. It uses OpenAPI Specification Version 3.0.3.
Fineract SDK Java API Client
The fineract-client.jar will eventually be available on Maven Central (watch FINERACT-1102). Until it is, you can quite easily build the latest and greatest version locally from source, see below.
The FineractClient is the entry point to the Fineract SDK Java API Client. Calls is a convenient and recommended utility to simplify the use of the retrofit2.Call type which all API operations return. This permits you to use the API like the FineractClientDemo illustrates:
import org.apache.fineract.client.util.FineractClient;
import static org.apache.fineract.client.util.Calls.ok;
FineractClient fineract = FineractClient.builder().baseURL("https://demo.fineract.dev/fineract-provider/api/v1/").tenant("default")
.basicAuth("mifos", "password").build();
List<StaffData> staff = Calls.ok(fineract.staff.retrieveAllStaff(1L, true, false, "ACTIVE"));
String name = staff.get(0).getDisplayName();
log.info("Display name: {}", name);
Generate API Client
The API client is built as part of the standard overall Fineract Gradle build. The client JAR can be found in fineract-client/build/libs as fineract-client.jar.
If you need to save time to incrementally work on making small changes to Swagger annotations in an IDE, you can execute e.g. the following line in root directory of the project to exclude non-require Gradle tasks:
./gradlew -x compileJava -x compileTest -x spotlessJava -x enhance resolve prepareInputYaml :fineract-client:buildJavaSdk
Validate OpenAPI Spec File
The resolve task in build.gradle file will generate the OpenAPI Spec File for the project. To make sure Swagger Codegen generates a correct library, it is important for the OpenAPI Spec file to be valid. Validation is done automatically by the OpenAPI code generator Gradle plugin. If you still have problems during code generation please use Swagger OpenAPI Validator to validate the spec file.
Glossary
TBD
Appendix A: Fineract Application Properties
TBD
Tenant Database Properties
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
fineract.tenant.host |
FINERACT_DEFAULT_TENANTDB_HOSTNAME |
localhost |
This property sets the hostname of the default tenant database. |
fineract.tenant.port |
FINERACT_DEFAULT_TENANTDB_PORT |
3306 |
This property sets the port of the default tenant database. |
fineract.tenant.username |
FINERACT_DEFAULT_TENANTDB_UID |
root |
This property sets the username of the default tenant database. |
fineract.tenant.password |
FINERACT_DEFAULT_TENANTDB_PWD |
mysql |
This property sets the password of the default tenant database. |
fineract.tenant.parameters |
FINERACT_DEFAULT_TENANTDB_CONN_PARAMS |
This property sets the connection parameters of the default tenant database. eg. whether ssl is enabled or not |
|
fineract.tenant.timezone |
FINERACT_DEFAULT_TENANTDB_TIMEZONE |
Asia/Kolkata |
This property sets the timezone of the default tenant |
fineract.tenant.identifier |
FINERACT_DEFAULT_TENANTDB_IDENTIFIER |
default |
This property sets the unique identifier for the tenant within fineract |
fineract.tenant.name |
FINERACT_DEFAULT_TENANTDB_NAME |
fineract_default |
This property sets the database name of the default tenant |
fineract.tenant.description |
FINERACT_DEFAULT_TENANTDB_DESCRIPTION |
Default Demo Tenant |
This property sets the description of the default tenant |
fineract.tenant.master-password |
FINERACT_DEFAULT_TENANTDB_MASTER_PASSWORD |
fineract |
The password used to encrypt sensitive tenant data within the database |
fineract.tenant.encryption |
FINERACT_DEFAULT_TENANTDB_ENCRYPTION |
AES/CBC/PKCS5Padding |
This property sets the symmetric encryption algorithm used to encrypt sensitive tenant data within the database e.g tenant database password |
spring.liquibase.enabled |
FINERACT_LIQUIBASE_ENABLED |
true |
If set to true, liquibase will be enabled and the instance running this configuration will run migrations |
fineract.tenant.read-only-name |
FINERACT_DEFAULT_TENANTDB_RO_NAME |
For read only configuration, set this to the name of the read only tenant database |
|
fineract.tenant.read-only-host |
FINERACT_DEFAULT_TENANTDB_RO_HOSTNAME |
For read only configuration, set this to the hostname of the read only tenant database |
|
fineract.tenant.read-only-port |
FINERACT_DEFAULT_TENANTDB_RO_PORT |
For read only configuration, set this to the port of the read only tenant database |
|
fineract.tenant.read-only-username |
FINERACT_DEFAULT_TENANTDB_RO_UID |
For read only configuration, set this to the username of the read only tenant database |
|
fineract.tenant.read-only-password |
FINERACT_DEFAULT_TENANTDB_RO_PWD |
For read only configuration, set this to the password of the read only tenant database |
|
fineract.tenant.read-only-parameters |
FINERACT_DEFAULT_TENANTDB_RO_CONN_PARAMS |
For read only configuration, set this to the connection parameters of the read only tenant database |
Hikari Connection Pool Properties
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
spring.datasource.hikari.driverClassName |
FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME |
org.mariadb.jdbc.Driver |
The correct driver name for the database that will be used with fineract. |
spring.datasource.hikari.jdbcUrl |
FINERACT_HIKARI_JDBC_URL |
jdbc:mariadb://localhost:3306/fineract_tenants |
The database connection string for the database with tenant information that will be used with fineract. |
spring.datasource.hikari.username |
FINERACT_HIKARI_USERNAME |
root |
The username for the database with tenant information that will be used with fineract |
spring.datasource.hikari.password |
FINERACT_HIKARI_PASSWORD |
mysql |
The password for the database with tenant information that will be used with fineract |
spring.datasource.hikari.minimumIdle |
FINERACT_HIKARI_MINIMUM_IDLE |
3 |
The minimum number of connections in hakari pool that will be maintained when the system is idle |
spring.datasource.hikari.maximumPoolSize |
FINERACT_HIKARI_MAXIMUM_POOL_SIZE |
10 |
The maximum number of connections that hikari can create in the pool. |
spring.datasource.hikari.idleTimeout |
FINERACT_HIKARI_IDLE_TIMEOUT |
60000 |
The maximum time in milliseconds that a connection is allowed to sit idle in the pool. |
spring.datasource.hikari.connectionTimeout |
FINERACT_HIKARI_CONNECTION_TIMEOUT |
20000 |
The maximum time in milliseconds that hikari will wait for a connection to be established. |
spring.datasource.hikari.connectionTestquery |
FINERACT_HIKARI_TEST_QUERY |
SELECT 1 |
The query that will be used to test the database connection. |
spring.datasource.hikari.autoCommit |
FINERACT_HIKARI_AUTO_COMMIT |
true |
If set to true, the connections in the pool will be in auto-commit mode. |
spring.datasource.hikari.dataSourceProperties['cachePrepStmts'] |
FINERACT_HIKARI_DS_PROPERTIES_CACHE_PREP_STMTS |
true |
If set to true, hikari caches compiled SQL statements to avoid the overhead of re-parsing and re-compiling SQL queries. |
spring.datasource.hikari.dataSourceProperties['prepStmtCacheSize'] |
FINERACT_HIKARI_DS_PROPERTIES_PREP_STMT_CACHE_SIZE |
250 |
The maximum number of prepared statements that hikari can cache. |
spring.datasource.hikari.dataSourceProperties['prepStmtCacheSqlLimit'] |
FINERACT_HIKARI_DS_PROPERTIES_PREP_STMT_CACHE_SQL_LIMIT |
2048 |
This property sets the upper limit for the size of individual SQL queries that can be stored in the cache. If a SQL query exceeds this limit in terms of character length, it will not be cached, even if caching is enabled. |
spring.datasource.hikari.dataSourceProperties['useServerPrepStmts'] |
FINERACT_HIKARI_DS_PROPERTIES_USE_SERVER_PREP_STMTS |
true |
This property determines if the connection should leverage server-side prepared statements rather than client-side ones. |
spring.datasource.hikari.dataSourceProperties['useLocalSessionState'] |
FINERACT_HIKARI_DS_PROPERTIES_USE_LOCAL_SESSION_STATE |
true |
This property allows the connection pool to locally track changes to session-specific properties (like character sets or time zones) rather than sending these queries to the database repeatedly. |
spring.datasource.hikari.dataSourceProperties['rewriteBatchedStatements'] |
FINERACT_HIKARI_DS_PROPERTIES_REWRITE_BATCHED_STATEMENTS |
true |
This property, when set to true, allows the JDBC driver to rewrite batched SQL statements into a more efficient single query format before sending them to the database. |
spring.datasource.hikari.dataSourceProperties['cacheResultSetMetadata'] |
FINERACT_HIKARI_DS_PROPERTIES_CACHE_RESULT_SET_METADATA |
true |
This property, when set to true, enables the caching of metadata for ResultSet objects. This metadata includes details such as column names, types, and other relevant schema information. |
spring.datasource.hikari.dataSourceProperties['cacheServerConfiguration'] |
FINERACT_HIKARI_DS_PROPERTIES_CACHE_SERVER_CONFIGURATION |
true |
When set to true, this property allows the JDBC driver to cache the server configuration settings, which include properties such as session state, character sets, and other configuration details relevant to the database server. |
spring.datasource.hikari.dataSourceProperties['elideSetAutoCommits'] |
FINERACT_HIKARI_DS_PROPERTIES_ELIDE_SET_AUTO_COMMITS |
true |
When set to true, this property prevents the JDBC driver from issuing a SET autocommit command on the database connection during its initialization. |
spring.datasource.hikari.dataSourceProperties['maintainTimeStats'] |
FINERACT_HIKARI_DS_PROPERTIES_MAINTAIN_TIME_STATS |
false |
When set to true, this property enables HikariCP to track and maintain statistics regarding various timing metrics related to connection pool operations, such as connection acquisition times. |
spring.datasource.hikari.dataSourceProperties['logSlowQueries'] |
FINERACT_HIKARI_DS_PROPERTIES_LOG_SLOW_QUERIES |
true |
When set to true, this property enables HikariCP to log SQL queries that exceed a specified execution time threshold, allowing developers and administrators to identify and analyze performance issues related to slow-running queries. |
spring.datasource.hikari.dataSourceProperties['dumpQueriesOnException'] |
FINERACT_HIKARI_DS_PROPERTIES_DUMP_QUERIES_IN_EXCEPTION |
true |
When set to true, this property instructs HikariCP to log the SQL statements that caused exceptions during execution. This includes capturing the query text and any associated parameters. |
SSL Properties
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
server.ssl.enabled |
FINERACT_SERVER_SSL_ENABLED |
true |
When set to true, SSL (Secure Sockets Layer) or TLS (Transport Layer Security) will be enabled for the server. |
server.ssl.protocol |
FINERACT_SERVER_SSL_PROTOCOL |
TLS |
This property allows you to define specific SSL/TLS protocol version the server will use when establishing secure connections. Common protocols include TLSv1.2, TLSv1.3, etc. |
server.ssl.ciphers |
FINERACT_SERVER_SSL_CIPHERS |
TLS_RSA_WITH_AES_128_CBC_SHA256 |
This property allows you to control the cipher suites that fineract will accept for secure connections |
server.ssl.enabled-protocols |
FINERACT_SERVER_SSL_PROTOCOLS |
TLSv1.2 |
This property allows you to define a list of SSL/TLS protocol versions that the server will support when establishing secure connections |
server.ssl.key-store |
FINERACT_SERVER_SSL_KEY_STORE |
classpath:keystore.jks |
The property is used to specify the location of the SSL key store file that contains the server’s private key and the associated certificate |
server.ssl.key-store-password |
FINERACT_SERVER_SSL_KEY_STORE_PASSWORD |
openmf |
The property defines the password for the keystore specified under property server.ssl.key-store |
Authentication Properties
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
fineract.security.basicauth.enabled |
FINERACT_SECURITY_BASICAUTH_ENABLED |
true |
When set to true, the supported authentication method will be basic authentication. |
fineract.security.oauth2.enabled |
FINERACT_SECURITY_OAUTH_ENABLED |
false |
When set to true, the supported authentication method will be OAuth. |
fineract.security.2fa.enabled |
FINERACT_SECURITY_2FA_ENABLED |
false |
Set the value to true enable two-factor authentication. For this to work as expected, ensure that you have set the correct email/sms configuration |
spring.security.oauth2.resourceserver.jwt.issuer-uri |
FINERACT_SERVER_OAUTH_RESOURCE_URL |
If OAuth is enabled and a custom resouce server (different from what is provided) is required, set the issuer-uri here. |
Tomcat Properties
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
server.tomcat.accept-count |
FINERACT_SERVER_TOMCAT_ACCEPT_COUNT |
100 |
The property specifies the maximum number of concurrent connection requests that embedded Tomcat can queue. If this limit is reached, incoming connection requests will be rejected. |
server.tomcat.accesslog.enabled |
FINERACT_SERVER_TOMCAT_ACCESSLOG_ENABLED |
false |
If set to true, tomcat will log access requests to file |
server.tomcat.max-connections |
FINERACT_SERVER_TOMCAT_MAX_CONNECTIONS |
8192 |
Sets the maximum number of simultaneous connections Tomcat can handle. |
server.tomcat.max-http-form-post-size |
FINERACT_SERVER_TOMCAT_MAX_HTTP_FORM_POST_SIZE |
2MB |
The property in sets the maximum size of HTTP POST requests that Tomcat can handle |
server.tomcat.max-keep-alive-requests |
FINERACT_SERVER_TOMCAT_MAX_KEEP_ALIVE_REQUESTS |
100 |
The property specifies the maximum number of HTTP requests that can be sent over a single persistent connection (HTTP Keep-Alive) before Tomcat closes the connection |
server.tomcat.threads.max |
FINERACT_SERVER_TOMCAT_THREADS_MAX |
200 |
The property sets the maximum number of threads that Tomcat can use to process requests |
server.tomcat.threads.min-spare |
FINERACT_SERVER_TOMCAT_THREADS_MIN_SPARE |
10 |
The property specifies the minimum number of spare (idle) threads that Tomcat should maintain |
Kafka Properties
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
fineract.remote-job-message-handler.kafka.enabled |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_ENABLED |
false |
Enables or disables Kafka for remote job execution. If Kafka is enabled then JMS shall be disabled. |
fineract.remote-job-message-handler.kafka.topic.auto-create |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_TOPIC_AUTO_CREATE |
true |
Enables topic auto creation. In case the auto creation of the topic is disabled please make sure that the replica and the partition count is properly configured. |
fineract.remote-job-message-handler.kafka.topic.name |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_TOPIC_NAME |
job-topic |
Name of the topic where partitioned tasks are sent to |
fineract.remote-job-message-handler.kafka.topic.replicas |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_TOPIC_REPLICAS |
1 |
Number of the replicas |
fineract.remote-job-message-handler.kafka.topic.partitions |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_TOPIC_PARTITIONS |
10 |
Number of partitions |
fineract.remote-job-message-handler.kafka.bootstrap-servers |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_BOOTSTRAP_SERVERS |
localhost:9092 |
Comma separated list of bootstrap servers |
fineract.remote-job-message-handler.kafka.consumer.group-id |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_CONSUMER_GROUPID |
fineract-consumer-group-id |
Group ID of the Consumer |
fineract.remote-job-message-handler.kafka.consumer.extra-properties-key-value-separator |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_CONSUMER_EXTRA_PROPERTIES_SEPARATOR |
= |
Defines key and value separator for consumer,e.g.: key=value |
fineract.remote-job-message-handler.kafka.consumer.extra-properties-separator |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_CONSUMER_EXTRA_PROPERTIES_SEPARATOR |
| |
Defines item separator for consumer, e.g.: key1=value1|key2=value2 |
fineract.remote-job-message-handler.kafka.consumer.extra-properties |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_CONSUMER_EXTRA_PROPERTIES |
#holds list of key value pairs using the above defined separators for consumer: key1=value1|key2=value2|…|keyn=valuen |
|
fineract.remote-job-message-handler.kafka.producer.extra-properties-key-value-separator |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_PRODUCER_EXTRA_PROPERTIES_KEY_VALUE_SEPARATOR |
= |
Defines key and value separator for producer,e.g.: key=value |
fineract.remote-job-message-handler.kafka.producer.extra-properties-separator |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_PRODUCER_EXTRA_PROPERTIES_SEPARATOR |
| |
Defines item separator for producer, e.g.: key1=value1|key2=value2 |
fineract.remote-job-message-handler.kafka.producer.extra-properties |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_PRODUCER_EXTRA_PROPERTIES |
#holds list of key value pairs using the above defined separators for producer: key1=value1|key2=value2|…|keyn=valuen |
|
fineract.remote-job-message-handler.kafka.admin.extra-properties-key-value-separator |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_ADMIN_EXTRA_PROPERTIES_KEY_VALUE_SEPARATOR |
= |
Defines key and value separator for admin,e.g.: key=value |
fineract.remote-job-message-handler.kafka.admin.extra-properties-separator |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_ADMIN_EXTRA_PROPERTIES_SEPARATOR |
| |
Defines item separator for admin, e.g.: key1=value1|key2=value2 |
fineract.remote-job-message-handler.kafka.admin.extra-properties |
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_KAFKA_ADMIN_EXTRA_PROPERTIES |
#holds list of key value pairs using the above defined separators for admin: key1=value1|key2=value2|…|keyn=valuen |
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
fineract.events.external.producer.kafka.enabled |
FINERACT_EXTERNAL_EVENTS_KAFKA_ENABLED |
false |
Enables disables Kafka for External Events. If Kafka is enabled then JMS shall be disabled. |
fineract.events.external.producer.kafka.timeout-in-seconds |
FINERACT_EXTERNAL_EVENTS_KAFKA_TIMEOUT_IN_SECONDS |
10 |
Timeout for Kafka confirming the messages written in the topic |
fineract.events.external.producer.kafka.topic.auto-create |
FINERACT_EXTERNAL_EVENTS_KAFKA_TOPIC_AUTO_CREATE |
true |
Enables topic auto creation. In case the auto creation of the topic is disabled please make sure that the replica and the partition count is properly configured. |
fineract.events.external.producer.kafka.topic.name |
FINERACT_EXTERNAL_EVENTS_KAFKA_TOPIC_NAME |
external-events |
Name of the topic where external events are sent to |
fineract.events.external.producer.kafka.topic.replicas |
FINERACT_EXTERNAL_EVENTS_KAFKA_TOPIC_REPLICAS |
1 |
Number of the replicas |
fineract.events.external.producer.kafka.topic.partitions |
FINERACT_EXTERNAL_EVENTS_KAFKA_TOPIC_PARTITIONS |
10 |
Number of partitions |
fineract.events.external.producer.kafka.bootstrap-servers |
FINERACT_EXTERNAL_EVENTS_KAFKA_BOOTSTRAP_SERVERS |
localhost:9092 |
Comma separated list of Kafka bootstrap servers |
fineract.events.external.producer.kafka.producer.extra-properties-separator |
FINERACT_EXTERNAL_EVENTS_KAFKA_PRODUCER_EXTRA_PROPERTIES_SEPARATOR |
| |
Defines item separator for producer,e.g.: key=value |
fineract.events.external.producer.kafka.producer.extra-properties-key-value-separator |
FINERACT_EXTERNAL_EVENTS_KAFKA_PRODUCER_EXTRA_PROPERTIES_KEY_VALUE_SEPARATOR |
= |
Defines key and value separator for producer client |
fineract.events.external.producer.kafka.producer.extra-properties |
FINERACT_EXTERNAL_EVENTS_KAFKA_PRODUCER_EXTRA_PROPERTIES |
linger.ms=10|batch.size=16384 |
Defines the extra properties for external event producer clients. Optimization for sending out large volume of messages. Increases Batch buffer size and batching time window. |
fineract.events.external.producer.kafka.admin.extra-properties-separator |
FINERACT_EXTERNAL_EVENTS_KAFKA_ADMIN_EXTRA_PROPERTIES_SEPARATOR |
| |
Defines item separator for admin client. |
fineract.events.external.producer.kafka.admin.extra-properties-key-value-separator |
FINERACT_EXTERNAL_EVENTS_KAFKA_ADMIN_EXTRA_PROPERTIES_KEY_VALUE_SEPARATOR |
= |
Defines key and value separator for admin client |
fineract.events.external.producer.kafka.admin.extra-properties |
FINERACT_EXTERNAL_EVENTS_KAFKA_ADMIN_EXTRA_PROPERTIES |
Defines the extra properties for external event admin clients |
Metrics Properties
For further understanding of the configurations properties related to metrics, refer to Springboot metrics docs
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
management.info.git.mode |
FULL |
Mode for displaying Git information in the |
|
management.endpoints.web.exposure.include |
FINERACT_MANAGEMENT_ENDPOINT_WEB_EXPOSURE_INCLUDE |
health,info,prometheus |
Comma-separated list of endpoints that should be exposed over the web. |
management.tracing.enabled |
FINERACT_MANAGEMENT_METRICS_TAGS_APPLICATION |
fineract |
Whether tracing is enabled. |
management.metrics.distribution.percentiles-histogram.http.server.requests |
FINERACT_MANAGEMENT_METRICS_DISTRIBUTION_HTTP_SERVER_REQUESTS |
false |
Whether to publish percentile histograms for HTTP server requests. |
management.otlp.metrics.export.url |
FINERACT_MANAGEMENT_OLTP_METRICS_EXPORT_URL |
URL to export OTLP metrics. |
|
management.otlp.metrics.export.aggregationTemporality |
FINERACT_MANAGEMENT_OLTP_METRICS_EXPORT_AGGREGATION_TEMPORALITY |
cumulative |
Aggregation temporality for OTLP metrics export. |
management.prometheus.metrics.export.enabled |
FINERACT_MANAGEMENT_PROMETHEUS_ENABLED |
false |
Whether to enable Prometheus metrics export. |
spring.cloud.aws.cloudwatch.enabled |
FINERACT_MANAGEMENT_CLOUDWATCH_ENABLED |
false |
Whether to enable AWS CloudWatch integration. |
management.metrics.export.cloudwatch.enabled |
FINERACT_MANAGEMENT_CLOUDWATCH_ENABLED |
false |
Whether to enable CloudWatch metrics export. |
management.metrics.export.cloudwatch.namespace |
FINERACT_MANAGEMENT_CLOUDWATCH_NAMESPACE |
fineract |
Namespace for CloudWatch metrics. |
management.metrics.export.cloudwatch.step |
FINERACT_MANAGEMENT_CLOUDWATCH_STEP |
1m |
Step size for CloudWatch metrics export. |
AWS Configuration Properties
For further understanding of the configuration properties related to AWS, refer to Spring Cloud AWS documentation.
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
spring.cloud.aws.endpoint |
FINERACT_AWS_ENDPOINT |
The AWS service endpoint. |
|
spring.cloud.aws.region.static |
FINERACT_AWS_REGION_STATIC |
us-east-1 |
The static region for AWS services. |
spring.cloud.aws.credentials.access-key |
FINERACT_AWS_CREDENTIALS_ACCESS_KEY |
The AWS access key. |
|
spring.cloud.aws.credentials.secret-key |
FINERACT_AWS_CREDENTIALS_SECRET_KEY |
The AWS secret key. |
|
spring.cloud.aws.credentials.instance-profile |
FINERACT_AWS_CREDENTIALS_INSTANCE_PROFILE |
false |
Whether to use the instance profile for credentials. |
spring.cloud.aws.credentials.profile.name |
FINERACT_AWS_CREDENTIALS_PROFILE_NAME |
The name of the AWS credentials profile. |
|
spring.cloud.aws.credentials.profile.path |
FINERACT_AWS_CREDENTIALS_PROFILE_PATH |
The path to the AWS credentials profile. |
Resilience4j Properties
For a deeper understanding of resilience4j, refer to the Official website
| Name | Env Variable | Default Value | Description |
|---|---|---|---|
fineract.retry.instances.executeCommand.max-attempts |
FINERACT_COMMAND_PROCESSING_RETRY_MAX_ATTEMPTS |
3 |
The number of attempts that resilience4j will attempt to execute a command after a failed execution. Refer to org. apache. fineract. commands. service. SynchronousCommandProcessingService#executeCommand for more details |
fineract.retry.instances.executeCommand.wait-duration |
FINERACT_COMMAND_PROCESSING_RETRY_WAIT_DURATION |
1s |
The fixed time value that the retry instance will wait before the next attempt can be made to execute a command |
fineract.retry.instances.executeCommand.enable-exponential-backoff |
FINERACT_COMMAND_PROCESSING_RETRY_ENABLE_EXPONENTIAL_BACKOFF |
true |
If set to true, the wait-duration will increase exponentially between each retry to execute a command |
fineract.retry.instances.executeCommand.exponential-backoff-multiplier |
FINERACT_COMMAND_PROCESSING_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER |
3 |
The multiplier for exponential backoff, this is useful only when enable-exponential-backoff is set to true |
fineract.retry.instances.executeCommand.retryExceptions |
FINERACT_COMMAND_PROCESSING_RETRY_EXCEPTIONS |
org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException,org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException |
This property specifies the list of exceptions that the execute command retry instance will retry on |
resilience4j.retry.instances.processJobDetailForExecution.max-attempts |
FINERACT_PROCESS_JOB_DETAIL_RETRY_MAX_ATTEMPTS |
3 |
The number of attempts that resilience4j will attempt to process job details for execution. Refer to org.apache.fineract.infrastructure.jobs.service.JobRegisterServiceImpl#processJobDetailForExecution for more details |
resilience4j.retry.instances.processJobDetailForExecution.wait-duration |
FINERACT_PROCESS_JOB_DETAIL_RETRY_WAIT_DURATION |
1s |
The fixed time value that the retry instance will wait before the next attempt can be made |
resilience4j.retry.instances.processJobDetailForExecution.enable-exponential-backoff |
FINERACT_PROCESS_JOB_DETAIL_RETRY_ENABLE_EXPONENTIAL_BACKOFF |
true |
If set to true, the wait-duration will increase exponentially between each retry to process job detail |
resilience4j.retry.instances.processJobDetailForExecution.exponential-backoff-multiplier |
FINERACT_PROCESS_JOB_DETAIL_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER |
2 |
The multiplier for exponential backoff, this is useful only when enable-exponential-backoff is set to true |
resilience4j.retry.instances.recalculateInterest.max-attempts |
FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_MAX_ATTEMPTS |
3 |
The number of attempts that resilience4j will attempt to run recalculate interest. Refer to org.apache.fineract.portfolio.loanaccount.service. LoanWritePlatformServiceJpaRepositoryImpl#recalculateInterest for more details |
resilience4j.retry.instances.recalculateInterest.wait-duration |
FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_WAIT_DURATION |
1s |
The fixed time value that the retry instance will wait before the next attempt can be made |
resilience4j.retry.instances.recalculateInterest.enable-exponential-backoff |
FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_ENABLE_EXPONENTIAL_BACKOFF |
true |
If set to true, the wait-duration will increase exponentially between each retry to recalculate interest |
resilience4j.retry.instances.recalculateInterest.exponential-backoff-multiplier |
FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER |
2 |
The multiplier for exponential backoff, this is useful only when enable-exponential-backoff is set to true |
resilience4j.retry.instances.recalculateInterest.retryException |
FINERACT_PROCESS_RECALCULATE_INTEREST_RETRY_EXCEPTIONS |
org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException |
This property specifies the list of exceptions that the recalculateInterest retry instance will retry on |
resilience4j.retry.instances.postInterest.max-attempts |
FINERACT_PROCESS_POST_INTEREST_RETRY_MAX_ATTEMPTS |
3 |
The number of attempts that resilience4j will attempt to run post interest. Refer to org.apache.fineract.portfolio.loanaccount.service. LoanWritePlatformServiceJpaRepositoryImpl#postInterest for more details |
resilience4j.retry.instances.postInterest.wait-duration= |
FINERACT_PROCESS_POST_INTEREST_RETRY_WAIT_DURATION |
1s |
The fixed time value that the retry instance will wait before the next attempt can be made |
resilience4j.retry.instances.postInterest.enable-exponential-backoff |
FINERACT_PROCESS_POST_INTEREST_RETRY_ENABLE_EXPONENTIAL_BACKOFF |
true |
If set to true, the wait-duration will increase exponentially between each retry to post interest |
resilience4j.retry.instances.postInterest.exponential-backoff-multiplier |
FINERACT_PROCESS_POST_INTEREST_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER |
2 |
The multiplier for exponential backoff, this is useful only when enable-exponential-backoff is set to true |
resilience4j.retry.instances.postInterest.retryExceptions |
FINERACT_PROCESS_POST_INTEREST_RETRY_EXCEPTIONS |
org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException |
This property specifies the list of exceptions that the post interest retry instance will retry on |