1. Why TimveroOS?
Building financial applications traditionally means months of development for basic features like payment processing, KYC compliance, and risk management. TimveroOS changes this by providing production-ready financial components that you customize through code, not configuration.
1.1. The Problem
-
6-12 months to build basic lending platform from scratch
-
Complex integrations with payment gateways, KYC providers, credit bureaus
-
Regulatory compliance requirements that slow development
-
Scalability challenges as your business grows
1.2. The TimveroOS Solution
-
2-4 weeks to customize and deploy with pre-built components
-
Built-in integrations for payments, documents, notifications
-
Compliance frameworks for KYC, AML, reporting built-in
-
Enterprise-scale architecture from day one
1.3. What You’ll Build
This SDK empowers developers to create financial applications through: * Entity-Form-Controller Pattern - Define business objects with automatic CRUD operations * Comprehensive Form System - Handle complex forms with validation, nested components, and MapStruct mapping * Credit Operations Framework - Manage loan lifecycle with automated calculations, accruals, and payments * Payment Transaction System - Process real-world payments through multiple gateways with complete audit trails * Entity Checkers - Implement event-driven business rules that respond to data changes automatically
For a complete working example, see the timvero-example project.
2. Learning Path
This guide is organized into logical parts that build on each other:
2.1. PART I: FOUNDATION
-
Platform Overview - Core concepts: Basic, Origination, and Servicing layers
-
Getting Started - Master the Entity-Form-Controller pattern with Client example
-
Data model setup - SQL autogeneration and Flyway migration workflows
2.2. PART II: USER INTERFACE
-
Form classes setup and usage - Form classes, validation, MapStruct mappers, and service layers
-
HTML Template Integration - Thymeleaf components, validation classes, and UI integration
-
Document Management - Document upload, requirements, and UI integration
-
Document Templates - Generate contracts, reports, and formatted documents
-
Notifications - Multi-channel automated notifications with template-based content
2.3. PART III: BUSINESS LOGIC
-
Entity Checkers setup and usage - Event-driven business rules and automation
-
DataSource Integration - External API integration and data enrichment
-
Workflow Integration - Integration for decision workflows
-
Offer Engine & Credit Products - Generate personalized loan offers from credit products and participant data
2.4. PART IV: FINANCIAL OPERATIONS
-
Credit Management System - Complete credit lifecycle management
-
Credit Operations Framework - Credit operations: charges, payments, accruals, past due processing
-
Payment Transactions Framework - Real-world payment processing with gateway integration
New to TimveroOS? Start with Part I and follow sequentially. Experienced developers can jump to specific parts based on their immediate needs. |
3. Platform Overview
Build lending applications fast with pre-built components.
3.1. What You Get
✅ Forms - Collect customer data with built-in validation ✅ Database - Automatic setup for loan data ✅ Payments - Connect to banks and card processors ✅ UI Components - Works on desktop and mobile ✅ Business Rules - Automate loan decisions
3.2. What You Build
🎯 Your loan products (personal loans, mortgages, etc.) 🎯 Your approval rules (credit scores, income requirements, etc.) 🎯 Your customer experience (application flow, portal design, etc.)
3.4. How It Works
Basic (foundation) → Origination (new loans) → Servicing (active loans)
Example: Personal loan process 1. Customer applies → forms, documents (Basic + Origination) 2. Loan approved → credit check, terms (Origination) 3. Loan created → account setup (Origination → Servicing) 4. Customer pays → balance updates (Servicing + Basic)
3.5. Key Platform Features
The platform provides several key features that make building lending applications easier:
1. Consistent Patterns
Everything follows the same pattern, so once you learn one part, you understand the whole platform:
-
Entities - Store your business data (customers, loans, payments)
-
Forms - Collect information from users with built-in validation
-
Controllers - Handle user actions like creating or editing records
-
Templates - Display information to users in web pages
Example: Whether you’re working with customers, loan applications, or payments, they all follow the same pattern.
2. Automatic Business Rules
The platform can automatically handle business logic for you:
-
When a loan application is approved → automatically create the loan account
-
When a payment is received → automatically update the loan balance
-
When a payment is late → automatically calculate late fees
-
When a loan is paid off → automatically close the account
Example: You define the rule "charge a $25 late fee if payment is 10 days late" and the platform handles it automatically.
3. Complete Audit Trail
Everything is tracked automatically for compliance and troubleshooting:
-
Who made each change and when
-
What the data looked like before and after changes
-
Why each change happened (payment received, fee charged, etc.)
-
Complete history of every loan account
Example: You can see that on March 15th, John Smith made a $500 payment, which reduced his loan balance from $5,000 to $4,500.
3.6. What You Need to Know
To get started with the platform, you need to understand a few key concepts:
1. Everything Builds on Basic
No matter what you’re building, you’ll always use Basic components: * Every application needs forms to collect data * Every application needs a database to store information * Every application needs templates to show information to users
Start here: Learn the Basic components first, then move to Origination or Servicing.
2. Choose Your Focus
Decide what type of application you’re building:
Building a loan application system? → Focus on Origination * Customer registration and applications * Credit scoring and risk assessment * Loan approval workflows
Building a loan management system? → Focus on Servicing * Payment processing and account management * Interest calculations and late fees * Customer account portals
Building both? → Start with Origination, then add Servicing
3. The Platform Does the Hard Work
You don’t need to build everything from scratch: * Database setup is automatic * Form validation is built-in * Payment processing is pre-built * Interest calculations are handled for you * Audit trails are automatic
Focus on: Your business rules, your loan products, and your customer experience.
3.7. Next Steps
Now that you understand the platform basics:
-
Start with Getting Started - Set up your first application and see how it works
-
Learn the Basic Components - Master forms, database, and templates (you’ll use these everywhere)
-
Pick your focus:
-
For loan applications: Learn about customers, applications, and risk assessment
-
For loan management: Learn about credits, payments, and operations
-
-
Build your application - Start simple and add features as you learn
The platform handles the complex technical details so you can focus on your business logic and customer experience.
4. Getting Started
This section provides a quick introduction to building applications with the Timvero SDK. Follow this guide to get up and running in minutes and understand the core concepts through practical examples.
4.1. Prerequisites
Before you begin, ensure you have the following installed:
-
Java 21 or later - The platform requires modern Java features
-
Maven 3.8+ - For dependency management and building
-
PostgreSQL 16+ - Primary database for the platform
-
IDE with Spring Boot support - IntelliJ IDEA, Eclipse, or VS Code
4.2. Quick Setup (5 minutes)
Step 1: Clone and Configure
-
Clone the example project:
git clone https://github.com/TimveroOS/timvero-example.git cd timvero-example
-
Configure database connection in
src/main/resources/application.properties
:spring.datasource.url=jdbc:postgresql://localhost:5432/your_database spring.datasource.username=your_username spring.datasource.password=your_password
-
Run the application:
mvn spring-boot:run
-
Access the application:
-
Admin UI: http://localhost:8081
-
Portal API: http://localhost:8082
-
Step 2: Verify Installation
Once running, you should see:
-
Database tables automatically created via Flyway migrations
-
Admin interface with navigation menu
-
Sample data (if configured)
-
No compilation errors in the console
Troubleshooting Setup Issues
Application Won’t Start
Problem: Application failed to start
with database connection errors
Solution:
-
Check database is running:
# PostgreSQL status check sudo systemctl status postgresql # Or for Docker docker ps | grep postgres
-
Verify database connection:
# Test connection manually psql -h localhost -p 5432 -U your_username -d your_database
-
Check application.properties:
# Ensure these match your database setup spring.datasource.url=jdbc:postgresql://localhost:5432/your_database spring.datasource.username=your_username spring.datasource.password=your_password
Common database URL mistakes: * Wrong port (default PostgreSQL is 5432) * Database name doesn’t exist * User lacks permissions
Flyway Migration Errors
Problem: FlywayException: Migration checksum mismatch
or migration failures
Solution:
-
Check migration file integrity:
-- View migration history SELECT * FROM flyway_schema_history ORDER BY installed_rank DESC;
-
Reset migrations (development only):
# WARNING: This deletes all data mvn flyway:clean flyway:migrate
-
Skip problematic migration (careful!):
# Only if you understand the implications mvn flyway:repair
Port Already in Use
Problem: Port 8081 was already in use
or similar port conflicts
Solution:
-
Find what’s using the port:
# Linux/Mac lsof -i :8081 # Windows netstat -ano | findstr :8081
-
Change application ports:
# In application.properties server.port=8090 management.server.port=8091
-
Kill conflicting process:
# Linux/Mac (replace PID with actual process ID) kill -9 PID # Windows taskkill /PID PID /F
Java Version Issues
Problem: UnsupportedClassVersionError
or compilation failures
Solution:
-
Check Java version:
java -version javac -version echo $JAVA_HOME
-
Ensure Java 21+:
# Install Java 21 if needed # Ubuntu/Debian sudo apt install openjdk-21-jdk # macOS with Homebrew brew install openjdk@21
-
Set JAVA_HOME:
# Linux/Mac - add to ~/.bashrc or ~/.zshrc export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # Windows - set in System Properties
Maven Build Failures
Problem: Maven dependency resolution or compilation errors
Solution:
-
Clean and rebuild:
mvn clean compile mvn clean install -U # Force update dependencies
-
Check Maven version:
mvn --version # Should be 3.8+
-
Clear local repository:
# Nuclear option - deletes all cached dependencies rm -rf ~/.m2/repository mvn clean install
Can’t Access Admin UI
Problem: Browser shows "This site can’t be reached" or connection refused
Solution:
-
Verify application started successfully:
# Check logs for "Started Application in X seconds" tail -f logs/application.log
-
Check correct URL:
# Default URLs Admin UI: http://localhost:8081 Portal API: http://localhost:8082 # NOT http://localhost:8080 (that's often Spring Boot default)
-
Check firewall/network:
# Test port connectivity telnet localhost 8081 # Or curl -I http://localhost:8081
Database Tables Not Created
Problem: Application starts but database is empty
Solution:
-
Verify migration files exist:
ls -la src/main/resources/db/migration/ # Should see V*.sql files
-
Check database permissions:
-- User needs privileges to execute migration ALTER DATABASE your_database OWNER TO your_username;
4.3. Your First Entity: Client Management (15 minutes)
Let’s explore how the Client entity demonstrates the platform’s core patterns. The Client entity is already implemented in the example project, so you can see a complete working example.
Entity Definition
The Client
entity demonstrates the platform’s entity structure:
@Entity
@Table
@Audited
@Indexed
public class Client extends AbstractAuditable<UUID> implements NamedEntity, HasDocuments {
@Embedded
@Valid
private IndividualInfo individualInfo;
@Embedded
@Valid
private ContactInfo contactInfo;
// getters and setters...
}
Key features:
* Extends AbstractAuditable
: Automatic creation/modification tracking
* Implements NamedEntity
: Provides display name functionality
* Composite structure: Contains IndividualInfo
and ContactInfo
components
* Search integration: @Indexed
enables full-text search
* Audit support: @Audited
tracks all changes
Form Structure
The ClientForm
handles user input with validation:
public class ClientForm {
@Valid
@NotNull
private IndividualInfoForm individualInfo;
@Valid
@NotNull
private ContactInfoForm contactInfo;
// getters and setters...
}
Benefits:
* Nested validation: @Valid
cascades validation to nested objects
* Clean separation: Form objects separate from entities
* Type safety: Strongly typed form fields
Controller Implementation
The main controller handles entity management:
@Controller
public class ClientController extends EntityController<UUID, Client, ClientForm> {
// Inherits all CRUD functionality automatically
}
Actions provide specific operations (buttons in the UI):
@Controller
public class CreateClientAction extends EntityCreateController<UUID, Client, ClientForm> {
@Override
protected boolean isOwnPage() {
return false;
}
}
@Controller
public class EditClientAction extends EditEntityActionController<UUID, Client, ClientForm> {
// Handles the edit button functionality
}
What you get automatically: * ✅ Create, Read, Update, Delete operations * ✅ Form validation and error handling * ✅ List view with search and filtering * ✅ Responsive web interface * ✅ Audit logging of all changes
Form Service Layer
The service layer handles business logic and data mapping:
@Service
public class ClientFormService extends EntityFormService<Client, ClientForm, UUID> {
// Inherits entity-form mapping and persistence operations
}
The service requires a corresponding MapStruct mapper for entity-form conversion:
@Mapper
public interface ClientFormMapper extends EntityToFormMapper<Client, ClientForm> {
// MapStruct automatically generates implementation for bidirectional mapping
}
Template Integration
The HTML template demonstrates the form component system:
<th:block th:insert="~{/form/components :: text(
#{client.individualInfo.fullName},
'individualInfo.fullName',
'v-required v-name')}" />
<th:block th:insert="~{/form/components :: text(
#{client.contactInfo.email},
'contactInfo.email',
'v-required v-email')}" />
4.4. Essential Concepts (10 minutes)
Entity-Form-Controller Pattern
The platform follows a consistent architectural pattern:
Component | Purpose | Example |
---|---|---|
Entity |
JPA entity with business logic and relationships |
|
Form |
DTO for user input with validation rules |
|
Controller |
Main entity controller providing CRUD operations |
|
Actions |
Specific operation buttons in the UI |
|
Service |
Business logic and entity-form mapping |
|
Mapper |
Automatic bidirectional object mapping |
|
Automatic Features
Once you create the basic structure following this pattern, the platform automatically provides:
-
CRUD Operations: Complete create, read, update, delete functionality
-
Form Validation: Client-side and server-side validation
-
Database Migrations: Automatic schema generation and versioning
-
Search and Filtering: Full-text search and advanced filtering
-
Audit Logging: Complete change history tracking
-
Responsive UI: Mobile-friendly web interface
-
Security Integration: Authentication and authorization
-
API Endpoints: RESTful API for external integration
Data Flow
Understanding the data flow helps you work effectively with the platform:
User Input → Form Validation → Controller → Service → Mapper → Entity → Database
↓
Template Rendering ← Form Object ← Mapper ← Entity ← Database Query
Troubleshooting Entity-Form-Controller Issues
Form Validation Not Working
Problem: Form submits with invalid data or validation messages don’t appear
Solution:
-
Check validation annotations:
// Ensure @Valid is present on nested objects @Valid @NotNull private IndividualInfoForm individualInfo;
-
Verify form component validation classes:
<!-- Ensure validation CSS classes are included --> <th:block th:insert="~{/form/components :: text( #{client.individualInfo.fullName}, 'individualInfo.fullName', 'v-required v-name')}" />
Controller Actions Not Appearing
Problem: Create/Edit buttons don’t show up in the UI
Solution:
-
Check controller annotations:
@Controller // Must be @Controller, not @RestController public class CreateClientAction extends EntityCreateController<UUID, Client, ClientForm> { }
-
Check template includes actions:
<!-- Ensure action templates are included --> <div th:replace="~{/entity/actions :: entityActions}"></div>
Form Fields Not Displaying
Problem: Form renders but specific fields are missing or empty
Solution:
-
Check form component syntax:
<!-- Ensure proper Thymeleaf fragment syntax --> <th:block th:insert="~{/form/components :: text( #{client.individualInfo.fullName}, 'individualInfo.fullName', 'v-required v-name')}" />
-
Verify i18n message keys exist:
# In messages.properties client.individualInfo.fullName=Full Name
4.5. Common Scenarios (20 minutes)
Adding Custom Validation
Enhance the Client form with custom business rules:
public class ClientForm {
@NotBlank
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
private String fullName;
@NotBlank
@Email(message = "Please provide a valid email address")
private String email;
@NotBlank
@Phone(message = "Please provide a valid phone number")
private String phone;
@PastOrPresent(message = "Birth date cannot be in the future")
private LocalDate dateOfBirth;
}
Implementing Business Logic with Entity Checkers
Create automated workflows that respond to client changes:
@Component
public class ClientWelcomeChecker extends EntityChecker<Client> {
@Override
protected void registerListeners(CheckerListenerRegistry<Client> registry) {
// Trigger when a new client is created
registry.entityChange().inserted();
}
@Override
protected boolean isAvailable(Client client) {
// Only for clients with complete contact information
return client.getContactInfo() != null
&& client.getContactInfo().getEmail() != null;
}
@Override
protected void perform(Client client) {
// Send welcome email to new clients
emailService.sendWelcomeEmail(client);
log.info("Welcome email sent to client: {}", client.getIndividualInfo().getFullName());
}
}
Adding Document Management
Enable clients to upload required documents:
// 1. Make Client support documents
@Entity
public class Client extends AbstractAuditable<UUID> implements HasDocuments {
// Existing client implementation
}
// 2. Configure document types
@Configuration
public class ClientDocumentConfiguration {
public static final EntityDocumentType ID_DOCUMENT = new EntityDocumentType("ID_DOCUMENT");
public static final EntityDocumentType PROOF_OF_ADDRESS = new EntityDocumentType("PROOF_OF_ADDRESS");
@Bean
DocumentTypeAssociation<Client> clientRequiredDocuments() {
return DocumentTypeAssociation.forEntityClass(Client.class)
.required(ID_DOCUMENT)
.required(PROOF_OF_ADDRESS)
.build();
}
}
// 3. Add document management tab
@Controller
@Order(1000)
public class ClientDocumentsTab extends EntityDocumentTabController<Client> {
@Override
public boolean isVisible(Client client) {
return true; // Always show documents tab for clients
}
}
Integrating External Data Sources
Fetch additional data from external APIs:
// 1. Create a data source subject interface
public interface CreditCheckSubject {
String getNationalId();
String getFullName();
}
// 2. Implement the interface in your entity
@Entity
public class Client implements CreditCheckSubject {
@Override
public String getNationalId() {
return getIndividualInfo().getNationalId();
}
@Override
public String getFullName() {
return getIndividualInfo().getFullName();
}
}
// 3. Create the data source implementation
@Service("creditCheck")
public class CreditCheckDataSource implements MappedDataSource<CreditCheckSubject, CreditReport> {
@Override
public Class<CreditReport> getType() {
return CreditReport.class;
}
@Override
public Content getData(CreditCheckSubject subject) throws Exception {
// Call external credit check API
String response = creditCheckApi.checkCredit(
subject.getNationalId(),
subject.getFullName()
);
return new Content(response.getBytes(), MediaType.APPLICATION_JSON_VALUE);
}
@Override
public CreditReport parseRecord(Content data) throws Exception {
return objectMapper.readValue(data.getData(), CreditReport.class);
}
}
4.6. What’s Next?
Explore Advanced Features
Now that you understand the basics, dive deeper into specific areas:
-
Form Classes - Complex validation, nested forms, and custom components
-
Entity Checkers - Business rule automation and workflow triggers
-
Document Management - File uploads, document requirements, and digital signatures
-
DataSource Integration - External API integration and data enrichment
-
Template System - Custom UI components and advanced templating
Real-World Implementation Patterns
Study these complete examples in the project:
-
Client Onboarding: Complete customer registration with validation and document collection
-
Application Processing: Multi-step loan application workflow with automated decision making
-
Participant Management: Complex participant relationships with role-based permissions
-
Document Workflows: Digital signature processes with DocuSign integration
-
Risk Assessment: External data integration for credit scoring and fraud detection
Development Best Practices
-
Start Simple: Begin with basic CRUD operations, add complexity gradually
-
Follow Patterns: Use the established Entity-Form-Controller pattern consistently
-
Leverage Automation: Use Entity Checkers for business rules instead of manual processes
-
Test Thoroughly: The platform provides excellent testing support for all components
-
Monitor Performance: Built-in metrics and logging help optimize your application
Getting Help
-
Documentation: This guide covers all platform features in detail
-
Example Project: Every feature demonstrated with working code
-
Professional Support: Enterprise support available for production deployments
Next Steps Checklist
-
Create your first custom entity following the Client pattern
-
Add custom validation rules to your forms
-
Implement an Entity Checker for business logic automation
-
Set up document management for your entities
-
Integrate with an external data source
-
Customize the UI templates for your specific needs
-
Deploy to a staging environment for testing
You’re now ready to build powerful financial applications with the Timvero platform!
Next Chapter: Data model setup - SQL autogeneration and Flyway migration workflows
Related Chapters: * Form classes setup and usage - Form classes, validation, MapStruct mappers, and service layers * HTML Template Integration - Thymeleaf components, validation classes, and UI integration * Entity Checkers setup and usage - Entity Checkers for event-driven business rules
5. Data model setup
This section describes how to set up and manage the data model using SQL file autogeneration and Flyway migrations.
5.1. SQL File Autogeneration
The platform automatically generates SQL files based on your entity definitions. This process creates the necessary database schema files that can be used with Flyway for database migrations.
Automatic Generation Process
After the class is configured, run the application. The system will analyze changes in the data model of Java classes and generate an SQL script with the necessary changes V241012192920.sql
in the project’s home directory (application.home=path
), in the subdirectory hbm2ddl
.
The generation process works as follows:
-
Entity Analysis: The system scans all JPA entity classes for changes
-
Schema Comparison: Compares current entity definitions with the existing database schema
-
SQL Generation: Creates appropriate DDL statements (CREATE, ALTER, DROP) for detected changes
-
File Creation: Generates timestamped migration files in the
hbm2ddl
directory -
Migration Integration: Files can be moved to Flyway migration directory for deployment
Entity Definition Example
Let’s look at the Participant
entity as an example:
@Entity
@Table
@Audited
@Indexed
public class Participant extends AbstractAuditable<UUID> implements NamedEntity, GithubDataSourceSubject, HasDocuments,
ProcessEntity, DocusignSigner,
HasPendingDecisions {
public static final String DECISION_OWNER_TYPE = "PARTICIPANT";
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private ParticipantStatus status = ParticipantStatus.NEW;
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
private Set<ParticipantRole> roles;
@ManyToOne(fetch = EAGER)
@JoinColumn(nullable = false, updatable = false)
private Application application;
@ManyToOne(fetch = EAGER)
@JoinColumn(nullable = false, updatable = false)
private Client client;
@Column(nullable = false)
@Enumerated(STRING)
private Employment employment;
@Column(nullable = false)
@Enumerated(STRING)
private Periodicity howOftenIncomeIsPaid;
@Embedded
private MonetaryAmount totalAnnualIncome;
@Embedded
private MonetaryAmount monthlyOutgoings;
@Column
private String githubUsername;
// getters and setters...
}
Enum Definitions
The entity uses several enums that define the possible values:
public enum ParticipantStatus implements InEnum<ParticipantStatus> {
NEW,
IN_PROCESS,
MANUAL_APPROVAL,
APPROVED,
DECLINED,
VOID;
public boolean isActive() {
return !this.in(DECLINED, VOID);
}
}
public enum Employment {
EMPLOYED,
HOMEMAKER,
UNEMPLOYED,
RETIRED,
SELF_EMPLOYED
}
public enum Periodicity {
MONTHLY,
FORTNIGHTLY,
WEEKLY,
UNDEFINED
}
5.2. Flyway Migration Integration
Migration File Structure
Flyway migration files are stored in the src/main/resources/db/migration/
directory and follow the naming convention:
V{version}__{description}.sql
For example:
V250530170222__init.sql V250609220043__participantStatus.sql
Generated SQL Example
Based on the Participant
entity definition, the system generates the following SQL:
create table participant (
id uuid not null,
created_at timestamp(6) with time zone not null,
updated_at timestamp(6) with time zone not null,
employment varchar(255) not null,
how_often_income_is_paid varchar(255) not null,
monthly_outgoings_currency varchar(3),
monthly_outgoings_number numeric(19,2),
total_annual_income_currency varchar(3),
total_annual_income_number numeric(19,2),
created_by uuid,
updated_by uuid,
-- Foreign key to application table
application_id uuid not null,
-- Foreign key to client table
client_id uuid not null,
primary key (id)
);
-- Foreign key constraints for participant table
-- Links participant to their associated loan application
alter table if exists participant
add constraint FKa8akyngsbkcpy4ev19q53x56h
foreign key (application_id)
references application;
-- Links participant to their client profile containing personal information
alter table if exists participant
add constraint FKcmejtugfqk653qthh0jalsx54
foreign key (client_id)
references client;
Migration Workflow
-
Entity Definition: Define your entity classes with appropriate JPA annotations
-
Application Execution: Run the application to trigger the automatic analysis process
-
SQL Autogeneration: The platform analyzes entity changes and generates SQL scripts in the
hbm2ddl
subdirectory -
Migration File Preparation: Move generated SQL files from
hbm2ddl
to the Flyway migration directory (src/main/resources/db/migration/
) -
File Naming: Rename files to follow Flyway convention:
V{version}__{description}.sql
-
Flyway Execution: During application startup, Flyway executes pending migrations in version order
-
Schema Versioning: Database schema version is tracked automatically in the
schema_version
table
Best Practices
-
Incremental Changes: Create separate migration files for each schema change
-
Descriptive Names: Use clear, descriptive names for migration files
-
Testing: Test migrations on development environments before production
-
Rollback Strategy: Consider rollback scenarios when designing schema changes
Migration File Example
Here’s an actual migration file that adds participant status functionality:
-- Migration: Add participant status functionality
-- Add status column to audit table (for historical tracking)
alter table if exists aud_participant
add column status varchar(255);
-- Add status column to main participant table
alter table if exists participant
add column status varchar(255);
-- Set default status for all existing participants
update participant set status = 'NEW';
-- Make status column mandatory after setting default values
alter table if exists participant
alter column status set not null;
This approach ensures that your database schema evolves in a controlled, versioned manner while maintaining data integrity throughout the development lifecycle.
5.3. Troubleshooting Data Model Issues
SQL Generation Problems
No SQL Files Generated
Problem: Need to generate SQL migration files but no files appear in hbm2ddl
directory
Solution:
-
Check application.home property:
# In application.properties application.home=/path/to/your/project # Or use relative path application.home=.
-
Verify entities are detected:
// Ensure entities have proper annotations @Entity @Table(name = "your_table_name") public class YourEntity extends AbstractAuditable<UUID> { }
-
Check entity scanning configuration:
// Ensure entities are in scanned packages @EntityScan(basePackages = {"com.yourpackage.entity"}) @SpringBootApplication public class Application { }
Flyway Migration Issues
Wrong Migration Order
Problem: Migrations execute in wrong order due to versioning issues
Solution:
-
Check version numbering:
# Correct format: VyyyyMMddHHmmss__description.sql V250610120000__add_participant_status.sql V250610130000__add_participant_employment.sql # Wrong (will execute in wrong order): V1__add_status.sql V10__add_employment.sql # This executes before V2!
-
Use consistent timestamp format:
# Generate timestamp for new migration date +"%y%m%d%H%M%S" # Use this in filename: V250610143022__your_description.sql
-
Fix ordering with new migration:
-- If wrong order was applied, create corrective migration -- V250610150000__correct_previous_changes.sql
Database Connection Issues
Connection Pool Exhaustion
Problem: HikariPool: Connection is not available
errors
Solution:
-
Check connection pool settings:
# In application.properties spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=20000 spring.datasource.hikari.idle-timeout=300000
-
Monitor connection usage:
# Enable connection pool metrics spring.datasource.hikari.register-mbeans=true management.endpoints.web.exposure.include=metrics
-
Check for connection leaks:
// Ensure @Transactional is used properly @Service @Transactional // This ensures connections are properly closed public class YourService { }
Database Lock Issues
Problem: Migrations hang or fail with lock timeout errors
Solution:
-
Check for long-running transactions:
-- PostgreSQL: Find blocking queries SELECT pid, usename, application_name, state, query FROM pg_stat_activity WHERE state != 'idle' ORDER BY query_start;
-
Kill blocking sessions (carefully):
-- Terminate specific session SELECT pg_terminate_backend(12345); -- Replace with actual PID
Performance Issues
Slow Entity Loading
Problem: Entity queries are slow or cause N+1 query problems
Solution:
-
Add database indexes using Hibernate annotations:
@Entity @Table(name = "participants", indexes = { @Index(name = "idx_participants_status", columnList = "status"), @Index(name = "idx_participants_employment", columnList = "employment"), @Index(name = "idx_participants_application_id", columnList = "application_id") }) public class Participant extends AbstractAuditable<UUID> { @Enumerated(EnumType.STRING) @Column(name = "status") private ParticipantStatus status; @Enumerated(EnumType.STRING) @Column(name = "employment") private Employment employment; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "application_id") private Application application; }
Alternative: Add indexes via migration file:
-- V250610120000__add_performance_indexes.sql CREATE INDEX idx_participants_status ON participants(status); CREATE INDEX idx_participants_employment ON participants(employment); CREATE INDEX idx_participants_application_id ON participants(application_id);
-
Use proper fetch strategies:
@Entity public class Participant { @ManyToOne(fetch = FetchType.LAZY) // Don't use EAGER unless necessary private Application application; @OneToMany(fetch = FetchType.LAZY, mappedBy = "participant") private List<Document> documents; }
-
Enable query logging to diagnose:
# In application.properties (development only) spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true logging.level.org.hibernate.SQL=DEBUG
6. Form classes setup and usage
This section describes how to set up and manage form classes for data input validation and processing in the application.
6.1. Form Class Architecture
The platform uses form classes to handle user input validation, data binding, and form processing. Form classes serve as DTOs (Data Transfer Objects) that define the structure and validation rules for user interfaces.
Form Class Hierarchy
The application uses a hierarchical form structure:
-
Main Forms: Top-level forms like
ClientForm
andApplicationForm
-
Nested Forms: Component forms like
IndividualInfoForm
andContactInfoForm
-
Validation: Bean Validation (JSR-303) annotations for field validation
Form Class Examples
ClientForm Structure
The ClientForm
class handles client registration and profile management:
@Valid
@NotNull
private IndividualInfoForm individualInfo;
@Valid
@NotNull
private ContactInfoForm contactInfo;
ApplicationForm Structure
The ApplicationForm
class manages loan application data:
@Valid
private ParticipantForm borrowerParticipant;
Nested Form Components
Personal information component:
@NotBlank
private String nationalId;
@NotBlank
private String fullName;
@PastOrPresent
@DateTimeFormat(pattern = ValidationUtils.PATTERN_DATEPICKER_FORMAT)
private LocalDate dateOfBirth;
@NotNull
private Country residenceCountry;
Validation Annotations Used
The form classes use standard Bean Validation (JSR-303) annotations:
@NotNull // Field cannot be null
@NotBlank // String field cannot be null, empty, or whitespace only
@Email // Valid email format
@PastOrPresent // Date must be in the past or present
@Valid // Cascade validation to nested objects
@Phone // Custom phone validation (platform-specific)
6.2. Form Processing Architecture
Action Classes
The platform uses generic action classes to handle form operations:
@Controller
public class CreateClientAction extends EntityCreateController<UUID, Client, ClientForm> {
@Override
protected boolean isOwnPage() {
return false;
}
}
@Controller
public class EditClientAction extends EditEntityActionController<UUID, Client, ClientForm> {
}
These actions are parameterized with:
* ID Type: UUID
- The entity identifier type
* Entity Type: Client
- The JPA entity class
* Form Type: ClientForm
- The form DTO class
Form Service Layer
Actions delegate form processing to specialized service classes:
@Service
public class ClientFormService extends EntityFormService<Client, ClientForm, UUID> {
The EntityFormService
provides:
* Entity to Form mapping: Converting entities to form objects for editing
* Form to Entity mapping: Converting form submissions to entity objects
* Validation integration: Coordinating with Bean Validation
* Persistence operations: Saving and updating entities
MapStruct Mappers
Form-to-entity conversion is handled by MapStruct mappers:
@Mapper
public interface ClientFormMapper extends EntityToFormMapper<Client, ClientForm> {
@Mapper(uses = ParticipantFormMapper.class)
public interface ApplicationFormMapper extends EntityToFormMapper<Application, ApplicationForm> {
@Mapper
public interface ParticipantFormMapper extends EntityToFormMapper<Participant, ParticipantForm> {
MapStruct automatically generates implementation classes that provide: * Bidirectional mapping: Entity ↔ Form conversion * Nested object mapping: Automatic handling of complex object structures * Type conversion: Automatic conversion between compatible types * Null handling: Safe mapping of optional fields
For detailed information about MapStruct features and configuration, see the official MapStruct documentation.
Processing Flow
The complete form processing flow:
-
Action Invocation:
CreateClientAction
orEditClientAction
is called -
Service Delegation: Action delegates to
ClientFormService
-
Mapper Usage: Service uses
ClientFormMapper
for conversions -
Entity Operations: Service performs database operations
-
Response Generation: Converted data is returned to the controller
EditClientAction<UUID, Client, ClientForm> ↓ ClientFormService.prepareEditModel(UUID id) ↓ ClientFormMapper.entityToForm(Client entity) ↓ ClientForm (ready for template rendering)
CreateClientAction<UUID, Client, ClientForm> ↓ ClientFormService.save(ClientForm form) ↓ ClientFormMapper.formToEntity(ClientForm form) ↓ Client entity (persisted to database)
6.3. Template Integration
Form classes integrate with HTML templates using Thymeleaf for rendering user interfaces. The templates use nested field access (dot notation) and reusable form components for consistent styling and validation.
For detailed information about HTML template integration, form components, and Thymeleaf usage, see HTML Template Integration.
6.4. Troubleshooting Form Issues
Form Validation Problems
Server-Side Validation Not Working
Problem: Invalid data reaches the service layer despite validation annotations
Solution:
-
Check @Valid annotations are present:
// On controller method parameters @PostMapping public String save(@Valid @ModelAttribute ClientForm form, BindingResult result) { if (result.hasErrors()) { return "client/edit"; } // ... }
-
Verify nested object validation:
public class ClientForm { @Valid // This is crucial for nested validation @NotNull private IndividualInfoForm individualInfo; @Valid // Don't forget this @NotNull private ContactInfoForm contactInfo; }
-
Check validation annotations are correct:
public class IndividualInfoForm { @NotBlank(message = "Full name is required") @Size(max = 255, message = "Full name cannot exceed 255 characters") private String fullName; @Email(message = "Please provide a valid email address") private String email; }
MapStruct Mapping Issues
Compilation Errors
Problem: No property named "X" exists in source parameter
or similar MapStruct errors
Solution:
-
Verify property names match exactly:
// Entity public class Client { private IndividualInfo individualInfo; // Property name public IndividualInfo getIndividualInfo() { return individualInfo; } } // Form - property name must match public class ClientForm { private IndividualInfoForm individualInfo; // Same name public IndividualInfoForm getIndividualInfo() { return individualInfo; } }
-
Check nested object mapping:
@Mapper public interface ClientFormMapper extends EntityToFormMapper<Client, ClientForm> { // Explicit mapping may be needed for complex cases @Mapping(source = "individualInfo.fullName", target = "individualInfo.fullName") @Mapping(source = "contactInfo.email", target = "contactInfo.email") ClientForm entityToForm(Client entity); }
-
Rebuild after changes:
# MapStruct generates code at compile time mvn clean compile # Check generated classes in target/generated-sources/annotations/
7. HTML Template Integration
This section describes how form classes integrate with HTML templates using Thymeleaf for rendering user interfaces.
7.1. Template Structure
The application uses Thymeleaf templates to render forms with automatic data binding and validation integration.
Client Form Template
The client edit form demonstrates nested form structure:
<h2 class="form-group__title" th:text="#{client.clientInfo}">Personal
Information</h2>
<th:block
th:insert="~{/form/components :: text(#{client.individualInfo.fullName},
'individualInfo.fullName', 'v-required v-name')}"
th:with="maxlength = 120" />
<th:block
th:insert="~{/form/components :: text(#{client.individualInfo.nationalId},
'individualInfo.nationalId', 'v-required')}" />
<th:block
th:insert="~{/form/components :: date (#{client.individualInfo.birthDate},
'individualInfo.dateOfBirth', '')}"
th:with="maxDate = ${#dates.format(#dates.createNow())}" />
<th:block
th:insert="~{/form/components :: select(#{client.address.stateOfResidence},
'individualInfo.residenceCountry', ${countries})}" />
<h2 class="form-group__title" th:text="#{client.contactInfo}">Contact
Information</h2>
<th:block
th:insert="~{/form/components :: text(#{client.contactInfo.email},
'contactInfo.email', 'v-required v-email')}" />
<th:block
th:insert="~{/form/components :: text(#{client.contactInfo.phone},
'contactInfo.phone', 'v-required v-phone')}" />
Key features:
* Nested field access: Uses dot notation like individualInfo.fullName
* Validation classes: CSS classes for client-side validation (v-required
, v-email
)
* Component reuse: Uses Thymeleaf fragments for consistent field rendering
Application Form Template
The application edit form shows financial data handling:
<h2 th:text="#{application.borrowerInfo}">Borrower
Information</h2>
<th:block
th:insert="~{/form/components :: select(#{participant.employment},
'borrowerParticipant.employment', ${employmentTypes})}" />
<th:block
th:insert="~{/form/components :: select(#{participant.howOftenIncomeIsPaid},
'borrowerParticipant.howOftenIncomeIsPaid', ${periodicities})}" />
<h2 class="mt-10" th:text="#{participant.financialInfo}">Financial
Information</h2>
<th:block
th:insert="~{/form/components :: amount(#{participant.totalAnnualIncome},
'borrowerParticipant.totalAnnualIncome', 'v-required')}" />
<th:block
th:insert="~{/form/components :: amount(#{participant.monthlyOutgoings},
'borrowerParticipant.monthlyOutgoings', '')}" />
Features:
* Enum handling: Select dropdowns for employment
and periodicities
* Monetary amounts: Special amount
component for financial fields
* Nested participant: Access to borrowerParticipant
fields
7.2. Form Component System
The platform uses Thymeleaf fragments for consistent form rendering across all forms. These components are defined in /form/components.html
and provide standardized UI elements with built-in validation support.
Available Form Components
Text Input Component
~{/form/components :: text(name, fieldname, inputclass)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the input field (e.g., |
|
String |
Field path for data binding (e.g., |
|
String |
CSS validation classes (e.g., |
-
maxlength
- Maximum character limit (default: 256) -
minlength
- Minimum character limit (default: 0) -
placeholder
- Placeholder text for the input field
<th:block th:insert="~{/form/components :: text(
#{client.individualInfo.fullName},
'individualInfo.fullName',
'v-required v-armenian-name')}"
th:with="maxlength = 120, placeholder = #{placeholder.fullName}" />
Select Dropdown Component
~{/form/components :: select(name, fieldname, values)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the select field |
|
String |
Field path for data binding |
|
Collection/Map |
Options data (Map for key-value pairs, Collection for simple lists) |
<th:block th:insert="~{/form/components :: select(
#{client.address.stateOfResidence},
'individualInfo.residenceCountry',
${countries})}" />
Date Picker Component
~{/form/components :: date(name, fieldname, inputclass)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the date field |
|
String |
Field path for data binding |
|
String |
CSS validation classes (optional) |
-
maxDate
- Maximum selectable date -
minDate
- Minimum selectable date -
startDate
- Default selected date
<th:block th:insert="~{/form/components :: date(
#{client.individualInfo.birthDate},
'individualInfo.dateOfBirth',
'v-required')}"
th:with="maxDate = ${#dates.format(#dates.createNow())}" />
Amount/Currency Component
~{/form/components :: amount(name, fieldname, inputclass)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the amount field |
|
String |
Field path for data binding |
|
String |
CSS validation classes (e.g., |
-
inputAmountPrefix
- Prefix for field IDs (optional) -
currencies
- Available currency options
<th:block th:insert="~{/form/components :: amount(
#{participant.totalAnnualIncome},
'borrowerParticipant.totalAnnualIncome',
'v-required v-armenian-tax-id')}" />
Checkbox Component
~{/form/components :: checkbox(name, fieldname, inputclass)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the checkbox |
|
String |
Field path for data binding |
|
String |
CSS classes for styling/validation |
<th:block th:insert="~{/form/components :: checkbox(
#{client.agreeToTerms},
'agreeToTerms',
'v-required')}" />
Textarea Component
~{/form/components :: textarea(name, fieldname, inputclass)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the textarea |
|
String |
Field path for data binding |
|
String |
CSS validation classes |
-
rows
- Number of textarea rows (default: 5) -
maxlength
- Maximum character limit
<th:block th:insert="~{/form/components :: textarea(
#{application.comments},
'comments',
'v-required')}"
th:with="rows = 3, maxlength = 500" />
Radio Button Component
~{/form/components :: radio(name, fieldname, params)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the radio group |
|
String |
Field path for data binding |
|
Map |
Key-value pairs for radio options |
<th:block th:insert="~{/form/components :: radio(
#{client.gender},
'gender',
${genderOptions})}" />
File Upload Component
~{/form/components :: fileInput(name, filelabel, fieldname, inputclass)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the file input |
|
String |
Button text for file selection |
|
String |
Field path for data binding |
|
String |
CSS classes for styling/validation |
<th:block th:insert="~{/form/components :: fileInput(
#{document.upload},
#{button.chooseFile},
'documentFile',
'v-required')}" />
Read-only Component
~{/form/components :: readonly(name, fieldname, inputclass)}
Parameter | Type | Description |
---|---|---|
|
String (i18n key) |
Label text for the read-only field |
|
String |
Field path for data binding |
|
String |
CSS classes for styling |
<th:block th:insert="~{/form/components :: readonly(
#{client.id},
'id',
'')}" />
Component Benefits
This component system ensures: * Consistency: All forms use the same styling and behavior * Maintainability: Changes to form components affect all forms * Validation Integration: Client-side validation works seamlessly * Accessibility: Standard form components ensure accessibility compliance * Internationalization: Built-in support for i18n message keys * Reusability: Components can be used across different forms and contexts
7.3. Client-Side Validation Classes
The platform provides CSS-based validation classes that integrate with jQuery Validation for client-side form validation:
Standard Validation Classes
CSS Class | Description | Usage Example |
---|---|---|
|
Field is mandatory and cannot be empty |
|
|
Field must contain a valid number |
|
|
Field must contain only digits (0-9) |
|
|
Field must contain a valid email address |
|
|
Field must contain a valid URL |
|
|
Field must contain a valid phone number |
|
|
Field must contain a positive number (> 0) |
|
|
Field must contain valid name characters (letters, spaces, hyphens, apostrophes), max 256 characters |
|
Custom Validation Methods
The platform extends jQuery Validation with custom validation methods:
// Armenian name validation (Armenian letters, spaces, hyphens only)
$.validator.addMethod('armenianName', function(value, element) {
const ARMENIAN_NAME_REGEX = /^[\u0531-\u0556\u0561-\u0587\s\-']+$/;
return this.optional(element) || ARMENIAN_NAME_REGEX.test(value);
});
// Tax identification number validation (Armenian format)
$.validator.addMethod('armenianTaxId', function(value, element) {
const TAX_ID_REGEX = /^\d{8}$/;
return this.optional(element) || TAX_ID_REGEX.test(value);
});
// Armenian postal code validation
$.validator.addMethod('armenianPostal', function(value, element) {
const POSTAL_REGEX = /^\d{4}$/;
return this.optional(element) || POSTAL_REGEX.test(value);
});
Validation Class Rules Mapping
The CSS classes are mapped to validation rules using jQuery Validation:
$.validator.addClassRules({
'v-armenian-name': {armenianName: true, maxlength: 256},
'v-armenian-tax-id': {armenianTaxId: true},
'v-armenian-postal': {armenianPostal: true},
});
Usage in Templates
Validation classes are applied as the third parameter in form component calls:
<!-- Required text field with name validation -->
<th:block th:insert="~{/form/components :: text(
#{client.individualInfo.fullName},
'individualInfo.fullName',
'v-required v-name')}" />
<!-- Required email field -->
<th:block th:insert="~{/form/components :: text(
#{client.contactInfo.email},
'contactInfo.email',
'v-required v-email')}" />
<!-- Required positive amount field -->
<th:block th:insert="~{/form/components :: amount(
#{participant.totalAnnualIncome},
'borrowerParticipant.totalAnnualIncome',
'v-required v-positive')}" />
Combining Validation Classes
Multiple validation classes can be combined using space separation:
-
'v-required v-email'
- Required email field -
'v-required v-name'
- Required name field with character validation -
'v-required v-positive'
- Required positive number field -
'v-number v-positive'
- Optional positive number field
8. Document Management
This section describes how to implement document management functionality for entities in the platform, including document type associations and UI integration.
8.1. Document System Overview
The document management system allows entities to have associated documents with configurable upload and requirement rules. The system consists of:
-
HasDocuments
- Interface marking entities that can have documents -
DocumentTypeAssociation
- Configuration for document types per entity -
EntityDocumentTabController
- UI integration for document management tabs
8.2. Document Type Configuration
Document types are configured using DocumentTypeAssociation
with a builder pattern that allows defining uploadable and required document types with conditional logic.
Document Type Associations
Required Document Configuration
Documents that must be uploaded based on entity conditions:
public static final EntityDocumentType OTHER = new EntityDocumentType("OTHER");
public static final EntityDocumentType ID_SCAN = new EntityDocumentType("ID_SCAN");
private static final Predicate<Participant> PARTICIPANT_GUARANTOR =
participant -> participant.getRoles().contains(ParticipantRole.GUARANTOR);
private static final Predicate<Participant> PARTICIPANT_BORROWER =
participant -> participant.getRoles().contains(ParticipantRole.BORROWER);
@Bean
DocumentTypeAssociation<Participant> idScanDocumentTypeAssociations() {
return DocumentTypeAssociation.forEntityClass(Participant.class).required(ID_SCAN)
This configuration:
* Makes ID_SCAN
document required
* Only applies when participant status is NEW
* Only applies to participants with GUARANTOR
or BORROWER
roles
8.3. UI Integration
Document Tab Implementation
To display document management interface, create a tab controller extending EntityDocumentTabController
:
package com.timvero.example.admin.participant.tab;
import com.timvero.example.admin.participant.entity.Participant;
import com.timvero.web.common.tab.EntityDocumentTabController;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Controller;
@Controller
@Order(1500)
public class ParticipantDocumentsTab extends EntityDocumentTabController<Participant> {
@Override
public boolean isVisible(Participant entity) {
return true;
}
}
Key features:
* @Order(1500)
- Controls tab display order in the UI
* isVisible()
- Determines when the tab should be shown
* Automatic functionality - Upload, download, and delete operations are handled automatically
8.4. Builder Pattern Usage
The DocumentTypeAssociation
uses a fluent builder pattern:
Available Methods
-
uploadable(EntityDocumentType)
- Adds a document type that can be uploaded -
required(EntityDocumentType)
- Adds a document type that must be uploaded -
predicate(Predicate<E>)
- Adds conditional logic for when the association applies
Predicate Chaining
Multiple predicates can be combined:
new SignableDocumentType("APPLICATION_CONTRACT", ApplicationContractDocumentCategory.TYPE);
public static final EntityDocumentType OTHER = new EntityDocumentType("OTHER");
public static final EntityDocumentType ID_SCAN = new EntityDocumentType("ID_SCAN");
Predicates are combined using AND logic - all conditions must be true.
8.5. Complete Implementation Example
To implement document management for an entity:
-
Entity implements HasDocuments:
@Entity public class Participant implements HasDocuments { // Entity implementation }
-
Create document type configuration:
@Configuration public class ParticipantDocumentTypesConfiguration { @Bean DocumentTypeAssociation<Participant> requiredDocuments() { return DocumentTypeAssociation.forEntityClass(Participant.class) .required(ID_SCAN) .predicate(participant -> participant.getStatus() == ParticipantStatus.NEW) .build(); } @Bean DocumentTypeAssociation<Participant> optionalDocuments() { return DocumentTypeAssociation.forEntityClass(Participant.class) .uploadable(OTHER) .build(); } }
-
Create document tab controller:
@Controller @Order(1500) public class ParticipantDocumentsTab extends EntityDocumentTabController<Participant> { @Override public boolean isVisible(Participant entity) { return true; // Always show documents tab } }
This provides a complete document management system with conditional requirements and integrated UI.
9. Document Templates
This section covers creating and customizing document templates for contracts, reports, and other generated documents in the platform.
9.1. Template System Overview
The document template system enables automatic generation of formatted documents using entity data. The system consists of:
-
DocumentCategory
- Defines document types and data models -
DocumentTemplate
- Stores template content (HTML/TXT) -
DocumentModel
- Provides data structure for templates -
Template Engine - Processes templates with entity data
9.2. Creating Document Categories
Document categories define what data is available to templates and when documents can be generated.
Basic Document Category
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class ApplicationContractDocumentCategory
extends DocumentCategory<UUID, Participant, ContractDocumentModel> {
public static final DocumentType TYPE = new DocumentType("APPLICATION_CONTRACT");
@Override
public DocumentType getType() {
return TYPE;
}
@Override
protected ContractDocumentModel getModel(Participant participant) {
PaymentSchedule paymentSchedule = participant.getApplication().getPaymentSchedule();
return new ContractDocumentModel(participant.getApplication(), paymentSchedule);
}
Key components:
* DocumentType
- Unique identifier for the document type
* getModel()
- Provides data structure for template processing
* isSuitableTestEntity()
- Determines when document generation is available
Document Model Creation
The document model exposes entity data to templates:
@Override
protected boolean isSuitableTestEntity(Participant participant) {
return participant.getStatus() == ParticipantStatus.APPROVED
&& participant.getApplication().getPaymentSchedule() != null;
}
public static class ContractDocumentModel extends DocumentModel {
private Application application;
private PaymentSchedule paymentSchedule;
public ContractDocumentModel() {
}
public ContractDocumentModel(Application application, PaymentSchedule paymentSchedule) {
this.application = application;
this.paymentSchedule = paymentSchedule;
}
public Application getApplication() {
return application;
}
public PaymentSchedule getPaymentSchedule() {
return paymentSchedule;
}
Model features:
* Public getters - Expose data to template engine
* Computed properties - Derive values from entity data (e.g., getFirstPayment()
)
* Nested objects - Include related entities for complex templates
9.3. Template Content Creation
Templates are created using the WYSIWYG editor in the admin interface, which provides rich text formatting and HTML support.
Using the WYSIWYG Editor
The admin interface provides a visual editor for creating document templates:
-
Rich Text Formatting - Bold, italic, fonts, colors, alignment
-
HTML Support - Full HTML markup for advanced formatting
-
Variable Insertion - Insert dynamic variables using Pebble syntax
-
Preview Mode - See how templates render with sample data
Pebble Template Processing
Templates use Pebble template engine for dynamic content processing. Pebble provides:
-
Variable substitution -
{{ variable.property }}
-
Conditional logic -
{% if condition %}…{% endif %}
-
Loops -
{% for item in items %}…{% endfor %}
-
Filters -
{{ amount | currency }}
,{{ date | date('yyyy-MM-dd') }}
Basic Template Example
<h1>Loan Agreement</h1>
<p>Borrower: {{ application.client.individualInfo.firstName }} {{ application.client.individualInfo.lastName }}</p>
<p>Loan Amount: <strong>{{ application.requestedAmount }}</strong></p>
<h3>Payment Schedule</h3>
<table border="1">
<tr><th>Payment Date</th><th>Amount</th></tr>
{% for payment in paymentSchedule.payments.values %}
<tr>
<td>{{ payment.dueDate | date('yyyy-MM-dd') }}</td>
<td>{{ payment.amount }}</td>
</tr>
{% endfor %}
</table>
9.4. Template Variables and Functions
Available Variables
Templates have access to: * Model properties - All public getters from your DocumentModel * Built-in filters - Date formatting, number formatting, string manipulation * Global variables - Current date/time, system settings
Common Pebble Filters
Date Formatting:
{{ payment.dueDate | date('MMMM dd, yyyy') }} // March 15, 2024
{{ payment.dueDate | date('yyyy-MM-dd') }} // 2024-03-15
Number Formatting:
{{ amount | currency }} // $1,234.56
{{ interestRate | numberformat('#,##0.00%') }} // 5.50%
{{ amount | numberformat('#,##0.00') }} // 1,234.56
String Formatting:
{{ client.name | upper }} // JOHN DOE
{{ client.name | lower }} // john doe
{{ client.name | title }} // John Doe
Conditional Content:
{% if application.status == 'APPROVED' %}
This loan has been approved.
{% else %}
This loan is pending review.
{% endif %}
Loops:
{% for participant in participants %}
{{ participant.role }}: {{ participant.client.displayName }}
{% endfor %}
9.5. Template Management
9.6. Advanced Features
Document Queries with EntityDocumentFinder
The EntityDocumentFinder
service provides powerful document querying capabilities for conditional template logic and document management.
Document Type Distinction
The system has two types of documents:
-
EntityDocumentType
- Regular uploaded documents (PDFs, images, etc.) -
SignableDocumentType
- Generated documents that require signatures
Methods work with different types:
* Document existence: EntityDocumentType
* Signature operations: SignableDocumentType
only
Checking Document Existence
Use EntityDocumentFinder
to check if documents exist before generation:
@Service
public class ContractGenerationService {
@Autowired
private EntityDocumentFinder documentFinder;
// Document type constants
private static final EntityDocumentType ID_SCAN = new EntityDocumentType("ID_SCAN");
private static final EntityDocumentType INCOME_PROOF = new EntityDocumentType("INCOME_PROOF");
private static final SignableDocumentType CONTRACT_TYPE = new SignableDocumentType("CONTRACT");
public boolean canGenerateContract(Participant participant) {
// Check if required documents are present (EntityDocumentType)
boolean hasIdScan = documentFinder.isPresent(participant, ID_SCAN);
boolean hasIncomeProof = documentFinder.isPresent(participant, INCOME_PROOF);
// Check if contract already exists and is signed (SignableDocumentType)
boolean contractSigned = documentFinder.isLatestSigned(participant, CONTRACT_TYPE);
return hasIdScan && hasIncomeProof && !contractSigned;
}
}
Document Status in Templates
Access document information in your DocumentModel:
public class ContractDocumentModel extends DocumentModel {
private Participant participant;
private EntityDocumentFinder documentFinder;
// Document type constants
private static final SignableDocumentType CONTRACT_TYPE = new SignableDocumentType("CONTRACT");
private static final SignableDocumentType PREVIOUS_CONTRACT_TYPE = new SignableDocumentType("PREVIOUS_CONTRACT");
public ContractDocumentModel(Participant participant, EntityDocumentFinder finder) {
this.participant = participant;
this.documentFinder = finder;
}
public boolean hasSignedPreviousContract() {
return documentFinder.isLatestSigned(participant, PREVIOUS_CONTRACT_TYPE);
}
public String getLastContractDate() {
return documentFinder.latest(participant, CONTRACT_TYPE)
.map(doc -> doc.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.orElse("No previous contract");
}
}
Use in templates:
{% if hasSignedPreviousContract %}
<p>This replaces your previous contract dated {{ lastContractDate }}.</p>
{% else %}
<p>This is your first contract with us.</p>
{% endif %}
Available Query Methods
Document Existence (EntityDocumentType):
* isPresent(owner, EntityDocumentType)
- Check if document exists
* isMissing(owner, EntityDocumentType)
- Check if document is missing
Document Retrieval:
* getDocuments(owner)
- Get all active documents
* latest(owner, EntityDocumentType)
- Get most recent document of type
* latest(owner, SignableDocumentType)
- Get most recent signable document
Signature Status (SignableDocumentType only):
* isLatestSigned(owner, SignableDocumentType)
- Check if latest document is signed
* signatureOfLatest(owner, SignableDocumentType)
- Get signature details
Multiple Document Templates
A single DocumentCategory can have multiple templates created in the admin interface. Generate documents using specific template IDs:
Single Document Category
@Component
public class ContractDocumentCategory extends DocumentCategory<UUID, Application, ContractDocumentModel> {
public static final DocumentType CONTRACT = new DocumentType("CONTRACT");
@Override
public DocumentType getType() {
return CONTRACT;
}
@Override
protected ContractDocumentModel getModel(Application application) {
return new ContractDocumentModel(application, application.getPaymentSchedule());
}
@Override
protected boolean isSuitableTestEntity(Application application) {
return application.getStatus() == ApplicationStatus.APPROVED;
}
}
Document Model with Conditional Logic
The DocumentModel can expose properties for template conditional logic:
public class ContractDocumentModel extends DocumentModel {
private Application application;
private PaymentSchedule schedule;
private LocalDate generationDate;
public ContractDocumentModel(Application application, PaymentSchedule schedule) {
this.application = application;
this.schedule = schedule;
this.generationDate = LocalDate.now();
}
public Application getApplication() {
return application;
}
public PaymentSchedule getSchedule() {
return schedule;
}
// Template conditional properties
public boolean isSecuredLoan() {
return application.isSecuredLoan();
}
public boolean isHighValueLoan() {
return application.getRequestedAmount().isGreaterThan(MonetaryAmount.of(100000, "USD"));
}
public String getContractType() {
if (isSecuredLoan()) {
return "Secured Loan Agreement";
}
return "Standard Loan Agreement";
}
// Secured loan specific data (null if not secured)
public String getCollateralDescription() {
return application.getCollateral() != null ?
application.getCollateral().getDescription() : null;
}
}
Multiple Templates in Admin Interface
Create different templates for the same DocumentCategory:
-
Standard Contract Template:
-
Name: "Standard Loan Contract"
-
Document Type: CONTRACT
-
Content: Basic loan terms without collateral sections
-
-
Secured Contract Template:
-
Name: "Secured Loan Contract"
-
Document Type: CONTRACT
-
Content: Includes collateral information using conditional logic
-
-
High-Value Contract Template:
-
Name: "High-Value Loan Contract"
-
Document Type: CONTRACT
-
Content: Additional compliance sections for large loans
-
Template Conditional Content
Templates use the same DocumentModel but show different content:
Standard Contract Template:
<h1>{{ contractType }}</h1>
<p>Loan Amount: {{ application.requestedAmount }}</p>
<p>Interest Rate: {{ application.condition.interestRate }}%</p>
{% if not isSecuredLoan %}
<h3>Unsecured Loan Terms</h3>
<p>This loan is not secured by collateral...</p>
{% endif %}
Secured Contract Template:
<h1>{{ contractType }}</h1>
<p>Loan Amount: {{ application.requestedAmount }}</p>
<p>Interest Rate: {{ application.condition.interestRate }}%</p>
{% if isSecuredLoan %}
<h3>Collateral Information</h3>
<p><strong>Description:</strong> {{ collateralDescription }}</p>
<p><strong>Value:</strong> {{ application.collateral.estimatedValue }}</p>
{% endif %}
Generating with Specific Templates
Generate documents using template IDs:
// Generate using specific template
SignableDocument document = documentService.generate(
application,
CONTRACT,
standardContractTemplateId // Specify which template to use
);
// Or for secured loans
SignableDocument securedDocument = documentService.generate(
application,
CONTRACT,
securedContractTemplateId // Different template, same DocumentCategory
);
This approach allows: * Single DocumentCategory - One category handles all contract variations * Multiple templates - Different layouts and content for same data * Template selection - Choose appropriate template based on business logic * Shared data model - Same DocumentModel serves all template variations
Custom Template Functions
Extend template capabilities with custom functions in your DocumentModel:
public class ContractDocumentModel extends DocumentModel {
// Format complex data for display
public String getFormattedLoanTerm() {
int months = application.getCondition().getTermInMonths();
return months == 12 ? "1 year" : months + " months";
}
// Business logic for conditional content
public boolean isHighRiskLoan() {
return application.getCondition().getInterestRate() > 15.0;
}
// Computed properties
public MonetaryAmount getMonthlyPayment() {
return schedule.getPayments().values().stream()
.findFirst()
.map(PaymentSegment::getAmount)
.orElse(MonetaryAmount.ZERO);
}
// Formatting helpers
public String getClientFullName() {
IndividualInfo info = application.getClient().getIndividualInfo();
return info.getFirstName() + " " + info.getLastName();
}
// Status checks
public boolean requiresAdditionalDocumentation() {
return isHighValueLoan() || isHighRiskLoan();
}
}
Use in templates:
<h1>{{ contractType }} - {{ formattedLoanTerm }}</h1>
<p>Borrower: {{ clientFullName }}</p>
<p>Monthly Payment: {{ monthlyPayment | currency }}</p>
{% if highRiskLoan %}
<div class="warning">
<strong>High Risk Loan</strong> - Additional terms apply
</div>
{% endif %}
{% if requiresAdditionalDocumentation %}
<p><em>Additional documentation may be required.</em></p>
{% endif %}
9.7. Integration with Signature Workflow
Signable Documents
For documents requiring signatures:
// Document generation creates SignableDocument
SignableDocument document = documentService.generate(
participant,
APPLICATION_CONTRACT,
templateId
);
// Document flows through signature process
// PENDING_SIGNATURE -> SIGNED -> contract complete
Signature Status Lifecycle
Documents progress through signature states:
-
GENERATION_FAILED
- Document generation failed -
PENDING_SIGNATURE
- Waiting for signature -
SIGNED
- Successfully signed -
REFUSE
- Signature refused -
REVOKE
- Signature revoked
DocumentSignature Extensions
The system supports different signature types through inheritance. Create custom signature implementations for specific workflows:
Physical Document Signature
For in-person or printed document signing:
@Entity
@Table(name = "physical_document_signature")
@DiscriminatorValue("PHYSICAL")
public class PhysicalDocumentSignature extends DocumentSignature {
}
Custom Signature Types
Create specialized signature classes for different signing methods:
@Entity
@Table(name = "electronic_document_signature")
@DiscriminatorValue("ELECTRONIC")
public class ElectronicDocumentSignature extends DocumentSignature {
@Column(name = "ip_address")
private String ipAddress;
@Column(name = "user_agent")
private String userAgent;
@Column(name = "signature_timestamp")
private Instant signatureTimestamp;
@Column(name = "verification_code")
private String verificationCode;
// Getters and setters
public String getIpAddress() { return ipAddress; }
public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; }
public String getUserAgent() { return userAgent; }
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
public Instant getSignatureTimestamp() { return signatureTimestamp; }
public void setSignatureTimestamp(Instant timestamp) { this.signatureTimestamp = timestamp; }
public String getVerificationCode() { return verificationCode; }
public void setVerificationCode(String code) { this.verificationCode = code; }
}
Signature Actions
Create action controllers to handle signature workflows:
@RequestMapping("/sign")
@Controller
@Order(1000)
public class SignDocumentAction extends SimpleActionController<UUID, SignableDocument> {
@Autowired
private ParticipantRepository participantRepository;
@Autowired
private EntityDocumentService entityDocumentService;
@Override
protected EntityAction<SignableDocument, Object> action() {
return when(d -> isRequiredDocAdded(d)
&& d.getStatus().in(SignatureStatus.PENDING_SIGNATURE)
&& d.getDocument() != null)
.then((document, f, u) -> {
Participant participant = participantRepository.getReferenceById(document.getOwnerId());
// Create appropriate signature type
PhysicalDocumentSignature signature = new PhysicalDocumentSignature();
signature.setSigner(participant.getDisplayedName());
signature.setEmail(participant.getClient().getContactInfo().getEmail());
signature.setPhone(participant.getClient().getContactInfo().getPhone());
document.setSignature(signature);
document.setStatus(SignatureStatus.SIGNED);
});
}
private boolean isRequiredDocAdded(SignableDocument d) {
Participant participant = participantRepository.getReferenceById(d.getOwnerId());
return entityDocumentService.requiredDocumentsAdded(participant);
}
}
Signature Integration
Templates can include signature placeholders and access signature information:
<div class="signature-section">
<p>Borrower Signature:</p>
<div class="signature-line">_________________________</div>
<p>Date: {{ generationDate }}</p>
{% if capturedSignature %}
<p><strong>Signed by:</strong> {{ signerName }}</p>
<p><strong>Signature Type:</strong> {{ capturedSignature.class.simpleName }}</p>
{% endif %}
</div>
Static Date Handling: Capture the generation date in the DocumentModel constructor, not in a getter:
public class ContractDocumentModel extends DocumentModel {
private LocalDate generationDate;
public ContractDocumentModel(Application application) {
this.generationDate = LocalDate.now(); // Captured at generation time
}
public String getGenerationDate() {
return generationDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
}
9.8. Complete Implementation Example
To implement a new document template:
-
Create DocumentCategory:
@Component public class LoanSummaryDocumentCategory extends DocumentCategory<UUID, Application, LoanSummaryModel> { public static final DocumentType LOAN_SUMMARY = new DocumentType("LOAN_SUMMARY"); @Autowired private EntityDocumentFinder documentFinder; @Override public DocumentType getType() { return LOAN_SUMMARY; } @Override protected LoanSummaryModel getModel(Application application) { // Capture dynamic data at generation time int docCount = documentFinder.getDocuments(application).size(); return new LoanSummaryModel(application, application.getPaymentSchedule(), docCount); } @Override protected boolean isSuitableTestEntity(Application application) { // Note: Using EntityDocumentType for document existence check EntityDocumentType contractEntityType = new EntityDocumentType("CONTRACT"); return application.getStatus() == ApplicationStatus.SERVICING && documentFinder.isPresent(application, contractEntityType); } }
-
Create Document Model (static data only):
public class LoanSummaryModel extends DocumentModel { private Application application; private PaymentSchedule schedule; private LocalDate generationDate; private int documentCount; // Captured at generation time public LoanSummaryModel(Application application, PaymentSchedule schedule, int docCount) { this.application = application; this.schedule = schedule; this.generationDate = LocalDate.now(); // Fixed at generation this.documentCount = docCount; // Static snapshot } public MonetaryAmount getTotalInterest() { return schedule.getPayments().values().stream() .map(PaymentSegment::getInterestAmount) .reduce(MonetaryAmount.ZERO, MonetaryAmount::add); } public String getGenerationDate() { return generationDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); } public int getDocumentCount() { return documentCount; // Static value } }
-
Create HTML Template in admin interface using model properties
-
Generate Documents using
DocumentService.generate()
This provides a complete document template system with rich formatting, dynamic content, and signature integration.
10. Notifications
This section covers creating and managing automated notifications for loan applications and credit accounts. The notification system provides multi-channel communication (EMAIL, SMS, VOICE, LETTER) with template-based content.
10.1. Notification System Overview
The notification system consists of:
-
NotificationEvent
- Defines when notifications are triggered -
NotificationModel
- Provides data to notification templates -
NotificationTemplate
- Database-stored templates with dynamic content -
Notification Gateways - Handle delivery via EMAIL, SMS, VOICE, LETTER
10.2. Creating Notification Events
Notification events define when and how notifications are sent to users.
Basic Notification Event
@Component
public class LoanApprovedEvent extends NotificationEvent<Application, NotificationModel> {
public static final NotificationEventType TYPE = new NotificationEventType("LOAN_APPROVED");
@Override
public NotificationEventType getType() {
return TYPE;
}
public boolean notify(UUID applicationId) {
return super.notify(applicationId, new NotificationModel());
}
@Override
protected boolean isSuitableTestEntity(Application application) {
return application.getStatus() == ApplicationStatus.SERVICING;
Key components:
* NotificationEventType
- Unique identifier for the event type
* getType()
- Returns the event type for template matching
* notify()
- Triggers notification with entity data
* isSuitableTestEntity()
- Determines when event can be tested
Event with Custom Data
public class LoanDeclinedEvent extends NotificationEvent<Application, LoanDeclineTemplateModel> {
public static final NotificationEventType TYPE = new NotificationEventType("LOAN_DECLINED");
@Override
public NotificationEventType getType() {
return TYPE;
}
public boolean notify(UUID applicationId, DeclineReason declineReason, boolean reapplicationEligible) {
LoanDeclineTemplateModel model = new LoanDeclineTemplateModel();
model.setDeclineReason(declineReason);
model.setReapplicationEligible(reapplicationEligible);
return super.notify(applicationId, model);
}
@Override
protected boolean isSuitableTestEntity(Application application) {
Custom template models provide specific data for templates:
private DeclineReason declineReason;
private boolean reapplicationEligible;
public DeclineReason getDeclineReason() {
return declineReason;
}
public void setDeclineReason(DeclineReason declineReason) {
this.declineReason = declineReason;
}
public boolean isReapplicationEligible() {
return reapplicationEligible;
}
public void setReapplicationEligible(boolean reapplicationEligible) {
this.reapplicationEligible = reapplicationEligible;
}
public Integer getReapplicationWaitDays() {
return reapplicationEligible ? 30 : 180;
Event with Parameters
Events can use EventParameters for template-configurable logic:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class PaymentReminderEvent extends NotificationEvent<ExampleCredit, PaymentReminderTemplateModel>
implements EntityEventListener<CreditCalculationDateChangeEvent> {
public static final NotificationEventType TYPE = new NotificationEventType("PAYMENT_REMINDER");
private static final String DAYS_COUNT = "daysCount";
private static final EventParameter DAYS_COUNT_PARAM = EventParameter.integerField("daysCount");
@Autowired
private PastDueOperationService pastDueService;
EventParameters allow admin users to configure notification behavior:
* daysCount
- Configurable number of days for payment reminders
* Used in entityMatchesTemplate()
to control when notifications are sent
10.3. Creating Notification Templates
Templates are created and managed through the admin interface, similar to document templates.
Creating New Template
-
Template Details:
-
Name: Descriptive name (e.g., "Loan Approval - Email")
-
Event Type: Select from dropdown (e.g., "LOAN_APPROVED")
-
Gateway Type: EMAIL, SMS, VOICE, or LETTER
-
Recipient Type: BORROWER, AGENT, etc.
-
Active: Check to enable template
-
-
Template Content: Use WYSIWYG editor with Pebble template syntax
Template Content Examples
Email Template (Loan Approval) - Minimal
Subject: Loan Approved - {{ application.id | slice(0, 8) | upper }}
Body:
<h2>Congratulations {{ application.client.individualInfo.firstName }}!</h2>
<p>Your {{ application.requestedAmount | currency }} loan has been approved at {{ application.condition.interestRate | numberformat('#,##0.00%') }}.</p>
<p>Documents will be ready within 2 business days.</p>
SMS Template (Payment Reminder)
{% if reminderType == 'UPCOMING' %}
Payment due {{ expectedPaymentDate | date('MM/dd') }}.
{% else %}
Payment overdue by {{ daysCount | abs }} days.
{% endif %}
Account: {{ credit.id | slice(0, 8) | upper }}
Pay online or call (555) 123-4567
Email Template (Loan Decline)
Subject: Loan Application Update
Body:
<h2>Dear {{ application.client.individualInfo.firstName }},</h2>
{% if declineReason %}
<p><strong>Status:</strong> {{ declineReason.description }}</p>
{% else %}
<p>Your application did not meet our current lending criteria.</p>
{% endif %}
<p>You may reapply after {{ reapplicationWaitDays }} days.</p>
<p>Questions? Call (555) 123-4567.</p>
10.4. Template Variables and Functions
Available Variables
Templates have access to: * Entity data - All properties from the associated entity (Application, ExampleCredit, etc.) * Model data - Custom data from NotificationModel subclasses * Built-in filters - Date formatting, number formatting, string manipulation
Common Pebble Filters
Date Formatting:
{{ application.createdAt | date('MMMM dd, yyyy') }} // March 15, 2024
{{ expectedPaymentDate | date('yyyy-MM-dd') }} // 2024-03-15
Number Formatting:
{{ application.requestedAmount | currency }} // $50,000.00
{{ application.condition.interestRate | numberformat('#,##0.00%') }} // 5.50%
String Operations:
{{ application.id | slice(0, 8) | upper }} // A1B2C3D4
{{ application.client.individualInfo.firstName | title }} // John
Conditional Content:
{% if application.status == 'SERVICING' %}
Your loan is now active.
{% else %}
Your application is being processed.
{% endif %}
Loops (for collections):
{% for document in application.documents %}
Document: {{ document.name }}
{% endfor %}
10.5. Event Parameters vs Custom Models
Use EventParameters when: Admin users need to configure when notifications send (timing, thresholds, conditions)
Use Custom Models when: Templates need additional data not available on the base entity
EventParameter Example
Payment reminders need admin-configurable timing:
public static final EventParameter DAYS_COUNT = EventParameter.integerField("daysCount");
public PaymentReminderEvent() {
super(DAYS_COUNT);
}
@Override
protected boolean entityMatchesTemplate(ExampleCredit credit, PaymentReminderTemplateModel model,
Map<String, Object> templateParams) {
Integer daysCount = (Integer) templateParams.get("daysCount");
model.setDaysCount(daysCount);
if (daysCount < 0) {
// Overdue reminder: check if credit is exactly |daysCount| days past due
return pastDueService.daysPastDue(credit) == -daysCount;
} else {
// Upcoming reminder: check if payment is exactly daysCount days away
return getNextPaymentDate(credit).equals(today.plusDays(daysCount));
}
}
10.6. Automatic Notifications
Entity Event Listeners
Events can implement EntityEventListener
to automatically trigger on system events:
@Component
public class PaymentReminderEvent extends NotificationEvent<ExampleCredit, PaymentReminderTemplateModel>
implements EntityEventListener<CreditCalculationDateChangeEvent> {
public PaymentReminderEvent() {
super(EventParameter.integerField("daysCount"));
}
@Override
public void handle(CreditCalculationDateChangeEvent event) {
notify(event.getCreditId(), new PaymentReminderTemplateModel());
}
@Override
protected boolean entityMatchesTemplate(ExampleCredit credit, PaymentReminderTemplateModel model,
Map<String, Object> templateParams) {
Integer daysCount = (Integer) templateParams.get("daysCount");
model.setDaysCount(daysCount);
if (daysCount < 0) {
// Overdue: check if exactly |daysCount| days past due
return pastDueService.daysPastDue(credit) == -daysCount;
} else {
// Upcoming: check if payment is exactly daysCount days away
return ChronoUnit.DAYS.between(today, nextPaymentDate) == daysCount;
}
}
}
This automatically triggers payment reminders when credit calculation dates change, with templates configured for specific day thresholds.
EntityChecker Integration
Notifications can be triggered automatically using EntityCheckers:
@Component
public class ApplicationStatusNotificationChecker extends EntityChecker<UUID, Application> {
@Autowired
private LoanApprovedEvent loanApprovedEvent;
@Autowired
private LoanDeclinedEvent loanDeclinedEvent;
@Override
protected void registerListeners() {
register(Application.class, EntityChangeEvent.Operation.UPDATE);
}
@Override
protected boolean isAvailable(Application application) {
return application.getStatus().in(ApplicationStatus.SERVICING, ApplicationStatus.DECLINE);
}
@Override
protected void perform(Application application) {
switch (application.getStatus()) {
case SERVICING -> loanApprovedEvent.notify(application.getId());
case DECLINE -> loanDeclinedEvent.notify(application.getId(),
application.getDeclineReason(), true);
}
}
}
10.7. Notification Gateways
Gateways handle actual delivery of notifications via EMAIL, SMS, VOICE, or LETTER channels.
Gateway Interface
public interface NotificationGateway {
NotificationGatewayType getType();
String send(Notification notification) throws Exception;
Map<String, NotificationStatus> check(Collection<String> messageIds) throws Exception;
}
Custom Implementation
Extend abstract gateways for specific providers:
@Component
public class TwilioSmsGateway extends AbstractSmsGateway {
@Override
protected String sendSms(String phone, String message) throws Exception {
// Integrate with Twilio API
Message twilioMessage = Message.creator(
new PhoneNumber(phone),
new PhoneNumber(fromPhoneNumber),
message
).create();
return twilioMessage.getSid();
}
@Override
public Map<String, NotificationStatus> check(Collection<String> messageIds) throws Exception {
// Check delivery status from Twilio
return messageIds.stream().collect(Collectors.toMap(
id -> id,
id -> getDeliveryStatus(id)
));
}
}
10.8. Internationalization (i18n)
The notification system integrates with Spring’s message system for localized labels and descriptions.
Event Type Labels
Event types are displayed in the admin interface using message keys:
# messages.properties
notification.event.type.APP_STATUS_CHANGE=Application Status Change
notification.event.type.PAYMENT_REMINDER=Payment Reminder
notification.event.type.LOAN_APPROVED=Loan Approved
notification.event.type.LOAN_DECLINED=Loan Declined
The pattern is: notification.event.type.{EVENT_TYPE_NAME}=Display Name
EventParameter Labels
EventParameter fields are labeled using their parameter names:
# messages.properties
notification.event.APP_STATUS_CHANGE.status=Status
notification.event.PAYMENT_REMINDER.daysCount=Days Count
notification.event.LOAN_DECLINED.declineReason=Decline Reason
The pattern is: notification.event.{EVENT_TYPE_NAME}.{PARAMETER_NAME}=Field Label
10.9. Testing Notifications
Template Testing
Use the admin interface to test templates:
-
Go to Notification Templates
-
Select a template
-
Click "Test Template"
-
Choose a suitable entity from the dropdown (filtered by
isSuitableTestEntity()
) -
Preview the rendered template
Programmatic Testing
@Test
public void testLoanApprovalNotification() {
// Create test application
Application application = createTestApplication();
application.setStatus(ApplicationStatus.SERVICING);
// Test notification
boolean sent = loanApprovedEvent.notify(application.getId());
assertTrue(sent);
}
10.10. Integration Examples
Scheduled Payment Reminders
@Component
public class PaymentReminderScheduler {
@Autowired
private PaymentReminderEvent paymentReminderEvent;
@Scheduled(cron = "0 0 9 * * ?") // Daily at 9 AM
public void sendPaymentReminders() {
// PaymentReminderEvent handles the business logic
// Templates configured with different daysCount values will
// automatically send appropriate reminders
List<ExampleCredit> activeCredits = creditRepository.findActiveCredits();
for (ExampleCredit credit : activeCredits) {
paymentReminderEvent.notify(credit.getId());
}
}
}
Workflow Integration
@Component
public class WorkflowCompletionListener implements EntityEventListener<ProcessExecutionFinishedEvent> {
@Autowired
private LoanApprovedEvent loanApprovedEvent;
@Override
public void handle(ProcessExecutionFinishedEvent event) {
if ("LOAN_APPROVAL_PROCESS".equals(event.getProcessDefinitionKey())) {
if ("APPROVED".equals(event.getResult())) {
loanApprovedEvent.notify(event.getEntityId());
}
}
}
}
10.11. Common Patterns
Event Design Decisions
// ✅ Good: Simple event, no custom data needed
public class LoanApprovedEvent extends NotificationEvent<Application, NotificationModel> {
public boolean notify(UUID applicationId) {
return super.notify(applicationId, new NotificationModel());
}
}
// ✅ Good: Custom data for template logic
public class LoanDeclinedEvent extends NotificationEvent<Application, LoanDeclineTemplateModel> {
public boolean notify(UUID applicationId, DeclineReason reason, boolean eligible) {
LoanDeclineTemplateModel model = new LoanDeclineTemplateModel();
model.setDeclineReason(reason);
model.setReapplicationEligible(eligible);
return super.notify(applicationId, model);
}
}
// ❌ Avoid: EventParameter for static data
public class BadEvent extends NotificationEvent<Application, NotificationModel> {
private static final EventParameter COMPANY_NAME = EventParameter.stringField("companyName");
// Use application properties or constants instead
}
Template Patterns
<!-- ✅ Good: Fallback for missing data -->
{{ application.client.individualInfo.firstName | default("Valued Customer") }}
<!-- ✅ Good: Safe navigation with conditionals -->
{% if application.condition %}
Rate: {{ application.condition.interestRate | numberformat('#,##0.00%') }}
{% endif %}
<!-- ❌ Avoid: Complex calculations in templates -->
<!-- Move business logic to NotificationModel methods -->
Integration Patterns
Automatic notifications: Use EntityChecker for state-driven events Scheduled reminders: Use EventParameters for admin-configurable timing Workflow notifications: Listen to process completion events
Performance Notes
-
Batch scheduled notifications - don’t send individually in loops
-
Use isSuitableTestEntity() to filter test data accurately
-
Monitor gateway delivery rates - EMAIL typically higher success than SMS
This notification system provides flexible, template-based communication that integrates seamlessly with your loan management workflow.
11. Entity Checkers setup and usage
This section describes how to set up and manage Entity Checkers for automated business rule validation and state management in the application.
11.1. Checker System Architecture
The platform uses Entity Checkers to implement event-driven business logic that automatically responds to entity changes. Checkers serve as reactive components that monitor database changes and execute business rules when specific conditions are met.
What are Entity Checkers?
Entity Checkers are specialized components that:
-
Monitor Entity Changes: Automatically detect when entities are created, updated, or deleted
-
Apply Business Rules: Execute predefined logic when specific conditions are satisfied
-
Maintain Data Consistency: Ensure related entities remain synchronized
-
Automate Workflows: Trigger next steps in business processes without manual intervention
Checker Class Hierarchy
The application uses a structured checker architecture based on framework components:
-
Base Checker Classes: Framework-provided abstract classes (
EntityChecker
) that handle the infrastructure -
Custom Entity Checkers: Application-specific implementations:
-
BorrowerStartTreeChecker
- manages borrower workflow initiation
-
Each checker consists of three main parts:
-
Listener Registration (
registerListeners
method) - defines what entity changes to monitor -
Availability Check (
isAvailable
method) - determines when the checker should execute -
Business Logic (
perform
method) - implements the actual business rule
11.2. Listener Registration
The registerListeners
method is the core mechanism for defining what entity changes a checker should monitor. This method uses the CheckerListenerRegistry
to configure event listeners that will trigger the checker’s business logic.
CheckerListenerRegistry
CheckerListenerRegistry<E>
is a registry for configuring event listeners for entity changes. The generic type E
represents the target entity type that the checker operates on (e.g., Application
, Participant
).
Key Methods
Method | Parameters | Description |
---|---|---|
|
|
Creates a listener for entity change events. See usage examples. |
|
(no parameters) |
Monitors the same entity type as the checker (shorthand). See usage examples. |
|
|
Filters to field update events only. |
|
(no parameters) |
Filters to entity insertion events only. |
|
|
Adds filtering conditions. Chain multiple conditions together. |
Using entityChange Method
The entityChange
method provides flexible entity-to-target mapping capabilities to solve different monitoring scenarios in checker implementations.
Problem: Your checker needs to monitor changes to the same entity type it operates on.
For example, an ApplicationChecker
that monitors Application
entity changes directly.
Solution:
registry.entityChange().updated("status")
This monitors changes to the same entity type as the checker operates on.
-
Simple checkers where trigger entity = target entity
-
Direct field monitoring without complex relationships
-
Most straightforward monitoring scenario
Problem: Your checker needs to monitor changes to different entity types and map them to the target entity through relationships.
For example, an ApplicationChecker
that triggers when related Participant
entities change, but needs to operate on the Application
.
Solution:
registry.entityChange(Participant.class, Participant::getApplication).updated("status")
-
Participant.class
- the entity type being monitored for changes -
Participant::getApplication
- function that converts the changedParticipant
to the targetApplication
-
Monitoring entities connected through direct JPA relationships
-
One-to-one or many-to-one relationships with getter methods
-
Related entities share the same transaction context
Problem: Your checker needs to monitor entities where the relationship requires repository lookup to resolve the target entity.
For example, monitoring SignableDocument
changes but needing to operate on the associated Participant
through a complex relationship.
Solution:
registry.entityChange(SignableDocument.class, d -> participantRepository.getReferenceById(d.getOwnerId()))
.updated("status")
-
SignableDocument.class
- the entity type being monitored -
d → participantRepository.getReferenceById(d.getOwnerId())
- repository-based mapping function
-
Complex relationships not modeled as direct JPA associations
-
Cross-context entity lookups
-
Dynamic relationship resolution based on entity state
Performance Consideration: Repository-based mapping introduces additional database queries. Use this approach only when direct relationship mapping is not possible. |
11.3. Checker Implementation Examples
BorrowerStartTreeChecker
The BorrowerStartTreeChecker
manages borrower workflow initiation when participants complete required documentation.
Component | Description |
---|---|
Target Entity |
|
Purpose |
Automatically start decision tree process when borrower completes all requirements |
Triggers |
Document signatures and uploads for required participant documents |
Business Logic |
Update participant status to |
Listener 1: Application Form Signature Monitor
Monitors when application forms are signed and triggers workflow initiation.
Purpose: Track completion of application form signatures
Trigger: SignableDocument.status
changes to SIGNED
for application forms
Target Resolution: Maps document changes to owning participant
registry.entityChange(SignableDocument.class, d -> participantRepository.getReferenceById(d.getOwnerId()))
.updated(SignableDocument_.STATUS)
.and(d -> d.getStatus() == SignatureStatus.SIGNED && d.getDocumentType() == ParticipantDocumentTypesConfiguration.APPLICATION_FORM);
Listener 2: Required Document Upload Monitor
Tracks when required documents are uploaded to the system.
Purpose: Monitor completion of required document uploads
Trigger: New EntityDocument
insertions for required document types
Target Resolution: Maps document uploads to owning participant
registry.entityChange(EntityDocument.class, d -> participantRepository.getReferenceById(d.getOwnerId()))
.inserted().and(d -> {
Participant participant = participantRepository.getReferenceById(d.getOwnerId());
return documentService.getRequiredDocumentTypes(participant).contains(d.getDocumentType());
});
Availability Check
Determines when the checker should execute its business logic.
@Override
protected boolean isAvailable(Participant participant) {
return needSignature(participant) && hasSignature(participant);
}
-
Participant must be a
BORROWER
-
Participant status must be
NEW
-
Application form must be signed
-
All required documents must be uploaded
Business Logic
Updates participant status and initiates the decision tree process.
@Override
protected void perform(Participant participant) {
participant.setStatus(ParticipantStatus.IN_PROCESS);
decisionProcessStarter.start(PARTICIPANT_TREE, participant.getId());
}
-
Status Update: Change participant status from
NEW
toIN_PROCESS
-
Process Initiation: Start the automated decision tree workflow
-
Transaction Safety: Both operations occur within the same transaction
12. DataSource Integration
External data integration powers modern lending decisions through the DataSource framework and Feature Store. DataSources fetch raw data from external APIs, while the Feature Store transforms this data into usable features for business logic and decision workflows.
12.1. Architecture Overview
The TimveroOS data integration follows a three-layer architecture:
-
DataSource Layer - Fetches raw data from external APIs
-
DataSourceManager - Manages caching, loading modes, and data lifecycle
-
Feature Store - Transforms raw data into business features through configurable mappings
External API → DataSource → DataSourceManager → Feature Store → Business Logic
Key Principle: DataSources should never be used directly. Always access them through DataSourceManager or Feature Store.
12.2. DataSource Framework
Core Interfaces
public interface DataSource<E extends DataSourceSubject> {
Content getData(E subject) throws Exception;
Duration lifespan(); // How long data remains valid
class Content {
private final byte[] body;
private final String mime;
// getters...
}
}
public interface MappedDataSource<E, T> extends DataSource<E> {
Class<T> getType(); // Target parsing type
T parseRecord(Content data); // Parse raw data to typed object
}
DataSourceManager - The Proper Access Layer
Never use DataSources directly. Always use DataSourceManager which provides:
-
Intelligent caching - Avoids redundant API calls
-
Loading modes - Control when to fetch vs use cached data
-
Data lifecycle - Automatic expiration and invalidation
-
Error handling - Graceful degradation when data unavailable
public interface DataSourceManager {
<E extends DataSourceSubject> Optional<DataSourceRecord> getData(
E entity, String dataSourceName, LoadingMode mode) throws IOException;
enum LoadingMode {
READ, // Use cached data only
QUERY, // Use cache or fetch if missing
FORCE // Always fetch fresh data
}
}
12.3. Complete Implementation Example: GitHub DataSource
The GitHub DataSource demonstrates a production-ready implementation that fetches user data from the GitHub API for risk assessment purposes.
Service Declaration
@Service(GithubDataSource.DATASOURCE_NAME)
public class GithubDataSource implements MappedDataSource<GithubDataSourceSubject, GithubUser> {
public static final String DATASOURCE_NAME = "github";
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
private final String GITHUB_API_BASE_URL = "https://api.github.com";
Key Points:
* @Service
with name - Makes DataSource discoverable by the platform
* Constant naming - Consistent reference for DataSource identification
* RestTemplate - Spring’s HTTP client for API calls
* ObjectMapper - Jackson for JSON parsing with flexible configuration
HTTP Configuration
{
objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
}
private HttpEntity<String> createHttpEntity() {
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/vnd.github.v3+json");
return new HttpEntity<>(headers);
}
Best Practices:
* Flexible JSON parsing - FAIL_ON_UNKNOWN_PROPERTIES = false
handles API changes
* API versioning - Explicit version headers ensure consistent responses
* Reusable headers - Centralized HTTP configuration
Data Retrieval Implementation
@Override
public Content getData(GithubDataSourceSubject subject) throws Exception {
try {
String url = GITHUB_API_BASE_URL + "/users/" + subject.getGithubUsername();
ResponseEntity<byte[]> response = restTemplate.exchange(
url,
HttpMethod.GET,
createHttpEntity(),
byte[].class
);
return new Content(response.getBody(), MediaType.APPLICATION_JSON_VALUE);
} catch (HttpClientErrorException.NotFound e) {
throw new DataUnavailableException("User not found: " + subject.getGithubUsername());
}
}
Implementation Details:
* URL construction - Safe string concatenation with subject data
* Byte array response - Preserves raw data for Content object
* Exception mapping - HTTP 404 becomes DataUnavailableException
* Media type preservation - Maintains content type for parsing
Type Information and Parsing
@Override
public Class<GithubUser> getType() {
return GithubUser.class;
}
@Override
public GithubUser parseRecord(Content data) throws IOException {
return objectMapper.readValue(data.getBody(), GithubUser.class);
}
Type Safety:
* Generic type preservation - getType()
enables runtime type checking
* Automatic parsing - Platform can automatically parse Content to target type
* Exception handling - Jackson exceptions bubble up as DataSource exceptions
12.4. Subject and Target Objects
Subject Interface
The subject defines what data to fetch from the external source:
public interface GithubDataSourceSubject {
String getGithubUsername();
}
Design Principles: * Interface-based - Allows multiple entities to implement the same subject * Minimal contract - Only required data for the external API call * Clear naming - Method names match the external API requirements
Target Data Class
The target object represents the parsed external data:
public class GithubUser {
private String login;
private String name;
@JsonProperty("followers")
private int followersCount;
@JsonProperty("following")
private int followingCount;
@JsonProperty("public_repos")
private int publicRepos;
@JsonProperty("avatar_url")
private String avatarUrl;
// Constructors, getters, and setters...
}
JSON Mapping:
* @JsonProperty
- Maps JSON field names to Java properties
* Selective fields - Only include relevant data for your application
* Type safety - Strong typing for external API responses
12.5. Entity Integration Pattern
The power of DataSources comes from integrating them directly with your business entities:
Entity Implementation
@Entity
public class Participant extends AbstractAuditable<UUID>
implements GithubDataSourceSubject {
@Column
private String githubUsername;
@Override
public String getGithubUsername() {
return githubUsername;
}
// Other entity fields and methods...
}
Integration Benefits: * Direct entity support - No additional mapping layers needed * Type safety - Compile-time checking of subject contracts * Automatic discovery - Platform can find applicable DataSources
Proper Usage Through DataSourceManager
❌ WRONG - Never use DataSources directly:
@Autowired
@Qualifier("github")
private MappedDataSource<GithubDataSourceSubject, GithubUser> githubDataSource;
// DON'T DO THIS - bypasses caching and lifecycle management
GithubUser data = githubDataSource.parseRecord(githubDataSource.getData(participant));
✅ CORRECT - Use DataSourceManager:
@Autowired
private DataSourceManager dataSourceManager;
public void enrichParticipantData(Participant participant) {
try {
Optional<DataSourceRecord> record = dataSourceManager.getData(
participant, "github", LoadingMode.QUERY);
if (record.isPresent()) {
GithubUser githubData = (GithubUser) record.get().getData();
assessDeveloperRisk(participant, githubData);
}
} catch (IOException e) {
log.warn("GitHub data unavailable for participant: {}", participant.getId());
// Continue without GitHub data
}
}
Loading Mode Benefits:
* READ
- Fast, uses only cached data for performance-critical paths
* QUERY
- Balanced, fetches if needed for standard workflows
* FORCE
- Fresh data for critical decisions or data refresh workflows
12.6. Feature Store Integration
The Feature Store is the primary way to consume DataSource data in business logic. It transforms raw external data into structured features through configurable field mappings.
What are Features?
A feature is a data transformation that converts raw data from integrated sources into a format usable by workflow decision logic:
-
Direct value extractions - Credit scores from bureau data
-
Calculated values - Debt-to-income ratios
-
Derived indicators - Payment pattern analysis
-
Aggregated metrics - Total outstanding debt
Feature Store Benefits
✅ Configurable transformations - Change feature extraction without code changes ✅ Automatic caching - Features are computed once and stored ✅ Version management - Track changes to feature definitions ✅ Type safety - Features have defined data types ✅ Error handling - Graceful handling of transformation failures ✅ Audit trail - Complete history of feature values ✅ Performance - Bulk feature extraction and caching
The Feature Store automatically uses DataSourceManager to fetch data with appropriate caching and lifecycle management, then applies configurable transformations to create business-ready features.
Note: Feature Store implementation and usage is covered in the Feature Store documentation. This chapter focuses on the underlying DataSource implementation that powers the Feature Store.
12.7. Advanced Patterns
Multiple DataSource Support
Entities can implement multiple subject interfaces for different data sources:
@Entity
public class Participant implements GithubDataSourceSubject, CreditBureauSubject {
@Override
public String getGithubUsername() {
return githubUsername;
}
@Override
public String getNationalId() {
return getClient().getIndividualInfo().getNationalId();
}
}
DataSource Lifespan Configuration
Configure how long data remains valid to balance freshness vs performance:
@Service("github")
public class GithubDataSource implements MappedDataSource<GithubDataSourceSubject, GithubUser> {
@Override
public Duration lifespan() {
return Duration.ofHours(24); // GitHub data valid for 24 hours
}
@Override
public Content getData(GithubDataSourceSubject subject) throws Exception {
// Implementation...
}
}
Error Recovery Strategies
Implement fallback mechanisms for critical data sources using DataSourceManager:
@Autowired
private DataSourceManager dataSourceManager;
public GithubUser getGithubDataWithFallback(Participant participant) {
try {
Optional<DataSourceRecord> record = dataSourceManager.getData(
participant, "github", LoadingMode.QUERY);
if (record.isPresent()) {
return (GithubUser) record.get().getData();
}
} catch (IOException e) {
log.warn("Primary GitHub data unavailable, trying fallback", e);
}
// Try with cached data only as fallback
try {
Optional<DataSourceRecord> cachedRecord = dataSourceManager.getData(
participant, "github", LoadingMode.READ);
if (cachedRecord.isPresent()) {
log.info("Using cached GitHub data for participant: {}", participant.getId());
return (GithubUser) cachedRecord.get().getData();
}
} catch (IOException e) {
log.warn("Cached GitHub data also unavailable", e);
}
return null; // No data available
}
12.8. Common Use Cases
Credit Bureau Integration
@Service("creditBureau")
public class CreditBureauDataSource
implements MappedDataSource<CreditBureauSubject, CreditReport> {
@Override
public Content getData(CreditBureauSubject subject) throws Exception {
// Call credit bureau API with SSN/National ID
// Handle authentication, rate limiting, etc.
}
}
12.9. Testing DataSources
Unit Testing
@Test
public void testGithubDataSource() throws Exception {
GithubDataSourceSubject subject = () -> "octocat";
Content content = githubDataSource.getData(subject);
GithubUser user = githubDataSource.parseRecord(content);
assertThat(user.getLogin()).isEqualTo("octocat");
assertThat(user.getPublicRepos()).isGreaterThan(0);
}
12.10. Best Practices
Architecture Patterns
✅ Use Feature Store for business logic - Primary pattern for consuming external data ✅ Use DataSourceManager for direct access - When you need raw data or custom processing ✅ Never use DataSources directly - Always go through DataSourceManager or Feature Store ✅ Choose appropriate loading modes - READ for performance, QUERY for balance, FORCE for freshness ✅ Handle data unavailability gracefully - Continue workflow when external data is missing ✅ Implement proper subject interfaces - Clear contracts for what data to fetch ✅ Use typed target objects - Strong typing for external API responses
DataSource Implementation
✅ Use meaningful service names - @Service("github")
not @Service("ds1")
✅ Handle errors gracefully - Always throw DataUnavailableException
for missing data
✅ Configure JSON parsing - Use FAIL_ON_UNKNOWN_PROPERTIES = false
for API resilience
✅ Set appropriate lifespans - Balance freshness vs API call costs
✅ Version your APIs - Use explicit API version headers
✅ Test thoroughly - Test both success and failure scenarios
✅ Implement proper parsing - Handle all expected data formats and edge cases
DataSourceManager Usage
✅ Use appropriate loading modes - Match mode to business requirements ✅ Handle Optional results - Check if data is present before using ✅ Catch IOException properly - Handle network and data access failures ✅ Log data access patterns - Monitor usage for performance optimization ✅ Implement fallback strategies - Use cached data when fresh data unavailable
Security and Performance
✅ Log appropriately - Log errors but not sensitive data ✅ Add retry logic - Handle temporary network failures ✅ Set reasonable timeouts - Don’t block indefinitely ✅ Monitor data freshness - Track when data was last updated ✅ Use lifespans effectively - Avoid unnecessary API calls
Anti-Patterns
❌ Don’t use DataSources directly - Bypasses caching and lifecycle management
❌ Don’t bypass Feature Store for business logic - Use Feature Store instead of raw DataSource data
❌ Don’t ignore Optional results - Always check if data is present
❌ Don’t hardcode loading modes - Choose based on business requirements
❌ Don’t expose sensitive data - Never log API keys or personal information
❌ Don’t hardcode URLs - Use configuration properties for API endpoints
❌ Don’t ignore exceptions - Handle IOException
and DataUnavailableException
Production Checklist
-
DataSources use meaningful service names
-
All external calls have appropriate timeouts
-
Error handling covers
DataUnavailableException
andIOException
-
Loading modes are chosen appropriately for each use case
-
Sensitive data is never logged
-
DataSource lifespans balance freshness vs cost
-
Subject interfaces are properly implemented
-
Target objects handle all expected data formats
13. Workflow Integration
Connect your entities to automated decision-making workflows for credit scoring, risk assessment, and approval processes.
13.1. What You’ll Learn
After reading this chapter, you’ll know how to:
-
Make any entity workflow-enabled with two simple interfaces
-
Automatically trigger workflows when business events occur
-
Handle workflow results and update entity status
-
Provide manual workflow controls for users
-
Test your workflow integration
13.2. The Big Picture
TimveroOS separates what decisions to make from how to make them:
-
Workflow Engine (admin configures): Decision logic, scoring rules, approval criteria
-
Your Code (this chapter): When to start workflows, how to handle results
Think of it like this: You tell the system "start credit check for this customer," the workflow engine figures out approve/decline/review, then you handle the result.
13.3. Step 1: Make Your Subject Workflow-Enabled
ProcessEntity
represents the subject being evaluated - typically a person, property, or asset. Not the business process itself.
@Entity
public class Borrower extends AbstractAuditable<UUID>
implements ProcessEntity, HasPendingDecisions {
// Person/subject information
private String fullName;
private String socialSecurityNumber;
private String email;
private LocalDate dateOfBirth;
// Workflow integration - just add these two things:
@OneToOne(cascade = CascadeType.ALL)
private PendingDecisionHolder pendingDecisionHolder =
new PendingDecisionHolder("BORROWER");
@Override
public String getPrimaryId() {
return socialSecurityNumber; // Unique identifier for data sources
}
@Override
public PendingDecisionHolder getPendingDecisionHolder() {
return pendingDecisionHolder;
}
}
Common ProcessEntity types: * Person: Borrower, Guarantor, Co-signer (credit checks, income verification) * Property: House, Vehicle, Asset (appraisals, valuations) * Business: Company, Partnership (business credit, financial analysis)
What Each Interface Does
ProcessEntity: "This subject can be evaluated by workflows"
* Requires getPrimaryId()
- how external data sources identify this subject (SSN, VIN, Tax ID, etc.)
HasPendingDecisions: "This subject can receive workflow evaluation results" * Stores decisions from workflows (approve/decline/manual review) * Tracks decision progress
13.4. Step 2: Automatically Start Workflows
Use EntityChecker
to start workflows when business events happen. The pattern is to listen for business process changes and start subject evaluation:
@Component
public class BorrowerCreditCheckTrigger extends EntityChecker<LoanApplication, UUID> {
@Autowired
private DecisionProcessStarter workflowStarter;
@Override
protected void registerListeners(CheckerListenerRegistry<LoanApplication> registry) {
// Listen for application status changes
registry.entityChange().updated(LoanApplication_.STATUS);
}
@Override
protected boolean isAvailable(LoanApplication application) {
return application.getStatus() == ApplicationStatus.SUBMITTED
&& application.getBorrower() != null;
}
@Override
protected void perform(LoanApplication application) {
// Start credit check workflow for the BORROWER (the subject)
Borrower borrower = application.getBorrower();
workflowStarter.start(CREDIT_CHECK_WORKFLOW, borrower.getId());
}
}
Key pattern:
1. Listen for business process events (application submitted, document signed, etc.)
2. Check if subject evaluation should start (isAvailable
)
3. Start workflow for the subject (borrower, property, etc.), not the process
Common Trigger Patterns
Status changes:
registry.entityChange().updated(MyEntity_.STATUS);
New records:
registry.entityChange().inserted();
Specific field updates:
registry.entityChange().updated(MyEntity_.CREDIT_SCORE);
Complex conditions:
registry.entityChange().updated(MyEntity_.STATUS)
.and(entity -> entity.getAmount().isGreaterThan(THRESHOLD));
Manual Workflow Calls
Sometimes you need to start workflows directly from your service code:
@Service
public class BorrowerEvaluationService {
@Autowired
private DecisionProcessStarter workflowStarter;
public void requestCreditCheck(UUID borrowerId) {
workflowStarter.start(CREDIT_CHECK_WORKFLOW, borrowerId);
}
public void requestIncomeVerification(UUID borrowerId) {
workflowStarter.start(INCOME_VERIFICATION_WORKFLOW, borrowerId);
}
public void evaluateAllBorrowersForApplication(UUID applicationId) {
LoanApplication app = applicationRepository.findById(applicationId);
for (Borrower borrower : app.getBorrowers()) {
workflowStarter.start(CREDIT_CHECK_WORKFLOW, borrower.getId());
}
}
}
Use this when: * User clicks "Run Credit Check" button for a specific borrower * External API triggers evaluation of a person/property * Scheduled job needs to re-evaluate subjects * You need precise control over which subjects to evaluate
13.5. Step 3: Handle Workflow Results
When workflows complete, they send results back to your subject. Handle them with EntityEventListener
:
@Component
public class BorrowerEvaluationResultHandler implements EntityEventListener<FinishedScoringEvent<Borrower>> {
@Autowired
private BorrowerService borrowerService;
@Autowired
private LoanApplicationService applicationService;
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handle(FinishedScoringEvent<Borrower> event) {
UUID borrowerId = event.getEntityId();
Borrower borrower = borrowerService.findById(borrowerId);
List<PendingDecision> decisions = borrower.getPendingDecisions();
if (hasDeclinedDecisions(decisions)) {
borrowerService.markAsDeclined(borrowerId, getDeclineReason(decisions));
// Update related applications
applicationService.handleBorrowerDeclined(borrowerId);
} else if (hasPendingDecisions(decisions)) {
borrowerService.markForManualReview(borrowerId);
} else {
borrowerService.markAsApproved(borrowerId);
// Check if all borrowers are approved, then approve application
applicationService.checkApplicationReadiness(borrower.getApplicationId());
}
}
private boolean hasDeclinedDecisions(List<PendingDecision> decisions) {
return decisions.stream().anyMatch(d -> d.getStatus() == DecisionStatus.DECLINED);
}
private boolean hasPendingDecisions(List<PendingDecision> decisions) {
return decisions.stream().anyMatch(d -> d.getStatus() == DecisionStatus.PENDING);
}
}
What this does: 1. Listens for subject evaluation completion events 2. Checks all decisions from the workflow 3. Updates subject status based on results 4. Triggers business logic (e.g., check if application can proceed) 5. Uses new transaction to avoid conflicts with workflow engine
Service Layer Pattern
Keep your business logic clean with separate services for subjects and processes:
@Service
public class BorrowerService {
@Transactional
public void markAsApproved(UUID borrowerId) {
Borrower borrower = repository.findById(borrowerId);
borrower.setEvaluationStatus(EvaluationStatus.APPROVED);
borrower.setEvaluationDate(Instant.now());
// Clear any pending decisions, update credit score, etc.
}
@Transactional
public void markAsDeclined(UUID borrowerId, String reason) {
Borrower borrower = repository.findById(borrowerId);
borrower.setEvaluationStatus(EvaluationStatus.DECLINED);
borrower.setDeclineReason(reason);
// Log decline reason, update risk profile, etc.
}
@Transactional
public void markForManualReview(UUID borrowerId) {
Borrower borrower = repository.findById(borrowerId);
borrower.setEvaluationStatus(EvaluationStatus.MANUAL_REVIEW);
// Create review tasks, notify underwriters, etc.
}
}
@Service
public class LoanApplicationService {
@Transactional
public void checkApplicationReadiness(UUID applicationId) {
LoanApplication app = repository.findById(applicationId);
// Check if all borrowers are evaluated
boolean allBorrowersReady = app.getBorrowers().stream()
.allMatch(b -> b.getEvaluationStatus() != EvaluationStatus.PENDING);
if (allBorrowersReady) {
boolean anyDeclined = app.getBorrowers().stream()
.anyMatch(b -> b.getEvaluationStatus() == EvaluationStatus.DECLINED);
if (anyDeclined) {
app.setStatus(ApplicationStatus.DECLINED);
} else {
app.setStatus(ApplicationStatus.APPROVED);
}
}
}
}
13.6. Step 4: Add Manual Controls
Sometimes users need to manually control workflows. Add action controllers:
@Controller
@RequestMapping("/retry-credit-check")
public class RetryCreditCheckAction extends SimpleActionController<UUID, Borrower> {
@Autowired
private DecisionProcessStarter workflowStarter;
@Override
protected EntityAction<? super Borrower, Object> action() {
return when(borrower ->
borrower.getEvaluationStatus() == EvaluationStatus.FAILED
).then((borrower, form, user) -> {
workflowStarter.start(CREDIT_CHECK_WORKFLOW, borrower.getId());
borrower.setEvaluationStatus(EvaluationStatus.PENDING);
});
}
}
This creates a "Retry Credit Check" button that: * Only shows when borrower evaluation failed * Restarts the credit check workflow for that borrower * Updates borrower evaluation status * Refreshes the page
13.7. Step 5: Configuration
Define Your Workflow Types
Create DecisionProcessType
constants for your workflows:
@Configuration
public class WorkflowConfiguration {
public static final DecisionProcessType<Borrower> CREDIT_CHECK_WORKFLOW =
new DecisionProcessType<>("CREDIT_CHECK_WORKFLOW", Borrower.class);
public static final DecisionProcessType<Borrower> INCOME_VERIFICATION_WORKFLOW =
new DecisionProcessType<>("INCOME_VERIFICATION_WORKFLOW", Borrower.class);
public static final DecisionProcessType<Borrower> FRAUD_CHECK_WORKFLOW =
new DecisionProcessType<>("FRAUD_CHECK_WORKFLOW", Borrower.class);
public static final DecisionProcessType<Property> PROPERTY_APPRAISAL_WORKFLOW =
new DecisionProcessType<>("PROPERTY_APPRAISAL_WORKFLOW", Property.class);
}
Each DecisionProcessType
specifies:
* Name: Identifier for the workflow process
* Entity type: What type of entity this workflow processes
Use these constants everywhere instead of creating new instances.
Application Setup
The workflow engine runs on a separate port. Set this up in your main class:
public class MyLendingApplication {
public static void main(String[] args) {
SpringApplicationBuilder parent = new SpringApplicationBuilder(BaseConfiguration.class)
.web(WebApplicationType.NONE);
parent.run(args);
// Main application (port 8081)
parent.child(WebMvcConfig.class, MyConfiguration.class)
.properties("server.port=8081")
.run(args);
// Workflow engine (separate port)
parent.child(ExternalProcessWebMvcConfig.class)
.properties("spring.config.name=workflow")
.run(args);
}
}
Workflow Properties
Create src/main/resources/workflow.properties
:
server.port=${process.engine.callbackPort}
server.servlet.context-path=/external-process
Application Properties
Add these essential workflow configuration properties to src/main/resources/application.properties
:
# Workflow Callback Configuration
process.engine.callbackPort=8180
process.engine.callbackUrl=http://localhost:
process.engine.type=workflow
# Workflow Modeler UI
process.modeler.url=http://localhost:8280/workflow
# Workflow Engine URL for back-to-back calls
process.engine.url=http://localhost:8280/workflow
What these properties do:
-
process.engine.callbackPort
: Port where your admin application runs (workflow engine calls back to this) -
process.engine.callbackUrl
: Base URL for workflow engine callbacks to your application -
process.engine.type
: Identifies this as a workflow-enabled application -
process.modeler.url
: URL to the workflow designer/modeler interface -
process.engine.url
: URL to the workflow execution engine
Important: The workflow engine runs separately from your application and needs these URLs to communicate back and forth.
13.8. Complete Example: Borrower Credit Check Workflow
Here’s everything working together for a borrower evaluation workflow:
1. The Subject (ProcessEntity)
@Entity
public class Borrower extends AbstractAuditable<UUID>
implements ProcessEntity, HasPendingDecisions {
private String fullName;
private String socialSecurityNumber;
private EvaluationStatus evaluationStatus = EvaluationStatus.PENDING;
@OneToOne(cascade = CascadeType.ALL)
private PendingDecisionHolder pendingDecisionHolder =
new PendingDecisionHolder("BORROWER");
@Override
public String getPrimaryId() { return socialSecurityNumber; }
@Override
public PendingDecisionHolder getPendingDecisionHolder() {
return pendingDecisionHolder;
}
// getters/setters...
}
2. Automatic Trigger (listens to business process)
@Component
public class BorrowerCreditCheckTrigger extends EntityChecker<LoanApplication, UUID> {
@Override
protected void registerListeners(CheckerListenerRegistry<LoanApplication> registry) {
registry.entityChange().updated(LoanApplication_.STATUS);
}
@Override
protected boolean isAvailable(LoanApplication app) {
return app.getStatus() == ApplicationStatus.SUBMITTED;
}
@Override
protected void perform(LoanApplication app) {
// Start workflow for each BORROWER (the subject)
for (Borrower borrower : app.getBorrowers()) {
workflowStarter.start(CREDIT_CHECK_WORKFLOW, borrower.getId());
}
app.setStatus(ApplicationStatus.UNDER_REVIEW);
}
}
3. Result Handler (handles subject evaluation results)
@Component
public class BorrowerEvaluationResultHandler implements EntityEventListener<FinishedScoringEvent<Borrower>> {
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handle(FinishedScoringEvent<Borrower> event) {
Borrower borrower = borrowerService.findById(event.getEntityId());
List<PendingDecision> decisions = borrower.getPendingDecisions();
if (hasDeclinedDecisions(decisions)) {
borrowerService.markAsDeclined(borrower.getId());
applicationService.handleBorrowerDeclined(borrower.getApplicationId());
} else if (hasPendingDecisions(decisions)) {
borrowerService.markForManualReview(borrower.getId());
} else {
borrowerService.markAsApproved(borrower.getId());
applicationService.checkApplicationReadiness(borrower.getApplicationId());
}
}
}
4. Manual Controls
@Controller
@RequestMapping("/retry-credit-check")
public class RetryCreditCheckAction extends SimpleActionController<UUID, Borrower> {
@Override
protected EntityAction<? super Borrower, Object> action() {
return when(borrower -> borrower.getEvaluationStatus() == EvaluationStatus.FAILED)
.then((borrower, form, user) -> {
workflowStarter.start(CREDIT_CHECK_WORKFLOW, borrower.getId());
borrower.setEvaluationStatus(EvaluationStatus.PENDING);
});
}
}
The Flow: 1. User submits application → Application status changes to SUBMITTED 2. EntityChecker detects change → Starts credit check workflow for each borrower 3. Workflow evaluates borrower → Sends result to your handler 4. Handler updates borrower status → Checks if application can proceed 5. If workflow fails → User can retry credit check for specific borrower
Key Pattern: Business process (application) triggers subject evaluation (borrower), results flow back to update both subject and process.
14. Offer Engine & Credit Products
Purpose: Generate personalized loan offers based on credit products, participant data, and business rules.
The Offer Engine transforms static credit product templates into personalized offers by evaluating participant risk data, workflow results, and business rules through configurable scripts.
14.1. What You’ll Build
-
Credit Products: Loan product templates with terms and parameters
-
Product Additives: Specific configurations within products (rates, conditions)
-
Offer Generation: Automated personalized offer creation
-
Secured Offers: Collateral-based offer variants
-
Integration: Workflow-driven offer generation
14.2. Core Concepts
Credit Products
Credit products define the basic parameters for loan types - amounts, terms, currencies, and fees.
@Entity
public class ExampleCreditProduct extends CreditProduct {
@Column(updatable = false)
private CurrencyUnit currency;
@Column(precision = AMOUNT_PRECISION, scale = AMOUNT_SCALE, nullable = false)
private BigDecimal minAmount;
@Column(precision = AMOUNT_PRECISION, scale = AMOUNT_SCALE, nullable = false)
private BigDecimal maxAmount;
@Column(nullable = false)
private Integer minTerm;
@Column(nullable = false)
private Integer maxTerm;
@Column(nullable = false, columnDefinition = NUMERIC)
private BigDecimal lateFeeRate;
// Constructor and getters/setters omitted for brevity
}
Key Features: * Amount Ranges: Min/max loan amounts with currency * Term Ranges: Min/max loan terms in months * Fee Structure: Late fee rates and other charges * Engine Integration: Links to offer generation engines
Product Additives
Additives are specific configurations within products that define interest rates, procuring types, and offer generation rules.
@Entity
public class ExampleCreditProductAdditive extends CreditProductAdditive {
@Column(nullable = false)
private String name;
@Column(precision = AMOUNT_PRECISION, scale = AMOUNT_SCALE, nullable = false)
private BigDecimal minAmount;
@Column(precision = AMOUNT_PRECISION, scale = AMOUNT_SCALE, nullable = false)
private BigDecimal maxAmount;
@Column(nullable = false)
private Integer minTerm;
@Column(nullable = false)
private Integer maxTerm;
@Column(nullable = false, columnDefinition = NUMERIC)
private BigDecimal interestRate;
protected ExampleCreditProductAdditive() {
super();
}
public ExampleCreditProductAdditive(CreditProduct product) {
super(product);
}
@Transient
@Override
public ExampleProductOffer createOffer() {
return new ExampleProductOffer();
}
@Override
public String getDisplayedName() {
return name;
}
// Standard getters/setters omitted for brevity
}
Additive Purpose: * Interest Rates: Specific rates for different risk segments * Procuring Types: Collateral or guarantee requirements * Offer Engines: Scripts that generate personalized offers * Term Variations: Different terms within product ranges
Product Offers
Generated offers are personalized versions of product additives tailored to specific participants.
@Entity
@Table(name = "product_offer")
@Audited
public class ExampleProductOffer extends ProductOffer {
@NotAudited
@Column(name = "uuid", nullable = false, updatable = false)
private UUID uuid;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Participant participant;
@Column(name = "min_amount", precision = AMOUNT_PRECISION, scale = AMOUNT_SCALE, nullable = false)
private BigDecimal minAmount;
@Column(name = "max_amount", precision = AMOUNT_PRECISION, scale = AMOUNT_SCALE, nullable = false)
private BigDecimal maxAmount;
@Column(name = "min_term", nullable = false)
private Integer minTerm;
@Column(name = "max_term", nullable = false)
private Integer maxTerm;
@Transient
public Application getApplication() {
return participant != null ? participant.getApplication() : null;
}
@Override
@Transient
public ExampleCreditProduct getCreditProduct() {
return (ExampleCreditProduct) getProductAdditive().getProduct();
}
@Transient
public CurrencyUnit getCurrency() {
return getCreditProduct().getCurrency();
}
@PrePersist
protected void prePersist() {
uuid = UUID.randomUUID();
}
// Standard getters/setters omitted for brevity
}
Offer Characteristics: * Personalized Terms: Adjusted amounts and terms based on risk assessment * Participant Link: Connected to specific application participant * Product Reference: Links back to originating additive and product * UUID Tracking: Unique identifier for offer selection and tracking
14.3. Offer Generation Process
Core Engine
The OfferEngine
orchestrates the generation process by combining product configurations with participant data.
Generation Flow: 1. Product Selection: Find products matching participant’s execution result type 2. Data Processing: Extract participant data using configured processor 3. Script Execution: Run offer generation scripts for each additive 4. Offer Creation: Generate personalized offers based on script results 5. Persistence: Save offers and link to participant
Data Processing
Implement OfferEngineDataProcessor
to map participant data for offer generation:
Unresolved directive in _offer-engine.adoc - include::/opt/teamcity-agent3/work/8187ccecf864e5bd/src/main/java/com/timvero/example/admin/com/timvero/example/admin/offer/ExampleDataProcessor.java[tags=processor]
Data Sources: * Workflow Results: Decision process outcomes and scoring * Pending Decisions: Incomplete workflow data * Participant Profile: Personal and financial information * Risk Assessment: External data source results
Offer Service
The service layer coordinates offer generation with error handling:
Unresolved directive in _offer-engine.adoc - include::/opt/teamcity-agent3/work/8187ccecf864e5bd/src/main/java/com/timvero/example/admin/com/timvero/example/admin/offer/ProductOfferService.java[tags=service]
Service Responsibilities: * Transaction Management: Ensures atomic offer generation * Error Handling: Captures and stores generation exceptions * Product Loading: Retrieves active products for generation * Offer Persistence: Saves generated offers to database
14.4. Secured Offers & Procuring Engines
Secured offers extend basic offers with collateral or guarantee requirements through the ProcuringEngine pattern.
Procuring Engine Pattern
The ProcuringEngine
transforms basic product offers into secured variants with specific collateral requirements:
@Component
public class PenaltyProcuringEngine implements ProcuringEngine {
@Override
public ProcuringType procuringType() {
return PENALTY;
}
@Override
public Collection<? extends SecuredOffer> generateSecuredOffers(ProductOffer productOffer) {
return List.of(new PenaltySecuredOffer((ExampleProductOffer) productOffer));
}
}
Engine Responsibilities: * Procuring Type: Defines what type of collateral/guarantee required * Offer Generation: Creates secured variants from basic offers * Business Logic: Implements specific procuring strategies * Flexibility: Multiple engines can handle different collateral types
Procuring Types
Define the types of collateral or guarantee requirements:
@Configuration
public class ExampleProcuringType {
public static final String CODE_PENALTY = "PENALTY";
public static final ProcuringType PENALTY = new ProcuringType(CODE_PENALTY);
// Other procuring types:
// VEHICLE_COLLATERAL, PROPERTY_COLLATERAL, COSIGNER, etc.
}
Common Procuring Types: * NO_PROCURING: Unsecured loans based on creditworthiness * PENALTY: Higher rates with penalty clauses for default * VEHICLE_COLLATERAL: Car loans with vehicle as collateral * PROPERTY_COLLATERAL: Mortgages with property as collateral * COSIGNER: Loans requiring guarantor/co-signer
Secured Offer Structure
Secured offers link to original offers while adding procuring-specific terms:
@Entity
@Table(name = "penalty_secured_offer")
@DiscriminatorValue(ExampleProcuringType.CODE_PENALTY)
public class PenaltySecuredOffer extends ExampleSecuredOffer {
protected PenaltySecuredOffer() {
}
public PenaltySecuredOffer(ExampleProductOffer originalOffer) {
super(originalOffer, ExampleProcuringType.PENALTY);
}
@Override
public String getOfferKey() {
return getOriginalOffer().getUuid() + ":PENALTY";
}
}
Base Secured Offer:
@Entity
@Table(name = "secured_offer")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "procuring_type", discriminatorType = DiscriminatorType.STRING)
public abstract class ExampleSecuredOffer extends SecuredOffer {
@ManyToOne(fetch = FetchType.EAGER, optional = false)
private ExampleProductOffer originalOffer;
@Column(name = "procuring_type", insertable = false, updatable = false)
private ProcuringType procuringType;
public ExampleSecuredOffer(ExampleProductOffer originalOffer, ProcuringType procuringType) {
this.originalOffer = originalOffer;
this.procuringType = procuringType;
}
// Abstract method for unique offer identification
public abstract String getOfferKey();
}
Secured Offer Features: * Original Offer Reference: Links to base product offer terms * Procuring Type: Specifies collateral/guarantee requirements * Inheritance Strategy: Supports multiple secured offer types * Offer Key: Unique identifier combining offer UUID and procuring type * Flexible Structure: Each procuring type can have custom fields and logic
Procuring Engine Integration
Procuring engines integrate with the main offer generation flow:
Generation Process: 1. Basic Offers: OfferEngine generates standard ProductOffers 2. Procuring Analysis: System identifies applicable procuring types 3. Secured Generation: Each ProcuringEngine creates secured variants 4. Offer Portfolio: Participant receives both basic and secured options 5. Selection: Participant chooses preferred offer type
Business Benefits: * Risk Mitigation: Collateral reduces lender risk * Rate Optimization: Secured offers can have lower interest rates * Market Expansion: Serve customers who need collateral-based options * Regulatory Compliance: Meet requirements for different loan types
Custom Procuring Engines
1. Define Procuring Type
@Configuration
public class MyProcuringTypes {
public static final String CODE_VEHICLE = "VEHICLE_COLLATERAL";
public static final ProcuringType VEHICLE = new ProcuringType(CODE_VEHICLE);
}
2. Implement Procuring Engine
@Component
public class VehicleProcuringEngine implements ProcuringEngine {
@Override
public ProcuringType procuringType() {
return MyProcuringTypes.VEHICLE;
}
@Override
public Collection<? extends SecuredOffer> generateSecuredOffers(ProductOffer productOffer) {
// Business logic for vehicle-secured offers
VehicleSecuredOffer securedOffer = new VehicleSecuredOffer((MyProductOffer) productOffer);
// Adjust terms based on vehicle value, age, etc.
return List.of(securedOffer);
}
}
3. Create Secured Offer Entity
@Entity
@Table(name = "vehicle_secured_offer")
@DiscriminatorValue(MyProcuringTypes.CODE_VEHICLE)
public class VehicleSecuredOffer extends MySecuredOffer {
@Column(name = "vehicle_value")
private BigDecimal vehicleValue;
@Column(name = "vehicle_year")
private Integer vehicleYear;
// Vehicle-specific offer logic
}
14.5. Integration Patterns
Offer to Credit Conversion
The conversion from offer to active credit happens in two phases: condition selection and contract signature.
Phase 1: Offer Selection & Condition Creation
When a participant selects an offer, the system creates a credit condition and prepares for contract signature:
@Controller
@RequestMapping("/submit-regular")
public class SelectRegularConditionAction extends SelectConditionAction<ExampleProductOffer, ConditionForm> {
@Override
protected EntityAction<? super ExampleProductOffer, ConditionForm> action() {
return when(o -> o.getApplication().getCondition() == null
&& o.getApplication().getStatus().equals(ApplicationStatus.CONDITION_CHOOSING)
&& o.getParticipant().getStatus() == ParticipantStatus.APPROVED).then((offer, form, user) -> {
Application application = offer.getApplication();
ExampleSecuredOffer securedOffer = findSecuredOffer(offer, form.getSecuredOfferKey());
// Calculate payment terms
MonetaryAmount principal = form.getPrincipal();
BigDecimal interestRate = offer.getProductAdditive().getInterestRate();
Integer term = form.getTerm();
MonetaryAmount regularPayment = PaymentCalculator.calcAnnuityPayment(
principal, MonetaryUtil.zero(principal.getCurrency()),
periodicInterest(Period.ofMonths(1), interestRate), term, 0);
// Create credit condition
ExampleCreditCondition condition = new ExampleCreditCondition(
principal, offer.getCreditProduct().getEngineName(),
interestRate, offer.getCreditProduct().getLateFeeRate(),
Method_30_360_BB.NAME, Period.ofMonths(1), term,
regularPayment, securedOffer);
application.setCondition(condition);
// Generate payment schedule
PaymentSchedule paymentSchedule = scheduledService.getPaymentSchedule(
condition, form.getPrincipal(), form.getStart());
application.setPaymentSchedule(paymentSchedule);
// Move to contract signature phase
application.setStatus(ApplicationStatus.PENDING_CONTRACT_SIGNATURE);
// Generate contract document
documentService.generate(application.getBorrowerParticipant(),
ParticipantDocumentTypesConfiguration.APPLICATION_CONTRACT,
offer.getCreditProduct().getUuidContractTemplate());
});
}
}
Phase 2: Contract Signature & Credit Creation
When the contract is signed, an EntityChecker automatically creates the active credit:
@Component
public class ContractSignChecker extends EntityChecker<Application, UUID> {
@Override
protected void registerListeners(CheckerListenerRegistry<Application> registry) {
registry.entityChange(SignableDocument.class,
d -> participantRepository.getReferenceById(d.getOwnerId()).getApplication())
.updated(SignableDocument_.STATUS)
.and(d -> d.getStatus() == SignatureStatus.SIGNED
&& d.getDocumentType() == ParticipantDocumentTypesConfiguration.APPLICATION_CONTRACT);
}
@Override
protected boolean isAvailable(Application application) {
return application.getStatus().equals(ApplicationStatus.PENDING_CONTRACT_SIGNATURE);
}
@Override
protected void perform(Application application) {
// Update application status
application.setStatus(ApplicationStatus.SERVICING);
// Get contract signature date
LocalDate signDate = documentFinder
.latest(application.getBorrowerParticipant(),
ParticipantDocumentTypesConfiguration.APPLICATION_CONTRACT)
.get().getDecisionMadeAt().atZone(ZoneId.systemDefault()).toLocalDate();
// Create active credit
ExampleCredit credit = new ExampleCredit();
credit.setApplication(application);
credit.setCondition(application.getCondition());
credit.setStartDate(signDate);
entityManager.persist(credit);
// Initialize credit calculations
calculationService.calculate(credit.getId(), signDate, signDate);
}
}
Complete Conversion Flow:
-
Offer Generation: Participant gets approved → offers generated via
GenerateOffersParticipantAction
-
Offer Selection: Participant selects offer →
SelectRegularConditionAction
createsCreditCondition
-
Contract Generation: System generates contract document for signature
-
Contract Signature: Participant signs contract → triggers
ContractSignChecker
-
Credit Creation: Checker creates
ExampleCredit
and initializes calculations -
Servicing: Credit becomes active and ready for operations
Key Components: * ConditionForm: Captures participant’s chosen amount, term, and start date * CreditCondition: Immutable terms including payment calculation and secured offer reference * PaymentSchedule: Pre-calculated payment schedule based on selected terms * EntityChecker: Automated credit creation triggered by contract signature * CreditCalculationService: Initializes credit balances and schedules
Workflow Integration
Offers are typically generated after participant approval:
Unresolved directive in _offer-engine.adoc - include::/opt/teamcity-agent3/work/8187ccecf864e5bd/src/main/java/com/timvero/example/admin/com/timvero/example/admin/participant/action/GenerateOffersParticipantAction.java[tags=action]
Integration Points: * Status Checking: Only approved participants get offers * Error Recovery: Regenerate offers if previous attempt failed * Timing Control: Manual or automated generation triggers * UI Integration: Action buttons and status indicators
Display Integration
Format offers for user interface display:
Unresolved directive in _offer-engine.adoc - include::/opt/teamcity-agent3/work/8187ccecf864e5bd/src/main/java/com/timvero/example/admin/com/timvero/example/admin/application/ExampleProductOfferViewService.java[tags=view]
Display Features: * Localization: Multi-language offer descriptions * Formatting: Monetary amounts and interest rates * Details: Product names and procuring type descriptions * Comparison: Consistent format for offer comparison
14.6. Implementation Guide
Creating Credit Products
1. Define Product Entity
@Entity
public class MyLoanProduct extends CreditProduct {
// Custom fields for your loan type
private BigDecimal originationFee;
private Integer gracePeriodDays;
private String collateralRequirement;
// Constructor and getters/setters omitted for brevity
}
2. Configure Product Additives
@Entity
public class MyLoanAdditive extends CreditProductAdditive {
private String riskSegment;
private BigDecimal baseRate;
private Boolean allowsSecuredOffers;
@Override
public ProductOffer createOffer() {
return new MyLoanOffer();
}
// Getters/setters omitted for brevity
}
3. Implement Custom Offers
@Entity
public class MyLoanOffer extends ProductOffer {
private BigDecimal finalRate;
private String approvalConditions;
private BigDecimal loanToValueRatio;
// Getters/setters omitted for brevity
}
Implementing Secured Offers
1. Define Procuring Types
@Configuration
public class MyProcuringTypes {
public static final String CODE_VEHICLE = "VEHICLE_COLLATERAL";
public static final String CODE_PROPERTY = "PROPERTY_COLLATERAL";
public static final String CODE_COSIGNER = "COSIGNER";
public static final ProcuringType VEHICLE = new ProcuringType(CODE_VEHICLE);
public static final ProcuringType PROPERTY = new ProcuringType(CODE_PROPERTY);
public static final ProcuringType COSIGNER = new ProcuringType(CODE_COSIGNER);
}
2. Create Base Secured Offer
@Entity
@Table(name = "my_secured_offer")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "procuring_type", discriminatorType = DiscriminatorType.STRING)
public abstract class MySecuredOffer extends SecuredOffer {
@ManyToOne(fetch = FetchType.EAGER, optional = false)
private MyLoanOffer originalOffer;
@Column(name = "procuring_type", insertable = false, updatable = false)
private ProcuringType procuringType;
@Column(name = "adjusted_interest_rate")
private BigDecimal adjustedInterestRate;
protected MySecuredOffer() {}
public MySecuredOffer(MyLoanOffer originalOffer, ProcuringType procuringType) {
this.originalOffer = originalOffer;
this.procuringType = procuringType;
}
public abstract String getOfferKey();
// Getters/setters omitted for brevity
}
3. Implement Specific Secured Offers
@Entity
@Table(name = "vehicle_secured_offer")
@DiscriminatorValue(MyProcuringTypes.CODE_VEHICLE)
public class VehicleSecuredOffer extends MySecuredOffer {
@Column(name = "vehicle_value")
private BigDecimal vehicleValue;
@Column(name = "vehicle_year")
private Integer vehicleYear;
@Column(name = "vehicle_make")
private String vehicleMake;
@Column(name = "loan_to_value_ratio")
private BigDecimal loanToValueRatio;
protected VehicleSecuredOffer() {}
public VehicleSecuredOffer(MyLoanOffer originalOffer) {
super(originalOffer, MyProcuringTypes.VEHICLE);
}
@Override
public String getOfferKey() {
return getOriginalOffer().getUuid() + ":VEHICLE";
}
// Business logic methods
public boolean isEligibleVehicle() {
return vehicleYear >= 2015 && vehicleValue.compareTo(BigDecimal.valueOf(5000)) >= 0;
}
// Getters/setters omitted for brevity
}
@Entity
@Table(name = "cosigner_secured_offer")
@DiscriminatorValue(MyProcuringTypes.CODE_COSIGNER)
public class CosignerSecuredOffer extends MySecuredOffer {
@Column(name = "cosigner_credit_score")
private Integer cosignerCreditScore;
@Column(name = "cosigner_income")
private BigDecimal cosignerIncome;
@Column(name = "relationship_type")
private String relationshipType;
protected CosignerSecuredOffer() {}
public CosignerSecuredOffer(MyLoanOffer originalOffer) {
super(originalOffer, MyProcuringTypes.COSIGNER);
}
@Override
public String getOfferKey() {
return getOriginalOffer().getUuid() + ":COSIGNER";
}
// Getters/setters omitted for brevity
}
Creating Procuring Engines
1. Vehicle Collateral Engine
@Component
public class VehicleProcuringEngine implements ProcuringEngine {
@Override
public ProcuringType procuringType() {
return MyProcuringTypes.VEHICLE;
}
@Override
public Collection<? extends SecuredOffer> generateSecuredOffers(ProductOffer productOffer) {
MyLoanOffer offer = (MyLoanOffer) productOffer;
VehicleSecuredOffer securedOffer = new VehicleSecuredOffer(offer);
// Apply vehicle-specific business logic
BigDecimal baseRate = offer.getProductAdditive().getInterestRate();
// Vehicle collateral typically reduces rate by 1-2%
BigDecimal adjustedRate = baseRate.subtract(BigDecimal.valueOf(0.015));
securedOffer.setAdjustedInterestRate(adjustedRate);
// Set loan-to-value ratio (typically 80-90% for vehicles)
securedOffer.setLoanToValueRatio(BigDecimal.valueOf(0.85));
return List.of(securedOffer);
}
}
2. Cosigner Engine
@Component
public class CosignerProcuringEngine implements ProcuringEngine {
@Override
public ProcuringType procuringType() {
return MyProcuringTypes.COSIGNER;
}
@Override
public Collection<? extends SecuredOffer> generateSecuredOffers(ProductOffer productOffer) {
MyLoanOffer offer = (MyLoanOffer) productOffer;
CosignerSecuredOffer securedOffer = new CosignerSecuredOffer(offer);
// Cosigner reduces risk, so lower interest rate
BigDecimal baseRate = offer.getProductAdditive().getInterestRate();
BigDecimal adjustedRate = baseRate.subtract(BigDecimal.valueOf(0.02));
securedOffer.setAdjustedInterestRate(adjustedRate);
return List.of(securedOffer);
}
}
3. Property Collateral Engine
@Component
public class PropertyProcuringEngine implements ProcuringEngine {
private final PropertyValuationService propertyService;
public PropertyProcuringEngine(PropertyValuationService propertyService) {
this.propertyService = propertyService;
}
@Override
public ProcuringType procuringType() {
return MyProcuringTypes.PROPERTY;
}
@Override
public Collection<? extends SecuredOffer> generateSecuredOffers(ProductOffer productOffer) {
MyLoanOffer offer = (MyLoanOffer) productOffer;
// Only generate if participant has property
if (!hasEligibleProperty(offer.getParticipant())) {
return Collections.emptyList();
}
PropertySecuredOffer securedOffer = new PropertySecuredOffer(offer);
// Property collateral gets best rates
BigDecimal baseRate = offer.getProductAdditive().getInterestRate();
BigDecimal adjustedRate = baseRate.multiply(BigDecimal.valueOf(0.7)); // 30% reduction
securedOffer.setAdjustedInterestRate(adjustedRate);
return List.of(securedOffer);
}
private boolean hasEligibleProperty(Participant participant) {
// Business logic to check property ownership
return propertyService.hasVerifiedProperty(participant);
}
}
Custom Data Processing
1. Implement Data Processor
@Component
public class MyDataProcessor extends OfferEngineDataProcessor<UUID, MyEntity> {
@Override
public ExecutionResultType getResultType() {
return new ExecutionResultType("MY_LOAN");
}
@Override
public Collection<Map<String, Object>> mapToData(MyEntity entity) {
// Map entity data for offer generation
Map<String, Object> data = new HashMap<>();
data.put("creditScore", entity.getCreditScore());
data.put("income", entity.getMonthlyIncome());
return List.of(data);
}
}
2. Configure Offer Generation Scripts
Scripts evaluate participant data and return offer parameters:
// Example offer generation script
if (profile.creditScore >= 700) {
offer.interestRate = productAdditive.interestRate * 0.9; // 10% discount
offer.maxAmount = Math.min(profile.income * 12, productAdditive.maxAmount);
return offer;
} else if (profile.creditScore >= 600) {
offer.interestRate = productAdditive.interestRate;
offer.maxAmount = Math.min(profile.income * 8, productAdditive.maxAmount);
return offer;
} else {
return null; // No offer for low credit scores
}
14.7. Next Steps
-
[credit-management]: Convert selected offers into active credits
-
Workflow Integration: Automate offer generation through workflows
-
[operations]: Handle offer-related operations and modifications
-
[payment-transactions]: Process payments for accepted offers
The Offer Engine provides the foundation for personalized lending by transforming static products into dynamic, risk-adjusted offers tailored to each participant’s profile and circumstances.
15. Credit Management System
This section describes how to implement and manage credit entities using the loan servicing framework. The ExampleCredit implementation demonstrates a complete loan management system, but the underlying loan module provides flexible components for implementing various lending products.
15.1. Credit System Architecture
The credit management system is built on the loan servicing framework, which provides core components for any type of lending product. The ExampleCredit serves as a reference implementation, but you can create different credit types for various lending scenarios.
Core Components
The loan module (com.timvero.servicing
) provides the foundation:
-
Credit
- Base entity class for all lending products -
CreditSnapshot
- Point-in-time credit state management -
CreditOperation
- Base class for all credit operations (payments, charges, etc.) -
Debt
- Flexible debt structure with account-based balances -
CreditCalculationService
- Core calculation engine -
CreditPaymentService
- Payment processing infrastructure
ExampleCredit Implementation
The ExampleCredit demonstrates a complete consumer loan implementation:
@Entity
@DiscriminatorValue("1")
public class ExampleCredit extends Credit implements NamedEntity {
@NotNull
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(nullable = false)
private Application application;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "condition")
@Fetch(FetchMode.JOIN)
private ExampleCreditCondition condition;
public Application getApplication() {
return application;
}
public void setApplication(Application application) {
this.application = application;
}
public ExampleCreditCondition getCondition() {
return condition;
}
public void setCondition(ExampleCreditCondition condition) {
this.condition = condition;
}
@Override
public String getDisplayedName() {
return "Loan for " + getApplication().getBorrowerParticipant().getDisplayedName();
}
@Transient
public LocalDate getMaturityDate() {
return getStartDate().plus(getCondition().getPeriod().multipliedBy(getCondition().getTerm()));
}
Key features:
* Discriminator Value: "1"
identifies this credit type in the database
* Application Integration: Links to loan application and borrower
* Condition Management: Contains loan terms (principal, term, interest rate)
* Maturity Calculation: Automatic calculation based on start date and terms
15.2. Credit Entity Setup
Creating Custom Credit Types
To implement different lending products, extend the base Credit
class:
@Entity
@DiscriminatorValue("2") // Unique identifier for this credit type
public class MortgageCredit extends Credit implements NamedEntity {
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(nullable = false)
private PropertyApplication application;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "mortgage_condition")
private MortgageCondition condition;
@Override
public String getDisplayedName() {
return "Mortgage for " + getApplication().getPropertyAddress();
}
@Transient
public LocalDate getMaturityDate() {
return getStartDate().plus(getCondition().getTerm());
}
}
Credit Condition Configuration
Define loan terms and conditions specific to your credit type:
@Entity
public class ExampleCreditCondition extends BasePersistable<UUID> {
@Embedded
@CompositeType(MonetaryAmountType.class)
private MonetaryAmount principal;
@Column(nullable = false)
private Period period; // Payment frequency (monthly, weekly, etc.)
@Column(nullable = false)
private Integer term; // Number of payment periods
@Column(nullable = false)
private BigDecimal interestRate;
// getters and setters...
}
Database Schema Generation
The platform automatically generates SQL migrations for credit entities:
-- Generated migration for ExampleCredit
CREATE TABLE credit (
id UUID PRIMARY KEY,
credit_type INTEGER NOT NULL, -- Discriminator column
start_date DATE NOT NULL,
calculation_date DATE,
actual_snapshot BIGINT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
-- ExampleCredit specific columns
application UUID NOT NULL,
condition UUID
);
15.3. Credit Operations Framework
Built-in Operation Types
The loan module provides standard operation types that work with any credit implementation:
Payment Operations
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
public class ExampleCreditPayment extends CreditPayment {
public static Integer TYPE = 200;
public ExampleCreditPayment(LocalDate date, MonetaryAmount amount) {
super(TYPE, date, OperationStatus.APPROVED, amount);
}
protected ExampleCreditPayment() {
}
@Override
public Integer getType() {
return TYPE;
}
@Override
public int getOrder() {
return 200;
}
Custom payment types can be created by extending CreditPayment
:
@Entity
@DiscriminatorValue("201")
public class MortgagePayment extends CreditPayment {
public static Integer TYPE = 201;
public MortgagePayment(LocalDate date, MonetaryAmount amount) {
super(TYPE, date, OperationStatus.APPROVED, amount);
}
@Override
public Integer getType() {
return TYPE;
}
}
Charge Operations
Implement fees and charges specific to your credit type:
@Entity
@DiscriminatorValue("301")
public class OriginationFeeCharge extends CreditOperation {
public static Integer TYPE = 301;
@Embedded
@CompositeType(MonetaryAmountType.class)
private MonetaryAmount amount;
public OriginationFeeCharge(LocalDate date, MonetaryAmount amount) {
super(TYPE, date, OperationStatus.APPROVED);
this.amount = amount;
}
@Override
public boolean isEndDayOperation() {
return false;
}
}
Accrual Operations
Interest and fee accruals are handled by the calculation engine:
@Entity
@DiscriminatorValue("401")
public class InterestAccrual extends CreditOperation {
@Embedded
@CompositeType(MonetaryAmountType.class)
private MonetaryAmount accruedAmount;
@Column(nullable = false)
private String accountType; // INTEREST, LATE_FEE, etc.
@Override
public boolean isEndDayOperation() {
return true; // Accruals typically run at end of day
}
}
Credit Action Controllers
Actions provide user interface operations for credit management:
Payment Registration
@Controller
@RequestMapping("/register-payment")
public class RegisterPaymentAction extends EntityActionController<UUID, ExampleCredit, ManualTransferForm> {
@Autowired
private BorrowerTransactionService borrowerTransactionService;
public static final Long OTHER = 0L;
@Override
protected EntityAction<? super ExampleCredit, ManualTransferForm> action() {
return when(c -> c.getActualSnapshot() != null && c.getActualSnapshot().getStatus().equals(ACTIVE))
.then((c, f, u) -> {
LiquidityClientPaymentMethod paymentMethod =
new LiquidityClientPaymentMethod(f.getProcessedDate(), f.getAmount(), TransactionType.INCOMING,
c.getApplication().getBorrowerParticipant().getClient().getIndividualInfo().getFullName());
borrowerTransactionService.proceedCustom(c, TransactionType.INCOMING, paymentMethod,
paymentMethod.getAmount(), true, f.getDescription());
});
The action uses a form to collect payment details:
public static class ManualTransferForm {
@NotNull
@Positive
private MonetaryAmount amount;
@NotNull
@DateTimeFormat(pattern = PATTERN_DATEPICKER_FORMAT)
private LocalDate processedDate;
private String description;
public MonetaryAmount getAmount() {
return amount;
}
public void setAmount(MonetaryAmount amount) {
this.amount = amount;
}
public LocalDate getProcessedDate() {
return processedDate;
}
public void setProcessedDate(LocalDate processedDate) {
this.processedDate = processedDate;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
Disbursement Processing
@Controller
@RequestMapping("/register-disbursement")
public class RegisterDisbursementAction extends EntityActionController<UUID, ExampleCredit, DisbursementForm> {
@Override
protected EntityAction<? super ExampleCredit, DisbursementForm> action() {
return when(c -> c.getActualSnapshot().getStatus().equals(APPROVED))
.then((c, f, u) -> {
// Create disbursement transaction
disbursementService.processDisbursement(c, f.getAmount(), f.getMethod());
});
}
}
Transaction Processing Integration
The credit system integrates with the transaction processing framework:
@Autowired
private PaymentTransactionService transactionService;
@Autowired
private CoreCreditRepository creditRepository;
@Autowired
private PaymentTransactionRepository transactionRepository;
@Autowired
private BorrowerTransactionRepository borrowerTransactionRepository;
@Autowired
private CreditPaymentService paymentService;
@Autowired
private ChargeOperationService chargeOperationService;
@Transactional(propagation = Propagation.MANDATORY)
public void proceedCustom(ExampleCredit credit, TransactionType type, PaymentMethod paymentMethod,
MonetaryAmount amount, boolean sync, String description) {
BorrowerTransaction transaction = new BorrowerTransaction(type, amount, paymentMethod, credit);
transaction.setStatus(TransactionStatus.READY_FOR_EXECUTION);
transaction.setDescription(description);
Transaction processing flow:
1. Transaction Creation: BorrowerTransaction
created with payment details
2. Payment Gateway: Transaction sent to payment processor
3. Success Handling: On success, creates CreditPayment
operation
4. Calculation Trigger: Credit calculation engine updates balances
5. Snapshot Update: New credit state snapshot created
15.4. Credit Calculation Engine
Debt Structure
The loan module uses a flexible debt structure with named accounts:
// Account type constants for ExampleCredit
public static final String PRINCIPAL = "PRINCIPAL";
public static final String INTEREST = "INTEREST";
public static final String LATE_FEE = "LATE_FEE";
public static final String PAST_DUE_PRINCIPAL = "PAST_DUE_PRINCIPAL";
public static final String PAST_DUE_INTEREST = "PAST_DUE_INTEREST";
For different credit types, define appropriate account structures:
// Mortgage-specific accounts
public static final String PRINCIPAL = "PRINCIPAL";
public static final String INTEREST = "INTEREST";
public static final String ESCROW = "ESCROW";
public static final String PMI = "PMI";
public static final String PROPERTY_TAX = "PROPERTY_TAX";
Calculation Service Integration
The calculation engine automatically processes credit operations:
// Triggered after payment registration
creditCalculationService.calculate(creditId, paymentDate, currentCalculationDate);
This recalculates: * Debt Balances: Updates account balances based on payment distribution * Interest Accruals: Calculates daily interest charges * Past Due Amounts: Moves overdue balances to past due accounts * Credit Status: Updates credit status based on payment history
15.5. Credit User Interface
Controller Implementation
@RequestMapping(value = ExampleCreditController.PATH)
@MenuItem(order = 5_300, name = "credit")
public class ExampleCreditController extends ViewableFilterController<UUID, ExampleCredit, ExampleCreditFilter> {
public static final String PATH = "/credit";
@Override
protected String getHeaderPage() {
return "/credit/header";
The controller provides: * List View: Paginated credit listing with filtering * Detail View: Comprehensive credit information display * Action Buttons: Context-sensitive operations based on credit status
Credit Filtering
public class ExampleCreditFilter extends ListFilter {
@Field(restriction = Restriction.IN, value = ExampleCredit_.ACTUAL_SNAPSHOT + "." + CreditSnapshot_.STATUS)
private CreditStatus[] status;
public CreditStatus[] getStatus() {
return status;
}
Filtering capabilities: * Status Filtering: Filter by credit status (ACTIVE, CLOSED, etc.) * Date Ranges: Filter by creation date, maturity date, etc. * Amount Ranges: Filter by principal amount, current balance * Custom Filters: Add business-specific filtering criteria
Tab-Based Interface
Credit details are organized into tabs for better user experience:
// Credit data tab showing balances and payment history
@Controller
@Order(1000)
public class CreditDataTab extends EntityTabController<UUID, ExampleCredit> {
@Override
protected String getTabTemplate(UUID id, Model model) throws Exception {
ExampleCredit credit = loadEntity(id);
// Add credit summary data
model.addAttribute("currentBalance", credit.getActualSnapshot().getDebt());
model.addAttribute("paymentHistory", getPaymentHistory(credit));
model.addAttribute("nextPaymentDate", calculateNextPaymentDate(credit));
return super.getTabTemplate(id, model);
}
}
Available tabs: * Credit Data: Current balances, payment status, maturity information * Payments: Payment history and upcoming payment schedule * Transactions: All financial transactions related to the credit * Calculations: Detailed calculation history and interest computations * Documents: Credit-related documents and contracts
Form Components for Credit Operations
Credit forms use the platform’s form component system:
<!-- Payment amount input -->
<th:block th:insert="~{/form/components :: amount(
#{credit.payment.amount},
'amount',
'v-required v-positive')}"
th:with="currencies = ${currencies}" />
<!-- Payment date picker -->
<th:block th:insert="~{/form/components :: date(
#{credit.payment.date},
'processedDate',
'v-required')}"
th:with="minDate = ${minDate}" />
<!-- Payment description -->
<th:block th:insert="~{/form/components :: textarea(
#{credit.payment.description},
'description',
'')}"
th:with="rows = 3" />
15.6. Credit Business Rules with Entity Checkers
Automated Credit Monitoring
Entity checkers can implement automated credit monitoring:
@Component
public class CreditPaymentChecker extends EntityChecker<ExampleCredit> {
@Override
protected void registerListeners(CheckerListenerRegistry<ExampleCredit> registry) {
// Monitor payment operations
registry.entityChange(CreditPayment.class, payment ->
creditRepository.findByOperationsIn(payment))
.inserted();
}
@Override
protected boolean isAvailable(ExampleCredit credit) {
return credit.getActualSnapshot().getStatus().equals(ACTIVE);
}
@Override
protected void perform(ExampleCredit credit) {
// Check if credit is now current after payment
if (isPaidCurrent(credit)) {
updateCreditStatus(credit, CURRENT);
sendPaymentConfirmation(credit);
}
}
}
Past Due Processing
Automated past due detection and processing:
@Component
public class PastDueChecker extends EntityChecker<ExampleCredit> {
@Override
protected void registerListeners(CheckerListenerRegistry<ExampleCredit> registry) {
// Monitor daily calculation updates
registry.entityChange().updated("calculationDate");
}
@Override
protected boolean isAvailable(ExampleCredit credit) {
return isPastDue(credit) && !isAlreadyMarkedPastDue(credit);
}
@Override
protected void perform(ExampleCredit credit) {
// Create past due operation
PastDueOperation pastDue = new PastDueOperation(
LocalDate.now(),
calculatePastDueAmount(credit)
);
credit.getOperations().add(pastDue);
// Send past due notification
notificationService.sendPastDueNotice(credit);
}
}
15.7. Extending the Credit System
Creating New Credit Types
To implement different lending products:
-
Extend Credit Entity: Create new entity with specific discriminator value
-
Define Condition Structure: Create condition entity with product-specific terms
-
Implement Operations: Create custom payment and charge operation types
-
Configure Account Types: Define debt account structure for the product
-
Create Controllers: Implement UI controllers and actions
-
Add Business Rules: Implement entity checkers for automated processing
Integration with External Systems
The credit system supports integration with external services:
-
Payment Gateways: Transaction processing through payment providers
-
Credit Bureaus: Credit reporting and monitoring
-
Core Banking: Integration with banking systems
-
Document Management: Contract and document storage
-
Notification Services: Email and SMS notifications
15.8. Best Practices
Credit Design Principles
-
Separation of Concerns: Keep business logic in services, not entities
-
Event-Driven Architecture: Use entity checkers for automated processing
-
Flexible Debt Structure: Design account types to accommodate future requirements
-
Audit Trail: Leverage built-in auditing for compliance requirements
-
Transaction Safety: Ensure operations are atomic and consistent
Performance Considerations
-
Calculation Optimization: Batch credit calculations during off-peak hours
-
Snapshot Management: Archive old snapshots to maintain performance
-
Index Strategy: Create appropriate database indexes for credit queries
-
Lazy Loading: Use lazy loading for large collections and related entities
Security and Compliance
-
Data Protection: Implement field-level encryption for sensitive data
-
Access Control: Use role-based security for credit operations
-
Audit Logging: Maintain comprehensive audit trails for regulatory compliance
-
Data Retention: Implement data retention policies for closed credits
The credit management system provides a robust foundation for implementing various lending products while maintaining consistency, auditability, and extensibility across different credit types.
16. Credit Operations Framework
The Credit Operations Framework provides a powerful, extensible system for managing financial operations throughout the credit lifecycle. This framework handles everything from loan disbursements and payments to interest accruals and account closures, with full audit trails and automated processing capabilities.
The |
16.1. Operations Architecture Overview
The operations framework is built on a flexible, event-driven architecture that separates operation definitions from their processing logic, enabling customization for various lending products and business models.
Core Components
The loan servicing module (com.timvero.servicing
) provides the foundation:
-
CreditOperation
- Base entity for all credit operations -
CreditOperationHandler<O>
- Interface for operation processing logic -
Snapshot
- Point-in-time credit state representation -
AccrualEngine
- Interface for time-based calculations -
PreCalculateSynchronizer
- Interface for operation synchronization -
CreditCalculationService
- Core calculation engine that processes operations
16.2. Operation Processing Flow
The credit calculation system processes operations through a sophisticated pipeline that ensures correct chronological execution and state management.
The Calculation Pipeline
When CreditCalculationService.calculate()
is called, the system follows this flow:
-
Synchronization Phase:
PreCalculateSynchronizer
implementations (likeAccrualOperationService
) ensure all necessary operations exist -
Date Range Processing: The system processes each date from the start date to the target date
-
Daily Operation Processing: For each date, operations are sorted by their
getOrder()
value and processed sequentially -
Snapshot Creation: After processing all operations for a date, a
CreditSnapshot
is created and stored -
State Updates: The credit’s actual snapshot and calculation date are updated
Operation Processing Order
Operations are processed in a specific order determined by their getOrder()
method. Lower numbers execute first:
Order | Operation Type | Example Type Code | Purpose |
---|---|---|---|
101 |
Charge Operations |
901 |
Add fees, penalties, or other charges to the credit |
111 |
Accrual Operations |
950 |
Calculate and apply interest, late fees, and other time-based charges |
200 |
Payment Operations |
200 |
Process borrower payments and apply to debt balances |
900 |
Past Due Operations |
900 |
Move current debt to past due status when payments are missed |
995 |
Void Operations |
995 |
Cancel or void credit operations |
999 |
Close Operations |
999 |
Close and finalize credit accounts |
End-of-Day vs Intraday Operations
Each operation implements isEndDayOperation()
to control when during the day it should be processed:
-
isEndDayOperation() = false
: Intraday operations - processed immediately when encountered -
isEndDayOperation() = true
: End-of-day operations - processed only when the date is "closed"
This distinction is crucial for business logic:
Intraday Operations (Most Operations)
@Override
public boolean isEndDayOperation() {
return false; // Process immediately
}
-
Charges - Applied immediately when created
-
Payments - Processed as soon as received
-
Accruals - Applied when calculation runs
End-of-Day Operations (Special Cases)
@Override
public boolean isEndDayOperation() {
return true; // Process only at end of day
}
-
Past Due Operations - Only processed when the day is "closed" to ensure all payments for that day have been received
This design prevents premature past due processing if a payment arrives later in the same business day.
Operation Handler Execution
Within each date, the OperationProcessor
handles individual operations:
-
Handler Discovery: The system finds the appropriate
CreditOperationHandler
for each operation type -
Snapshot Application: Each handler modifies the current
Snapshot
to reflect the operation’s effect -
Debt Tracking: The system tracks how each operation changes the debt balances
-
State Management: Operations can modify both debt balances and credit status
Transaction and Locking
The calculation service uses sophisticated transaction management:
-
Pessimistic Locking: Credits are locked during calculation to prevent concurrent modifications
-
Separate Transactions: Synchronization and calculation run in separate transactions
-
Event Publishing: Status changes and snapshot updates trigger events for other system components
16.3. Operation Entity Structure
Base Operation Entity
All credit operations extend the base CreditOperation
class:
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "operation_type", discriminatorType = DiscriminatorType.INTEGER)
public abstract class CreditOperation extends AbstractAuditable<UUID> {
@Column(nullable = false)
private Integer type;
@Column(nullable = false)
private LocalDate date;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OperationStatus status;
// Abstract methods that subclasses must implement
public abstract boolean isEndDayOperation();
public abstract int getOrder();
}
Key features:
* Single Table Inheritance: All operations stored in one table with discriminator
* Type Identification: Each operation type has a unique integer identifier
* Date-based Processing: Operations are tied to specific business dates
* Status Management: Operations can be APPROVED, CANCELED, DECLINED, or PENDING
* Audit Trail: Full change history via AbstractAuditable
16.4. Implementing Custom Operations
To implement custom operations, follow the same patterns demonstrated in the example project. You’ll need to:
-
Create the Operation Entity - Extend
CreditOperation
with your specific fields and behavior -
Implement the Operation Handler - Create a service that implements
CreditOperationHandler<YourOperation>
-
Configure the Handler - Add the handler as a Spring bean in your configuration
-
Define Processing Order - Set appropriate
getOrder()
andisEndDayOperation()
values
The example project’s ChargeOperation
and ChargeOperationService
demonstrate the simplest implementation pattern, while AccrualOperation
and AccrualOperationService
show more complex synchronization behavior.
16.5. Standard Operation Types
The example project demonstrates key operation types that represent the core concepts of credit operations.
Charge Operations
Charge operations represent the simplest operation type - they add a monetary amount to a credit account.
@Entity
@DiscriminatorValue("901")
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
public class ChargeOperation extends CreditOperation {
public static Integer TYPE = 901;
@Embedded
@NotNull
private MonetaryAmount amount;
protected ChargeOperation() {
super();
}
public ChargeOperation(LocalDate date, MonetaryAmount amount) {
super(TYPE, date, OperationStatus.APPROVED);
this.amount = amount;
}
public MonetaryAmount getAmount() {
return amount;
}
@Override
public boolean isEndDayOperation() {
return false;
}
@Override
public int getOrder() {
return 101;
}
}
Key characteristics:
* Type Code: 901
- Unique identifier for database discrimination
* Order: 101
- Early processing order to apply charges before other operations
* Monetary Amount: Embedded amount to be added to the debt
Payment Operations
Payment operations process borrower payments and distribute them across debt accounts according to business rules.
@Entity
@DiscriminatorValue("200")
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
public class ExampleCreditPayment extends CreditPayment {
public static Integer TYPE = 200;
public ExampleCreditPayment(LocalDate date, MonetaryAmount amount) {
super(TYPE, date, OperationStatus.APPROVED, amount);
}
protected ExampleCreditPayment() {
}
@Override
public Integer getType() {
return TYPE;
}
@Override
public int getOrder() {
return 200;
}
}
The payment distribution order is configured in CreditCalculationConfiguration
:
@Bean
CreditPaymentOperationHandler<ExampleCreditPayment> creditPaymentOperationHandler() {
return new CreditPaymentOperationHandler<>(OVERPAYMENT, List.of(PAST_DUE_PRINCIPAL, PAST_DUE_INTEREST,
LATE_FEE, INTEREST, PRINCIPAL)) {};
}
This defines the payment waterfall: past due amounts first, then fees, current interest, and finally principal. Overpayments are credited to the OVERPAYMENT
account.
Accrual Operations
Accrual operations represent a sophisticated concept for calculating time-based charges like interest and late fees. Unlike other operations that are created manually, accrual operations are automatically synchronized with significant credit events.
The Accrual Concept
The key insight behind accrual operations is that interest and fees need to be calculated whenever the debt balance changes. The system automatically creates accrual operations on dates when:
-
Payments are made - Interest must be calculated up to the payment date
-
Past due events occur - Balances move to past due status, affecting future calculations
This synchronization is handled by the AccrualOperationService
which implements PreCalculateSynchronizer
. It scans for payment and past due operations and ensures corresponding accrual operations exist for those dates.
Accrual Engines
The actual calculation logic is delegated to specialized accrual engines:
-
InterestAccrualEngine
- Calculates interest on thePRINCIPAL
balance using the credit’s interest rate -
LateFeeAccrualEngine
- Calculates late fees on past due principal and interest using the late fee rate
These engines extend BasisAccrualEngine
which provides sophisticated day-count calculations, handling rate changes over time, and pro-rated calculations between significant dates.
How It Works
-
Event Detection: When payments or past due operations are processed, the synchronizer identifies dates needing accrual calculations
-
Accrual Creation:
AccrualOperation
entities are automatically created for these dates -
Engine Calculation: During credit calculation, accrual engines compute the exact amounts based on outstanding balances, rates, and time periods
-
Balance Application: The calculated accruals are added to the appropriate debt accounts (
INTEREST
,LATE_FEE
, etc.)
This design ensures that time-based charges are accurately calculated and applied, even when payments are made on irregular dates or when credit terms change over time.
Past Due Operations
Past due operations handle one of the most critical business processes in lending - managing overdue debt. When borrowers miss scheduled payments, the system must reorganize debt balances to reflect the new risk profile and enable different treatment of overdue amounts.
The Past Due Concept
The fundamental principle is that overdue debt behaves differently from current debt:
-
Late fees accrue only on past due balances, not current balances
-
Collection processes target past due amounts with different strategies
-
Reporting and risk assessment treat past due debt as higher risk
-
Payment distribution prioritizes past due amounts over current debt
When a scheduled payment date passes without sufficient payment, current debt must be "moved" to past due accounts to enable this differentiated treatment.
Scheduled Payment Detection
Past due operations are triggered by the credit’s payment schedule, which is defined in the credit condition. The system monitors for:
-
Regular payment dates - monthly, weekly, or other periodic payments based on the credit terms
-
Maturity date - the final payment date when the entire remaining balance becomes due
-
Missed payment amounts - comparing expected payments against actual payments received
Debt Movement Logic
When a past due event occurs, the operation performs account transfers:
-
INTEREST
balance →PAST_DUE_INTEREST
account -
PRINCIPAL
balance →PAST_DUE_PRINCIPAL
account
This reorganization enables the accrual engines to calculate late fees specifically on past due amounts, while new interest continues to accrue on any remaining current principal.
Maturity vs Regular Past Due
The maturity
flag in the operation distinguishes between two scenarios:
-
Regular Past Due: Borrower missed a scheduled payment but loan hasn’t matured - partial amounts may move to past due
-
Maturity Past Due: Loan has reached its final payment date - typically the entire remaining balance becomes past due
This distinction allows for different business rules, such as accelerated collection procedures or different late fee calculations for matured loans.
Integration with Credit Lifecycle
Past due operations integrate with other parts of the system:
-
Accrual Operations: Automatically created to calculate late fees on the newly past due amounts
-
Credit Labels: Display indicators like
PastDueLabel
to show past due status without changing the core credit status -
Notification Systems: Often trigger automated borrower communications
-
Collection Workflows: May initiate collection processes or escalation procedures
This design ensures that the transition from current to past due debt is handled consistently and triggers all necessary downstream processes.
16.6. Account Structure and Debt Management
The operations framework uses a flexible account-based debt structure defined in the configuration.
Account Type Constants
The example project defines these account types in CreditCalculationConfiguration
:
public static final String PRINCIPAL = "PRINCIPAL";
public static final String INTEREST = "INTEREST";
public static final String PAST_DUE_PRINCIPAL = "PD_PRINCIPAL";
public static final String PAST_DUE_INTEREST = "PD_INTEREST";
public static final String LATE_FEE = "LATE_FEE";
public static final String OVERPAYMENT = "OVERPAYMENT";
These accounts represent different types of debt:
* PRINCIPAL
- Outstanding loan principal amount
* INTEREST
- Accrued interest charges
* PAST_DUE_PRINCIPAL
- Overdue principal amounts
* PAST_DUE_INTEREST
- Overdue interest amounts
* LATE_FEE
- Late payment penalties
* OVERPAYMENT
- Credit balance from overpayments
16.7. Operation Configuration
The operations framework requires careful configuration to define how operations behave, how payments are distributed, and how the overall credit system operates. The example project demonstrates a complete configuration approach.
Configuration Architecture
All operation-related configuration is centralized in CreditCalculationConfiguration
, which serves as the single source of truth for:
-
Credit Status Definitions - Available credit states and their properties
-
Account Structure - Debt account types and their relationships
-
Operation Handlers - Services that process each operation type
-
Payment Distribution - Rules for how payments are applied to debt
-
Accrual Engines - Time-based calculation components
-
Credit View Options - UI display configuration
Credit Status Configuration
The framework defines credit statuses with specific properties:
public static final CreditStatus PENDING = new CreditStatus("PENDING", 1000, false);
public static final CreditStatus ACTIVE = new CreditStatus("ACTIVE", 1100, false);
public static final CreditStatus CLOSED = new CreditStatus("CLOSED", 2000, true);
public static final CreditStatus VOID = new CreditStatus("VOID", 2100, true);
Each status includes: * Name: Human-readable identifier * Order: Numeric value for status progression logic * Ending Flag: Whether this status represents a terminal state
Account Structure Configuration
The debt account structure is defined as constants:
public static final String PRINCIPAL = "PRINCIPAL";
public static final String INTEREST = "INTEREST";
public static final String PAST_DUE_PRINCIPAL = "PD_PRINCIPAL";
public static final String PAST_DUE_INTEREST = "PD_INTEREST";
public static final String LATE_FEE = "LATE_FEE";
public static final String OVERPAYMENT = "OVERPAYMENT";
These accounts represent different types of debt and credit balances:
* Current Debt: PRINCIPAL
, INTEREST
- active loan balances
* Past Due Debt: PAST_DUE_PRINCIPAL
, PAST_DUE_INTEREST
- overdue amounts
* Penalties: LATE_FEE
- fees for late payments
* Credits: OVERPAYMENT
- borrower credit balances
Operation Handler Configuration
Each operation type requires a corresponding handler bean:
Charge Operation Handler
@Bean
ChargeOperationService chargeOperationService() {
return new ChargeOperationService();
}
The ChargeOperationService
implements CreditOperationHandler<ChargeOperation>
and defines how charge operations affect debt balances.
Payment Operation Handler
@Bean
CreditPaymentOperationHandler<ExampleCreditPayment> creditPaymentOperationHandler() {
return new CreditPaymentOperationHandler<>(OVERPAYMENT, List.of(PAST_DUE_PRINCIPAL, PAST_DUE_INTEREST,
LATE_FEE, INTEREST, PRINCIPAL)) {};
}
The payment handler configuration is critical as it defines:
* Overpayment Account: Where excess payments are credited (OVERPAYMENT
)
* Payment Waterfall: The order in which payments are applied to debt accounts
Past Due Operation Handler
@Bean
PastDueOperationService pastDueOperationService() {
LinkedHashMap<String, String> map = new LinkedHashMap<>();
map.put(INTEREST, PAST_DUE_INTEREST);
map.put(PRINCIPAL, PAST_DUE_PRINCIPAL);
return new PastDueOperationService(map);
}
The mapping defines how current debt accounts are transferred to past due accounts when payments are missed.
Accrual Engine Configuration
Accrual engines handle specific types of time-based calculations:
Loan Engine Configuration
The loan engine orchestrates the overall calculation process:
@Bean
LoanEngine loanEngine() {
return new BasicLoanEngine(PENDING);
}
The BasicLoanEngine
is initialized with the default credit status (PENDING
) for new credits.
Credit View Configuration
The view configuration determines which accounts are displayed in the user interface:
@Bean
CreditViewOptions creditViewOptions() {
return new CreditViewOptions(PRINCIPAL, INTEREST, PAST_DUE_PRINCIPAL, PAST_DUE_INTEREST, LATE_FEE);
}
This configuration excludes the OVERPAYMENT
account from standard credit displays, as it represents a credit balance rather than debt.
Customizing Configuration for Different Credit Products
Different credit products require different configurations. Common customization patterns include:
Custom Account Structures and Payment Distribution
// Credit card configuration
public static final String PURCHASES = "PURCHASES";
public static final String CASH_ADVANCES = "CASH_ADVANCES";
public static final String FEES = "FEES";
// Payment order: fees first, then cash advances, then purchases
new CreditPaymentOperationHandler<>("CREDIT_BALANCE",
List.of("FEES", "CASH_ADVANCES", "PURCHASES"));
// Mortgage configuration with escrow
public static final String ESCROW = "ESCROW";
public static final String PMI = "PMI";
// Payment order: fees, past due, escrow, current debt
new CreditPaymentOperationHandler<>("ESCROW_SURPLUS",
List.of("LATE_FEE", "PAST_DUE_PRINCIPAL", "ESCROW", "PRINCIPAL"));
Configuration Validation
The framework automatically validates configuration consistency:
-
Handler Registration: Ensures all operation types have corresponding handlers
-
Account References: Validates that payment distribution references valid account types
-
Engine Registration: Confirms accrual engines are properly registered
-
Status Consistency: Checks that status definitions are logically consistent
Configuration Best Practices
-
Centralized Configuration: Keep all operation configuration in a single class for maintainability
-
Meaningful Constants: Use descriptive names for account types and statuses
-
Documented Relationships: Clearly document how accounts relate to each other
-
Environment-Specific Beans: Use Spring profiles or conditions for product-specific configurations
-
Validation: Implement configuration validation to catch setup errors early
-
Consistent Naming: Use consistent naming patterns across account types and operation handlers
The configuration approach in the example project provides a flexible foundation that can be adapted for various lending products while maintaining consistency and clarity.
16.8. Operation Synchronization
The PreCalculateSynchronizer
interface enables operations to maintain consistency with related events.
Synchronization Example
The AccrualOperationService
demonstrates how synchronization works by automatically creating accrual operations when payments or past due events occur. The service implements PreCalculateSynchronizer
and scans for trigger operations, ensuring that accrual operations exist for dates when debt balances change.
You can examine the complete implementation in the source code: AccrualOperationService.java
.
16.9. Real-world Usage Patterns
Understanding how operations work together in practice is essential for implementing robust credit systems. The example project’s test cases demonstrate several real-world scenarios that show the complete operation flow.
Scenario 1: Loan Disbursement and Interest Accrual
This scenario demonstrates the basic credit lifecycle from disbursement through interest calculation.
The Flow
-
Credit Creation: A new credit is created with defined terms (principal amount, interest rate, payment schedule)
-
Principal Disbursement: A
ChargeOperation
adds the loan principal to thePRINCIPAL
account -
Time Progression: As time passes, the calculation engine processes each day
-
Interest Accrual:
AccrualOperationService
automatically createsAccrualOperation
entities for interest calculation -
Balance Updates: Interest is calculated and added to the
INTEREST
account
Scenario 2: Payment Processing and Distribution
This scenario shows how borrower payments are processed and distributed across debt accounts.
The Flow
-
Outstanding Debt: Credit has balances in multiple accounts (principal, interest, fees)
-
Payment Received: An
ExampleCreditPayment
operation is created -
Payment Distribution: The payment handler applies the payment according to the configured waterfall
-
Balance Updates: Debt accounts are reduced according to priority order
-
Continued Accruals: Interest continues to accrue on remaining balances
Payment Waterfall Logic
The example project uses this payment priority:
1. PAST_DUE_PRINCIPAL
- Overdue principal first
2. PAST_DUE_INTEREST
- Overdue interest second
3. LATE_FEE
- Late fees third
4. INTEREST
- Current interest fourth
5. PRINCIPAL
- Current principal last
From the Test Cases
The paymentOperation1()
and paymentOperation2()
tests demonstrate:
Partial Payment Scenario:
Charge Principal → Partial Payment → Verify Interest Paid → Verify Principal Unchanged
Full Payment Scenario:
Charge Principal → Full Payment → Verify Interest Paid → Verify Principal Reduced
Scenario 3: Past Due Processing and Late Fees
This scenario illustrates the complex process of handling missed payments and calculating late fees.
The Flow
-
Payment Due Date: A scheduled payment date arrives
-
Insufficient Payment: Borrower makes no payment or insufficient payment
-
Past Due Operation: System automatically creates
PastDueOperation
-
Account Transfers: Current debt moves to past due accounts
-
Late Fee Accrual:
LateFeeAccrualEngine
begins calculating fees on past due amounts -
Payment Priority Change: Future payments prioritize past due amounts
Account Movement Logic
When past due occurs:
* INTEREST
balance → PAST_DUE_INTEREST
account
* PRINCIPAL
balance → PAST_DUE_PRINCIPAL
account
From the Test Case
The pastDue1()
test demonstrates this complex scenario:
Charge Principal → Insufficient Payment → Calculate Past Due Date → Verify Account Transfers → Verify Late Fees
The test shows how the system: - Moves unpaid amounts to past due accounts - Calculates late fees on the past due balances - Maintains accurate balance tracking across account types
Scenario 4: End-of-Day vs Intraday Processing
This scenario highlights the importance of operation timing in business logic.
The Concept
-
Intraday Operations: Charges, payments, and accruals process immediately
-
End-of-Day Operations: Past due operations wait until the business day is "closed"
Business Rationale
Past due operations use isEndDayOperation() = true
to prevent premature past due status if a payment arrives later in the same business day.
Example Timeline
9:00 AM - Payment due date arrives
10:00 AM - Borrower payment received (processed immediately)
11:00 AM - Another payment received (processed immediately)
End of Day - Past due operation processes only if still insufficient payment
This prevents false past due situations when payments arrive throughout the day.
Scenario 5: Operation Synchronization in Action
This scenario demonstrates how the synchronization system maintains data consistency.
The Challenge
Accrual operations must exist for every date when: - Payments are made (to calculate interest up to payment date) - Past due events occur (to recalculate accruals on new account structure)
The Solution
AccrualOperationService
implements PreCalculateSynchronizer
:
-
Scan for Trigger Events: Finds all payment and past due operations
-
Identify Required Dates: Determines which dates need accrual calculations
-
Create Missing Operations: Adds
AccrualOperation
entities for missing dates -
Cancel Unnecessary Operations: Removes accrual operations for dates without triggers
Key Patterns from Real Usage
1. Event-Driven Architecture
Operations trigger other operations automatically: - Payments trigger accrual calculations - Past due events trigger late fee calculations - Each operation maintains referential integrity
2. Chronological Processing
The calculation engine processes operations in strict date order:
- Earlier operations affect later operations
- Processing order within a date matters (getOrder()
values)
- State changes are cumulative and consistent
Testing Real-World Scenarios
The example project’s CalculationTest
demonstrates how to test these scenarios:
-
Setup Realistic Conditions: Create credits with proper terms and schedules
-
Apply Operations in Sequence: Mirror real-world event timing
-
Verify All Effects: Check not just primary changes but secondary effects
-
Test Edge Cases: Include scenarios like overpayments and zero balances
These patterns provide a foundation for implementing robust credit operations that handle the complexity of real-world lending scenarios while maintaining accuracy and auditability.
16.10. Best Practices
Operation Design Principles
-
Immutability: Operations should be immutable once created and approved
-
Idempotency: Operations should produce the same result when applied multiple times
-
Atomicity: Each operation should represent a single, atomic business transaction
-
Auditability: All operations must be fully auditable with complete change history
-
Extensibility: Design operations to be easily extended for new business requirements
Performance Optimization
-
Batch Processing: Group related operations for efficient processing
-
Lazy Loading: Use lazy loading for operation collections to avoid N+1 queries
-
Indexing: Create appropriate database indexes for operation queries
-
Caching: Cache frequently accessed operation handlers and calculation results
Error Handling
-
Validation: Validate operations before processing to catch errors early
-
Retry Logic: Implement retry mechanisms for transient failures
-
Compensation: Design compensation operations for failed transactions
-
Monitoring: Monitor operation processing for performance and error patterns
Security Considerations
-
Authorization: Ensure proper authorization for operation creation and modification
-
Data Protection: Protect sensitive operation data with encryption
-
Audit Logging: Maintain comprehensive audit logs for regulatory compliance
-
Access Control: Implement role-based access control for operation management
16.11. Testing Operations
The operations framework provides comprehensive testing capabilities that allow you to verify operation behavior, calculation accuracy, and integration between different operation types.
Integration Testing Approach
The example project demonstrates a complete integration testing strategy using CalculationTest
that tests the entire operation processing pipeline.
Test Configuration
The test uses a comprehensive Spring configuration that mirrors the production setup:
@DataJpaTest
@AutoConfigureEmbeddedDatabase(provider = DatabaseProvider.ZONKY)
@ContextConfiguration(classes = {CreditCalculationConfiguration.class, CalculationTest.CalculationTestConfig.class})
Key testing components:
* Embedded Database: Uses Zonky for isolated database testing
* Transaction Management: TransactionTemplateBuilder
for proper transaction handling
* Real Services: Uses actual CreditCalculationService
and AccrualService
instances
* Complete Configuration: Includes all operation handlers and accrual engines
Testing Patterns
The example project demonstrates several essential testing patterns:
Testing the complete credit lifecycle from creation through operations:
// Create credit with realistic conditions
UUID creditId = initCredit(startDate, TODAY);
// Apply operations
charge(creditId, chargeDate, principalAmount);
registerPayment(creditId, paymentDate, paymentAmount);
// Trigger calculation
calculate(creditId, startDate, TODAY);
// Verify results
ExampleCredit credit = entityManager.find(ExampleCredit.class, creditId);
Assertions.assertEquals(expectedBalance, credit.getActualSnapshot().getDebt().getAccount(PRINCIPAL).get());
Testing how different operations interact with each other:
@Test
public void paymentOperation1() {
// Setup: Create credit and charge principal
charge(creditId, chargeDate, principal);
// Test: Make partial payment
registerPayment(creditId, paymentDate, partialPayment);
calculate(creditId, startDate, TODAY);
// Verify: Check payment distribution and remaining balances
ExampleCreditPayment payment = credit.getOperations(ExampleCreditPayment.class, APPROVED).findAny().get();
assertEquals(expectedInterestPayment, payment.getFinalDebt().get().getAccount(INTEREST).get());
assertEquals(expectedRemainingInterest, credit.getActualSnapshot().getDebt().getAccount(INTEREST).get());
}
Testing time-based calculations and accrual accuracy:
@Test
public void chargeOperation() {
charge(creditId, chargeDate, principal);
calculate(creditId, startDate, TODAY);
// Verify accrued interest calculation
MonetaryAmount expectedInterest = principal.multiply(
(INTEREST_RATE.doubleValue() / 100d) * (daysBetween / 360d)
);
Debt accruals = accrualService.calculateCurrentAccurals(credit);
assertEquals(expectedInterest, accruals.getAccount(INTEREST).get());
}
Testing complex past due scenarios:
@Test
public void pastDue1() {
// Setup credit with insufficient payment
charge(creditId, chargeDate, principal);
registerPayment(creditId, paymentDate, insufficientPayment);
// Calculate beyond payment due date
calculate(creditId, startDate, TODAY.plusMonths(1));
// Verify past due balances and late fee accruals
assertEquals(expectedPastDueInterest, credit.getActualSnapshot().getDebt().getAccount(PAST_DUE_INTEREST).get());
assertEquals(expectedLateFee, accrualService.calculateCurrentAccurals(credit).getAccount(LATE_FEE).get());
}
Testing Utilities
The test class provides reusable utility methods for common testing scenarios:
Credit Initialization
public UUID initCredit(LocalDate startDate, LocalDate today) {
return transactionTemplateBuilder.requiresNew().execute(s -> {
// Create complete credit structure: product, condition, application, credit
// Return credit ID for use in tests
});
}
Operation Creation
public void charge(UUID creditId, LocalDate operationDate, MonetaryAmount amount) {
transactionTemplateBuilder.requiresNew().executeWithoutResult(status -> {
chargeOperationService.createOperation(creditId, operationDate, amount);
});
}
public void registerPayment(UUID creditId, LocalDate paymentDate, MonetaryAmount amount) {
transactionTemplateBuilder.requiresNew().executeWithoutResult(status -> {
entityManager.find(ExampleCredit.class, creditId).getOperations()
.add(new ExampleCreditPayment(paymentDate, amount));
});
}
Test Data Management
The tests use realistic financial data and calculations:
private static final BigDecimal INTEREST_RATE = BigDecimal.valueOf(12); // 12% annual
private static final BigDecimal LATE_FEE_RATE = BigDecimal.valueOf(24); // 24% annual
private static final BigDecimal PRINCIPAL = BigDecimal.valueOf(2_000_000); // 2M ZWL
Interest calculations use proper day-count methods:
MonetaryAmount interest = principal.multiply(
(INTEREST_RATE.doubleValue() / 100d) * (ChronoUnit.DAYS.between(chargeDate, TODAY) / 360d)
);
Assertion Strategies
The tests demonstrate comprehensive assertion patterns:
Balance Verification
assertEquals(expectedAmount, credit.getActualSnapshot().getDebt().getAccount(PRINCIPAL).get());
Operation Effect Verification
ExampleCreditPayment payment = credit.getOperations(ExampleCreditPayment.class, APPROVED).findAny().get();
assertEquals(paymentAmount.negate(), payment.getFinalDebt().get().getAccount(INTEREST).get());
Best Practices for Operation Testing
-
Use Realistic Data: Test with actual financial amounts and rates that reflect real-world scenarios
-
Test Operation Sequences: Verify that operations work correctly when applied in different orders
-
Verify Time-based Calculations: Ensure accruals calculate correctly across different time periods
-
Test Edge Cases: Include scenarios like overpayments, zero balances, and boundary conditions
-
Use Proper Transactions: Each operation should be in its own transaction to mirror production behavior
-
Verify All Accounts: Check not just the primary effects but also secondary account impacts
-
Test Calculation Accuracy: Use precise mathematical calculations to verify financial accuracy
The testing approach in the example project provides a solid foundation for ensuring operation correctness and can be extended for custom operation types and business scenarios.
The Credit Operations Framework provides a solid foundation for building sophisticated financial applications while maintaining flexibility for customization and extension. By following these patterns and best practices, you can create robust, scalable operation processing systems that meet your specific business requirements.
17. Payment Transactions Framework
The Payment Transactions Framework connects real-world payments to credit operations. When someone makes a payment, it creates a transaction record, processes it through a payment gateway, and then creates the corresponding credit operation. This ensures every payment operation can be traced back to an actual payment.
17.1. The Basic Concept
Think of payment transactions as a receipt system:
-
Customer pays → Create transaction record (like writing a receipt)
-
Process payment → Send to bank/payment processor
-
Payment succeeds → Create credit operation (update the loan balance)
-
Keep records → Maintain complete audit trail
This two-step process (transaction → operation) ensures every balance change has a real-world payment behind it.
17.2. Architecture Overview
The system has three main layers that work together:
Transaction Layer
What it does: Records and processes real payments
BorrowerTransaction → PaymentGateway → Bank/Card Processor
Bridge Layer
What it does: Converts successful payments into credit operations
BorrowerTransactionService.handle() → Creates CreditOperation
Credit Layer
What it does: Updates loan balances and calculates interest
ExampleCreditPayment → Credit Calculation → Updated Balances
Core Components
The payment transaction system includes:
-
PaymentTransaction
- Base record for all payment attempts -
BorrowerTransaction
- Example project’s payment transaction type -
PaymentGateway
- Interface for connecting to payment processors -
PaymentMethod
- How customer wants to pay (bank account, card, etc.) -
BorrowerTransactionService
- Converts successful payments to credit operations
17.3. Real-World Basis Principle
All operations in the credit system must have verifiable real-world foundations:
Payment Operations Foundation
Payment operations must originate from actual payment transactions:
Real Payment → PaymentTransaction → Success Handler → CreditOperation → Balance Update
Other Operations Foundations
While payment operations require transactions, other operations have their own real-world basis:
-
Accrual Operations → Contract terms and offer conditions (interest rates, payment schedules)
-
Past Due Operations → Payment schedule agreements (contractual due dates)
-
Charge Operations → Should be based on disbursements or outgoing transactions
The example project may have a flaw with charge operations - they should typically be tied to disbursement transactions, outgoing transactions, or regulatory events rather than being manually created without clear business justification. |
This principle ensures: * Regulatory Compliance - Every financial change can be traced to a real event * Audit Trail Integrity - Complete documentation of why changes occurred * Business Logic Accuracy - Operations reflect actual business events * Fraud Prevention - Prevents unauthorized or fictitious transactions
17.4. Transaction Entity Structure
Every payment attempt creates a PaymentTransaction
record that tracks the payment from start to finish.
BorrowerTransaction Example
The example project uses BorrowerTransaction
for loan payments:
@Entity
@DiscriminatorValue("BORROWER")
public class BorrowerTransaction extends PaymentTransaction {
@ManyToOne(fetch = FetchType.LAZY)
private ExampleCredit credit;
@ManyToOne(fetch = FetchType.LAZY)
@NotAudited
private CreditOperation operation;
public BorrowerTransaction(TransactionType type, MonetaryAmount amount,
PaymentMethod paymentMethod, ExampleCredit credit) {
super(type, amount);
this.credit = credit;
setPaymentMethod(paymentMethod);
}
public ExampleCredit getCredit() { return credit; }
public CreditOperation getOperation() { return operation; }
public void setOperation(CreditOperation operation) { this.operation = operation; }
}
Key fields:
* credit
- Which loan this payment is for
* operation
- The credit operation created when payment succeeds
* amount
- How much money (uses MonetaryAmount
for currency handling)
* type
- INCOMING
(customer pays) or OUTGOING
(refund/disbursement)
* status
- Current state of the payment
* paymentMethod
- How the customer is paying (bank account, card, etc.)
Transaction Status Lifecycle
Transactions go through these states:
DRAFT → READY_FOR_EXECUTION → IN_PROGRESS → SUCCEED
↓ ↓ ↓ ↓
CANCELLED CANCELLED FAILED (Create Operation)
↓
UNAVAILABLE
↓
(Manual Review)
Understanding Transaction Status
The TransactionStatus
enum has three important properties:
public enum TransactionStatus {
SUCCEED(800, true, true), // successful=true, complete=true
FAILED(700, false, true), // successful=false, complete=true
IN_PROGRESS(400, false, false); // successful=false, complete=false
private boolean successful; // Did the payment work?
private boolean complete; // Is processing finished?
}
Status meanings:
* DRAFT
- Transaction created, not yet sent to payment processor
* IN_PROGRESS
- Sent to payment processor, waiting for response
* SUCCEED
- Payment processor approved the payment
* FAILED
- Payment processor declined the payment
* UNAVAILABLE
- System error or payment processor is down
* CHARGEBACK
- Bank reversed a previously successful payment
Transaction Processing Flow
The complete transaction processing follows this pattern:
-
Transaction Creation:
BorrowerTransaction
entity created with payment details -
Gateway Submission:
PaymentTransactionService
submits to appropriate gateway -
Async Processing: Transaction processed asynchronously to avoid blocking
-
Status Updates: Transaction status updated based on gateway response
-
Success Handling:
BorrowerTransactionService.handle()
creates credit operation -
Error Handling: Failed transactions trigger appropriate error responses
17.5. Payment Gateways
Payment gateways connect your system to banks and payment processors. Think of them as translators that convert your payment requests into the specific format each processor expects.
The Gateway Interface
All payment gateways implement the same interface:
public interface PaymentGateway {
String getMethodType(); // "ACH", "CARD", etc.
String getName(); // "Stripe", "Bank_ACH", etc.
boolean verify(PaymentMethod method) throws IOException;
TransactionResult proceedIncoming(String orderId, PaymentMethod method, MonetaryAmount amount);
TransactionResult proceedOutgoing(String orderId, PaymentMethod method, MonetaryAmount amount);
}
What each method does:
* verify()
- Validate payment method before processing (called by PaymentTransactionService.verify()
)
* proceedIncoming()
- Process customer payments (money coming in)
* proceedOutgoing()
- Process refunds and disbursements (money going out)
Gateway Implementation Patterns
Payment gateways can be implemented following these patterns:
For immediate credit/debit card processing:
@Service
public class CardPaymentGateway implements PaymentGateway {
public TransactionResult proceedIncoming(String orderId, PaymentMethod method, MonetaryAmount amount) {
// 1. Extract payment method details (tokenized)
// 2. Build API request with transaction data
// 3. Submit to payment processor via HTTPS
// 4. Parse response and map to TransactionResult
// 5. Return standardized result with gateway reference
}
}
Key characteristics: * Immediate Processing - Real-time API calls with instant responses * Token-Based Security - Uses tokenized payment methods for PCI compliance * Structured Response - JSON/XML responses parsed into standard result format * Error Detection - Handles duplicate transactions and various error conditions
For traditional banking integration:
@Service
public class ACHGateway implements PaymentGateway {
public TransactionResult proceedOutgoing(String orderId, PaymentMethod method, MonetaryAmount amount) {
// 1. Build SOAP command with ACH details
// 2. Add merchant credentials and security headers
// 3. Submit via SOAP web service
// 4. Handle asynchronous ACH processing status
// 5. Return result with settlement timing information
}
}
Key characteristics: * SOAP Integration - XML-based web service communication * Asynchronous Processing - ACH transactions require settlement time * Comprehensive Logging - Full request/response logging for audit * Credential Management - Secure handling of merchant credentials
For bulk ACH processing via NACHA files:
@Service
public class NACHABatchGateway implements PaymentGateway {
@Scheduled(fixedRate = 3600000) // Hourly batch processing
public void processBatch() {
// 1. Find transactions ready for batch processing
// 2. Create NACHA-compliant file format
// 3. Add each transaction to appropriate batch
// 4. Generate file and transmit via SFTP
// 5. Update transaction statuses
}
}
Key characteristics: * Batch Processing - Multiple transactions in single file * File-Based Transport - SFTP or similar file delivery * NACHA Compliance - Proper ACH file format generation * Delayed Settlement - Transactions marked successful when file sent, not when settled
Gateway Configuration
Different gateways can be configured for different payment types:
@Service
public class ACHGateway implements PaymentGateway {
public String getMethodType() { return "ACH"; }
public String getName() { return "Bank_ACH"; }
}
@Service
public class CardGateway implements PaymentGateway {
public String getMethodType() { return "CARD"; }
public String getName() { return "Stripe"; }
}
The system selects the appropriate gateway based on the payment method type.
17.6. Payment Methods
Payment methods represent how customers want to pay - bank account, credit card, etc. Each payment method stores the necessary information to process payments through the appropriate gateway.
Example: LiquidityClientPaymentMethod
The example project includes a simple payment method for testing:
@Entity
@DiscriminatorValue(LiquidityClientPaymentMethod.TYPE)
public class LiquidityClientPaymentMethod extends PaymentMethod {
public static final String TYPE = LiquidityPaymentGateway.GATEWAY_TYPE;
@Column(name = "processed_date")
private LocalDate processedDate;
@Embedded
private MonetaryAmount amount;
@Column(name = "name")
private String ownerName;
public LiquidityClientPaymentMethod(LocalDate processedDate, MonetaryAmount amount,
TransactionType transactionType, String ownerName) {
super(TYPE);
this.processedDate = processedDate;
this.amount = amount;
this.transactionType = transactionType;
this.ownerName = ownerName;
}
public LocalDate getProcessedDate() { return processedDate; }
public MonetaryAmount getAmount() { return amount; }
public String getOwnerName() { return ownerName; }
}
This payment method: * Stores an amount - For testing, it has a fixed amount * Has a processed date - When the "payment" was processed * Works with gateways - Can be used by payment gateways that support this type
Payment Method Types
Different payment method types serve different use cases:
Type | Use Case | Processing Pattern | Security Model |
---|---|---|---|
ACH |
Bank account transfers |
Batch or real-time |
Account number encryption |
Debit/Credit Cards |
Card payments |
Real-time API |
PCI tokenization |
Digital Wallets |
Mobile payments |
Real-time API |
OAuth tokens |
Wire Transfers |
Large amounts |
Manual processing |
Bank verification |
Payment Method Implementation Patterns
When implementing new payment method types:
ACH Payment Method Pattern
@Entity
@DiscriminatorValue("ACH")
public class ACHPaymentMethod extends PaymentMethod {
// Encrypted bank account details
private String ownerName;
private String accountNumber; // Encrypted
private String routingNumber;
private AccountType accountType; // CHECKING, SAVINGS
// Validation and security methods
public boolean isValid() {
return validateRoutingNumber() && validateAccountNumber();
}
}
Key features: * Bank Account Details - Routing and account numbers for ACH processing * Account Type Classification - Checking vs savings account handling * Validation Logic - Routing number format and account number validation * Encryption - Sensitive account data encrypted at rest
Card Payment Method Pattern
@Entity
@DiscriminatorValue("CARD")
public class CardPaymentMethod extends PaymentMethod {
// Tokenized card data - no sensitive information stored
private String token; // From payment processor
private String lastFourDigits; // For display only
private String expiryMonth;
private String expiryYear;
public boolean isExpired() {
return LocalDate.now().isAfter(getExpiryDate());
}
}
Key characteristics: * Tokenization - Card numbers replaced with secure tokens from payment processor * PCI Compliance - No sensitive card data stored in application database * Display Information - Only last four digits stored for user interface * Expiry Validation - Built-in expiration checking
Payment Method Security
The framework implements comprehensive security patterns:
Data Protection
// Sensitive data encrypted at rest
@Convert(converter = EncryptedStringConverter.class)
private String accountNumber;
// Tokens from external processors
private String processorToken;
// Display-only information
private String maskedAccountNumber; // "****1234"
Validation and Verification
public interface PaymentMethodValidator {
boolean validate(PaymentMethod method);
ValidationResult verify(PaymentMethod method) throws IOException;
}
// Gateway-specific validation
@Override
public boolean verify(PaymentMethod method) throws IOException {
// Real-time validation with payment processor
return gateway.validatePaymentMethod(method);
}
17.7. How Transactions Become Operations
When a payment succeeds, the system needs to update the loan balance. This happens in BorrowerTransactionService.handle()
.
The Conversion Process
Here’s what happens when a payment succeeds:
@Override
public void handle(PaymentTransaction t) {
BorrowerTransaction transaction = (BorrowerTransaction) t;
if (transaction.getStatus() == TransactionStatus.SUCCEED) {
ExampleCredit credit = transaction.getCredit();
LocalDate date = getProcessedDate(transaction);
// Create the right type of operation
CreditOperation operation = switch (transaction.getType()) {
case INCOMING -> handleIncoming(credit, transaction, date); // Customer payment
case OUTGOING -> handleOutgoing(credit, transaction, date); // Refund/disbursement
};
// Link them together for audit trail
transaction.setOperation(operation);
}
}
Customer Payments (INCOMING)
When a customer makes a payment:
private CreditPayment handleIncoming(ExampleCredit credit, BorrowerTransaction transaction, LocalDate date) {
// Create payment operation
CreditPayment payment = new ExampleCreditPayment(date, transaction.getAmount());
// Register with credit system
return paymentService.registerPayment(credit, payment);
}
This creates an ExampleCreditPayment
operation that reduces the loan balance.
Refunds and Disbursements (OUTGOING)
When money goes to the customer:
private ChargeOperation handleOutgoing(ExampleCredit credit, BorrowerTransaction transaction, LocalDate date) {
// Create charge operation (increases balance)
return chargeOperationService.createOperation(credit.getId(), date, transaction.getAmount());
}
This creates a ChargeOperation
that increases the loan balance (for disbursements) or reverses payments (for refunds).
The Audit Trail
The system maintains complete traceability:
-
Transaction Record - Shows the real-world payment attempt
-
Gateway Response - Stored in
transaction.trace
field -
Operation Link -
transaction.operation
points to the credit operation -
Credit Update - Operation appears in credit’s operation list
This means you can always trace a balance change back to the original payment.
17.8. Processing Payments Asynchronously
Payment processing happens in the background so users don’t have to wait. When someone submits a payment, the system:
-
Creates transaction record - Saves it immediately
-
Returns to user - Shows "processing" message
-
Processes in background - Calls payment gateway
-
Updates status - Success or failure
-
Creates operation - If payment succeeded
Why Async Processing?
-
Faster user experience - Don’t wait for slow payment processors
-
Better error handling - Can retry failed payments
-
Scalability - Handle many payments at once
Error Handling
When processing payments, three things can happen:
Payment Declined
// Gateway says "insufficient funds" or "invalid card"
transaction.setStatus(TransactionStatus.FAILED);
transaction.addTrace("Gateway declined: " + result.getMessage());
System Error
// Code bug or unexpected error
transaction.setStatus(TransactionStatus.UNAVAILABLE);
transaction.addTrace("System error: " + e.getMessage());
Gateway Down
// Payment processor is unavailable
transaction.setStatus(TransactionStatus.UNAVAILABLE);
transaction.addTrace("Gateway unavailable: " + e.getMessage());
// Can retry later
17.9. Transaction Types and Patterns
Different transaction types serve different business purposes and follow specific processing patterns.
Incoming Payment Transactions
Borrower payments to reduce credit balances:
BorrowerTransaction payment = new BorrowerTransaction(
TransactionType.INCOMING,
credit,
paymentMethod,
paymentAmount,
"Borrower payment"
);
Processing flow:
1. User Initiates - Borrower submits payment through portal
2. Transaction Created - BorrowerTransaction
entity persisted
3. Gateway Processing - Payment method charged via appropriate gateway
4. Success Handling - ExampleCreditPayment
operation created
5. Balance Update - Credit calculation applies payment to debt accounts
Outgoing Payment Transactions
Disbursements or refunds to borrowers:
BorrowerTransaction disbursement = new BorrowerTransaction(
TransactionType.OUTGOING,
credit,
paymentMethod,
disbursementAmount,
"Loan disbursement"
);
Processing flow: 1. System Initiates - Loan approval triggers disbursement 2. Transaction Created - Outgoing transaction entity 3. Gateway Processing - Funds sent to borrower account 4. Success Handling - Disbursement operation created 5. Balance Update - Principal balance increased
Chargeback Transactions
Handling payment reversals:
// Original payment is reversed
originalTransaction.setStatus(TransactionStatus.CHARGEBACK);
// Chargeback operation created to reverse the payment
ChargebackOperation chargeback = new ChargebackOperation(
originalPayment.getAmount().negate(),
"Chargeback: " + originalTransaction.getOrderId()
);
Retry Patterns
Failed transactions may be retried based on failure type:
if (canRetry(transaction, result)) {
scheduleRetry(transaction, calculateBackoffDelay(transaction.getRetryCount()));
} else {
markPermanentFailure(transaction, result);
}
Retry logic considers: * Failure Type - Network errors retryable, declines usually not * Retry Count - Exponential backoff with maximum attempts * Time Limits - Don’t retry indefinitely old transactions
17.10. Testing Payment Transactions
Testing payment transactions requires careful consideration of external dependencies and asynchronous processing.
Test Gateway Implementation
For testing, implement a controllable test gateway:
@Service
public class TestPaymentGateway implements PaymentGateway {
@Override
public TransactionResult proceedIncoming(String orderId, PaymentMethod method, MonetaryAmount amount) {
// Simulate different scenarios based on test data
if (amount.getNumber().doubleValue() == 999.99) {
return new TransactionResult(orderId, amount, Status.FAIL, false, "Test decline");
}
if ("ERROR_TOKEN".equals(method.getToken())) {
throw new RuntimeException("Test gateway error");
}
return new TransactionResult(orderId, amount, Status.SUCCESS, false, "Test success");
}
}
Integration Testing Patterns
Test the complete transaction-to-operation flow:
@Test
@Transactional
public void testSuccessfulPayment() {
// Setup: Create credit and payment method
UUID creditId = createTestCredit();
PaymentMethod paymentMethod = createTestPaymentMethod();
// Execute: Process payment transaction
BorrowerTransaction transaction = new BorrowerTransaction(
TransactionType.INCOMING, credit, paymentMethod,
MonetaryAmount.of(500, "USD"), "Test payment"
);
paymentTransactionService.processTransaction(transaction.getId());
// Wait for async processing
await().atMost(5, SECONDS).until(() ->
transactionRepository.findById(transaction.getId()).getStatus() == TransactionStatus.SUCCEED
);
// Verify: Check operation was created and credit updated
ExampleCredit updatedCredit = creditRepository.findById(creditId);
assertThat(updatedCredit.getOperations(ExampleCreditPayment.class)).hasSize(1);
ExampleCreditPayment payment = updatedCredit.getOperations(ExampleCreditPayment.class).iterator().next();
assertThat(payment.getAmount()).isEqualTo(MonetaryAmount.of(500, "USD"));
assertThat(payment.getTransaction()).isEqualTo(transaction);
}
Mocking External Dependencies
For unit tests, mock gateway dependencies:
@MockBean
PaymentGateway mockGateway;
@Test
public void testGatewayFailure() {
// Setup: Mock gateway to return failure
when(mockGateway.proceedDebit(any(), any(), any()))
.thenReturn(new TransactionResult("123", amount, Status.FAIL, false, "Declined"));
// Execute: Process transaction
paymentTransactionService.processTransaction(transactionId);
// Verify: Transaction marked as failed
BorrowerTransaction transaction = transactionRepository.findById(transactionId);
assertThat(transaction.getStatus()).isEqualTo(TransactionStatus.FAILED);
// Verify: No operation created
assertThat(credit.getOperations()).isEmpty();
}
17.11. Security and Compliance
Payment transaction processing requires adherence to strict security and compliance standards.
PCI DSS Compliance
For card payments:
-
No Card Storage - Card numbers never stored in application database
-
Tokenization - Sensitive data replaced with non-sensitive tokens
-
Secure Transmission - All payment data encrypted in transit
-
Access Controls - Role-based access to payment functionality
Bank Security Standards
For ACH payments:
-
Encryption at Rest - Bank account data encrypted in database
-
Secure APIs - TLS encryption for all gateway communication
-
Credential Management - Secure storage of gateway credentials
-
Audit Logging - Complete transaction audit trails
Regulatory Compliance
Financial regulations require:
-
Transaction Traceability - Complete audit trail from user action to balance change
-
Data Retention - Transaction records maintained for required periods
-
Reporting - Transaction data available for regulatory reporting
-
Error Handling - Proper handling and reporting of failed transactions
17.12. Best Practices
Transaction Design Principles
-
Idempotency - Transactions should produce same result when retried
-
Atomicity - Each transaction represents a single business event
-
Traceability - Complete audit trail from initiation to completion
-
Error Recovery - Graceful handling of all failure scenarios
Gateway Integration Best Practices
-
Timeout Handling - Appropriate timeouts for gateway calls
-
Retry Logic - Intelligent retry strategies for transient failures
-
Rate Limiting - Respect gateway rate limits and quotas
-
Monitoring - Comprehensive monitoring of gateway performance
17.13. Summary
The Payment Transactions Framework ensures every credit operation has a real-world basis:
The Flow:
Customer Payment → BorrowerTransaction → PaymentGateway → Bank/Processor
↓
Payment Succeeds → BorrowerTransactionService.handle() → ExampleCreditPayment → Updated Loan Balance
Key Benefits: * Complete audit trail - Every balance change traces to a real payment * Async processing - Fast user experience, reliable background processing * Multiple gateways - Support different payment processors * Error handling - Graceful handling of declined payments and system errors * Regulatory compliance - Full documentation for audits
For Developers:
* Extend PaymentTransaction
for your transaction types
* Implement PaymentGateway
for new payment processors
* Use PaymentTransactionHandler
to convert transactions to operations
* Always maintain the transaction → operation link for audit trails
This foundation supports any type of payment processing while ensuring complete traceability and regulatory compliance.