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

  1. Platform Overview - Core concepts: Basic, Origination, and Servicing layers

  2. Getting Started - Master the Entity-Form-Controller pattern with Client example

  3. Data model setup - SQL autogeneration and Flyway migration workflows

2.2. PART II: USER INTERFACE

  1. Form classes setup and usage - Form classes, validation, MapStruct mappers, and service layers

  2. Form Templates - Form structure, layout patterns, and two-column grids for modal forms

  3. HTML Template Integration - Thymeleaf components, validation classes, and UI integration

  4. Fast Search System - Unified search across all entities with Hibernate Search and Lucene

  5. Document Management - Document upload, requirements, and UI integration

  6. Document Templates - Generate contracts, reports, and formatted documents

  7. Notifications - Multi-channel automated notifications with template-based content === PART III: BUSINESS LOGIC

  8. [entity-checkers-setup-and-usage] - Event-driven business rules and automation

  9. DataSource Integration - External API integration and data enrichment

  10. Workflow Integration - Integration for decision workflows

  11. Product Web Components Configuration - Automated web component setup for credit products and additives

  12. Offer Engine & Credit Products - Generate personalized loan offers from credit products and participant data

  13. REST API Integration - REST API setup and external system integration

  14. Docusign Integration - Document signing integration with DocuSign === PART IV: FINANCIAL OPERATIONS

  15. Credit Management System - Complete credit lifecycle management

  16. Credit Operations Framework - Credit operations: charges, payments, accruals, past due processing

  17. 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.3. Three Parts

🏗️ Basic - Foundation

Forms, database, templates, documents Used by everything

📝 Origination - New Loans

Applications, customers, approvals Getting loans started

💰 Servicing - Active Loans

Payments, interest, account management Day-to-day loan operations

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:

  1. Start with Getting Started - Set up your first application and see how it works

  2. Learn the Basic Components - Master forms, database, and templates (you’ll use these everywhere)

  3. Pick your focus:

    • For loan applications: Learn about customers, applications, and risk assessment

    • For loan management: Learn about credits, payments, and operations

  4. 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
  1. Clone the example project:

    git clone https://github.com/TimveroOS/timvero-example.git
    cd timvero-example
  2. 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
  3. Run the application:

    mvn spring-boot:run
  4. Access the application:

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:

  1. Check database is running:

    # PostgreSQL status check
    sudo systemctl status postgresql
    # Or for Docker
    docker ps | grep postgres
  2. Verify database connection:

    # Test connection manually
    psql -h localhost -p 5432 -U your_username -d your_database
  3. 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:

  1. Check migration file integrity:

    -- View migration history
    SELECT * FROM flyway_schema_history ORDER BY installed_rank DESC;
  2. Reset migrations (development only):

    # WARNING: This deletes all data
    mvn flyway:clean flyway:migrate
  3. 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:

  1. Find what’s using the port:

    # Linux/Mac
    lsof -i :8081
    # Windows
    netstat -ano | findstr :8081
  2. Change application ports:

    # In application.properties
    server.port=8090
    management.server.port=8091
  3. 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:

  1. Check Java version:

    java -version
    javac -version
    echo $JAVA_HOME
  2. Ensure Java 21+:

    # Install Java 21 if needed
    # Ubuntu/Debian
    sudo apt install openjdk-21-jdk
    # macOS with Homebrew
    brew install openjdk@21
  3. 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:

  1. Clean and rebuild:

    mvn clean compile
    mvn clean install -U  # Force update dependencies
  2. Check Maven version:

    mvn --version  # Should be 3.8+
  3. 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:

  1. Verify application started successfully:

    # Check logs for "Started Application in X seconds"
    tail -f logs/application.log
  2. 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)
  3. 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:

  1. Verify migration files exist:

    ls -la src/main/resources/db/migration/
    # Should see V*.sql files
  2. 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
  • {ok} Create, Read, Update, Delete operations

  • {ok} Form validation and error handling

  • {ok} List view with search and filtering

  • {ok} Responsive web interface

  • {ok} 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:

Table 1. Platform Architecture Pattern
Component Purpose Example

Entity

JPA entity with business logic and relationships

Client - stores customer data with audit trail

Form

DTO for user input with validation rules

ClientForm - handles form submission and validation

Controller

Main entity controller providing CRUD operations

ClientController - handles entity management

Actions

Specific operation buttons in the UI

CreateClientAction, EditClientAction - handle specific operations

Service

Business logic and entity-form mapping

ClientFormService - converts between entities and forms

Mapper

Automatic bidirectional object mapping

ClientFormMapper - MapStruct-generated conversions

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:

  1. Check validation annotations:

    // Ensure @Valid is present on nested objects
    @Valid
    @NotNull
    private IndividualInfoForm individualInfo;
  2. 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:

  1. Check controller annotations:

    @Controller  // Must be @Controller, not @RestController
    public class CreateClientAction extends EntityCreateController<UUID, Client, ClientForm> {
    }
  2. 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:

  1. 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')}" />
  2. 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
  • {todo} Create your first custom entity following the Client pattern

  • {todo} Add custom validation rules to your forms

  • {todo} Implement an Entity Checker for business logic automation

  • {todo} Set up document management for your entities

  • {todo} Integrate with an external data source

  • {todo} Customize the UI templates for your specific needs

  • {todo} 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:

  1. Entity Analysis: The system scans all JPA entity classes for changes

  2. Schema Comparison: Compares current entity definitions with the existing database schema

  3. SQL Generation: Creates appropriate DDL statements (CREATE, ALTER, DROP) for detected changes

  4. File Creation: Generates timestamped migration files in the hbm2ddl directory

  5. 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
public class Participant extends AbstractAuditable implements NamedEntity, GithubDataSourceSubject, HasDocuments,
    ProcessEntity, DocusignSigner, HasPendingDecisions, HasMetric {

    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:

ParticipantStatus enum
public enum ParticipantStatus implements InEnum<ParticipantStatus> {
    NEW,
    IN_PROCESS,
    MANUAL_APPROVAL,
    APPROVED,
    DECLINED,
    VOID;

    public boolean isActive() {
        return !this.in(DECLINED, VOID);
    }
}
Employment enum
public enum Employment {

    EMPLOYED,
    HOMEMAKER,
    UNEMPLOYED,
    RETIRED,
    SELF_EMPLOYED

}
Periodicity enum
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
  1. Entity Definition: Define your entity classes with appropriate JPA annotations

  2. Application Execution: Run the application to trigger the automatic analysis process

  3. SQL Autogeneration: The platform analyzes entity changes and generates SQL scripts in the hbm2ddl subdirectory

  4. Migration File Preparation: Move generated SQL files from hbm2ddl to the Flyway migration directory (src/main/resources/db/migration/)

  5. File Naming: Rename files to follow Flyway convention: V{version}__{description}.sql

  6. Flyway Execution: During application startup, Flyway executes pending migrations in version order

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

V250609220043__participantStatus.sql
-- 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:

  1. Check application.home property:

    # In application.properties
    application.home=/path/to/your/project
    # Or use relative path
    application.home=.
  2. Verify entities are detected:

    // Ensure entities have proper annotations
    @Entity
    @Table(name = "your_table_name")
    public class YourEntity extends AbstractAuditable<UUID> {
    }
  3. 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:

  1. 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!
  2. Use consistent timestamp format:

    # Generate timestamp for new migration
    date +"%y%m%d%H%M%S"
    # Use this in filename: V250610143022__your_description.sql
  3. 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:

  1. 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
  2. Monitor connection usage:

    # Enable connection pool metrics
    spring.datasource.hikari.register-mbeans=true
    management.endpoints.web.exposure.include=metrics
  3. 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:

  1. 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;
  2. 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:

  1. 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);
  2. 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;
    }
  3. 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 and ApplicationForm

  • Nested Forms: Component forms like IndividualInfoForm and ContactInfoForm

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

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

Contact information component:

    @NotBlank
    @Email
    private String email;

    @NotBlank
    @Phone
    private String phone;
ParticipantForm

Financial participant information:

    @NotNull
    private Employment employment;

    @NotNull
    private Periodicity howOftenIncomeIsPaid;

    @NotNull
    private MonetaryAmount totalAnnualIncome;

    private MonetaryAmount monthlyOutgoings;
Validation Annotations Used

The form classes use standard Bean Validation (JSR-303) annotations:

Validation Annotations in Use
@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:

Generic Action Structure
@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:

EntityFormService Usage
@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 Structure
@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:

  1. Action Invocation: CreateClientAction or EditClientAction is called

  2. Service Delegation: Action delegates to ClientFormService

  3. Mapper Usage: Service uses ClientFormMapper for conversions

  4. Entity Operations: Service performs database operations

  5. Response Generation: Converted data is returned to the controller

Example Flow for Edit Operation
EditClientAction<UUID, Client, ClientForm>
    ↓
ClientFormService.prepareEditModel(UUID id)
    ↓
ClientFormMapper.entityToForm(Client entity)
    ↓
ClientForm (ready for template rendering)
Example Flow for Save Operation
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:

  1. 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";
        }
        // ...
    }
  2. 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;
    }
  3. 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:

  1. 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; }
    }
  2. 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);
    }
  3. Rebuild after changes:

    # MapStruct generates code at compile time
    mvn clean compile
    
    # Check generated classes in target/generated-sources/annotations/
Form Templates
Form Template Structure

The form template begins with a <form> tag. The following required blocks are expected within the form:

form__actions Block

The form__actions block contains form control elements:

Form title (<h4>) - displayed only when the form is opened as a separate page. When the form is displayed in a modal window, the title is automatically hidden Button block - contains form action buttons (save, cancel, etc.) with ml-auto class for alignment

The location of the form__actions block is not important - it can be placed either at the beginning or at the end of the <form> element.
<div class="form__actions">
    <h4>Form Title</h4>
    <div class="ml-auto">
        <!-- Form buttons -->
    </div>
</div>
form-body Block

The form-body block is the main container for form fields and is responsible for the grid layout. The following elements are placed inside this block:

form-group - form field groups

app_toggled - togglable blocks

<h4> - section subheadings

<div class="form-body">
    <h4>Form Section</h4>
    <div class="form-group">
        <!-- Form fields -->
    </div>
    <div class="app_toggled">
        <!-- Togglable content -->
    </div>
</div>
Additional Elements
Headings

The <h4> tag is used for all headings within the form.

Tables and Other Blocks

All additional blocks (tables, information blocks) are inserted directly into the <form> element, outside the form-body block. IMPORTANT: Tables must be wrapped in a <div>:

<form>
    <div class="form__actions">
        <!-- ... -->
    </div>
    <div class="form-body">
        <!-- ... -->
    </div>

    <div>
        <table>
        <!-- Table content -->
        </table>
    </div>
</form>
Two-Column Layout for Modal Forms

The form-2-column class is used to add a two-column layout to forms in modal windows. This class is added to the form-body block.

Layout Structure

The two-column layout uses CSS Grid with the following characteristics:

Grid columns: Two equal columns (1fr 1fr) Column gap: 60px spacing between columns Width: Full width of the container (100%)

Element Placement Rules

form-group elements: Automatically placed in columns (alternating left/right) app_toggled elements: Also placed in columns like form-group elements All other elements: Span both columns (full width) using grid-column: 1 / -1

Special Container: form__container

The form__container class can be used within form-2-column to create a nested two-column grid:

Spans both columns of the parent grid Creates its own two-column grid with the same 60px gap Useful for grouping related fields that need their own column layout

<div class="form-body form-2-column">
    <h4>Section Title</h4> <!-- Spans both columns -->
    <div class="form-group">
        <!-- Placed in left column -->
    </div>

    <div class="form-group">
        <!-- Placed in right column -->
    </div>

    <div class="app_toggled">
        <!-- Placed in next available column (not spanning full width) -->
    </div>

    <div class="form__container">
        <!-- Nested two-column grid -->
        <div class="form-group"><!-- Left column --></div>
        <div class="form-group"><!-- Right column --></div>
    </div>
</div>
CSS Implementation
.form-2-column {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-column-gap: 60px;
    width: 100%;
    *:not(.form-group):not(.app-toggled) {
        grid-column: 1 / -1;
    }

    .form__container {
        grid-column: 1 / -1;
        display: grid;
        grid-template-columns: 1fr 1fr;
        grid-column-gap: 60px;
    }
}
Both form-group and app_toggled elements are automatically distributed across columns. All other elements (headings, tables, containers) will span the full width of both columns.
Complete Structure Example
<form>
    <!-- Action block (can be at the beginning or end) -->
    <div class="form__actions">
        <h4>Form Name</h4>
        <div class="ml-auto">
            <button type="submit">Save</button>
            <button type="button">Cancel</button>
        </div>
    </div>
    <!-- Main form body (standard layout) -->
    <div class="form-body">
        <h4>Basic Information</h4>
        <div class="form-group">
            <label>Field 1</label>
            <input type="text" />
        </div>

        <div class="app_toggled">
            <div class="form-group">
                <label>Field 2</label>
                <input type="text" />
            </div>
        </div>
    </div>

    <!-- Main form body (two-column layout for modals) -->
    <div class="form-body form-2-column">
    <h4>Modal Form Section</h4> <!-- Spans both columns -->

        <div class="form-group">
            <label>Field 1</label>
            <input type="text" />
            <!-- Left column -->
        </div>

        <div class="form-group">
            <label>Field 2</label>
            <input type="text" />
            <!-- Right column -->
        </div>

        <div class="app_toggled">
            <div class="form-group">
                <label>Field 3</label>
                <input type="text" />
            </div>
        </div>

        <div class="form__container">
            <!-- Nested two-column grid spanning full width -->
            <div class="form-group">
                <label>Nested Field 1</label>
                <input type="text" />
            </div>
            <div class="form-group">
                <label>Nested Field 2</label>
                <input type="text" />
            </div>
        </div>
    </div>

    <!-- Additional elements -->
    <div>
        <table>
            <thead>
                <tr>
                <th>Column 1</th>
                <th>Column 2</th>
                </tr>
            </thead>
            <tbody>
            <!-- Table data -->
            </tbody>
        </table>
    </div>
</form>

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.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)}
Table 2. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the input field (e.g., #{client.individualInfo.fullName})

fieldname

String

Field path for data binding (e.g., 'individualInfo.fullName')

inputclass

String

CSS validation classes (e.g., 'v-required v-armenian-name')

Additional Variables
  • maxlength - Maximum character limit (default: 256)

  • minlength - Minimum character limit (default: 0)

  • placeholder - Placeholder text for the input field

Usage Example
<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)}
Table 3. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the select field

fieldname

String

Field path for data binding

values

Collection/Map

Options data (Map for key-value pairs, Collection for simple lists)

Usage Example
<th:block th:insert="~{/form/components :: select(
    #{client.address.stateOfResidence},
    'individualInfo.residenceCountry',
    ${countries})}" />
Date Picker Component
~{/form/components :: date(name, fieldname, inputclass)}
Table 4. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the date field

fieldname

String

Field path for data binding

inputclass

String

CSS validation classes (optional)

Additional Variables
  • maxDate - Maximum selectable date

  • minDate - Minimum selectable date

  • startDate - Default selected date

Usage Example
<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)}
Table 5. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the amount field

fieldname

String

Field path for data binding

inputclass

String

CSS validation classes (e.g., 'v-required v-positive')

Additional Variables
  • inputAmountPrefix - Prefix for field IDs (optional)

  • currencies - Available currency options

Usage Example
<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)}
Table 6. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the checkbox

fieldname

String

Field path for data binding

inputclass

String

CSS classes for styling/validation

Usage Example
<th:block th:insert="~{/form/components :: checkbox(
    #{client.agreeToTerms},
    'agreeToTerms',
    'v-required')}" />
Textarea Component
~{/form/components :: textarea(name, fieldname, inputclass)}
Table 7. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the textarea

fieldname

String

Field path for data binding

inputclass

String

CSS validation classes

Additional Variables
  • rows - Number of textarea rows (default: 5)

  • maxlength - Maximum character limit

Usage Example
<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)}
Table 8. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the radio group

fieldname

String

Field path for data binding

params

Map

Key-value pairs for radio options

Usage Example
<th:block th:insert="~{/form/components :: radio(
    #{client.gender},
    'gender',
    ${genderOptions})}" />
File Upload Component
~{/form/components :: fileInput(name, filelabel, fieldname, inputclass)}
Table 9. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the file input

filelabel

String

Button text for file selection

fieldname

String

Field path for data binding

inputclass

String

CSS classes for styling/validation

Usage Example
<th:block th:insert="~{/form/components :: fileInput(
    #{document.upload},
    #{button.chooseFile},
    'documentFile',
    'v-required')}" />
Read-only Component
~{/form/components :: readonly(name, fieldname, inputclass)}
Table 10. Parameters
Parameter Type Description

name

String (i18n key)

Label text for the read-only field

fieldname

String

Field path for data binding

inputclass

String

CSS classes for styling

Usage Example
<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
Table 11. Built-in Validation Rules
CSS Class Description Usage Example

v-required

Field is mandatory and cannot be empty

'individualInfo.fullName', 'v-required v-name'

v-number

Field must contain a valid number

'amount', 'v-number'

v-digits

Field must contain only digits (0-9)

'quantity', 'v-digits'

v-email

Field must contain a valid email address

'contactInfo.email', 'v-required v-email'

v-url

Field must contain a valid URL

'website', 'v-url'

v-phone

Field must contain a valid phone number

'contactInfo.phone', 'v-required v-phone'

v-positive

Field must contain a positive number (> 0)

'totalAnnualIncome', 'v-required v-positive'

v-name

Field must contain valid name characters (letters, spaces, hyphens, apostrophes), max 256 characters

'individualInfo.fullName', 'v-required v-name'

Custom Validation Methods

The platform extends jQuery Validation with custom validation methods:

Custom Validators
// 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:

Class Rules Configuration
$.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:

Template Usage Examples
<!-- 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. Fast Search System

Add search functionality to your entities with minimal configuration.

8.1. What You Need to Know

Fast Search lets users search across all your application entities. It’s already enabled and integrated into the UI - you just need to configure which entities are searchable.

8.2. Configuration

Properties

Configure Fast Search in application.properties:

## Quick Search (uses Lucene backend by default)
hibernate.search.enabled=true
hibernate.search.backend.directory.root=/index
Using Elasticsearch

To use Elasticsearch instead of the default Lucene backend:

## Quick Search with Elasticsearch
hibernate.search.enabled=true
hibernate.search.backend.type=elasticsearch
hibernate.search.backend.hosts=localhost:9200
hibernate.search.backend.protocol=http

8.3. Quick Setup

1. Make Entity Searchable

Add @Indexed to your entity and mark searchable fields:

@Entity
@Indexed
public class Client extends AbstractAuditable<UUID> {

    @FullTextField  // Natural language search
    private String fullName;

    @KeywordField   // Exact matches only
    private String nationalId;

    @IndexedEmbedded  // Include nested fields
    private ContactInfo contactInfo;
}
2. Configure Search Entity

Create a configuration class:

@Configuration
@ConditionalOnProperty("hibernate.search.enabled")
public class ClientSearchConfig {

    @Bean
    FastSearchEntity<Client> clientSearchEntity() {
        return new FastSearchEntity<>(Client.class, Client::getDisplayedName);
    }
}

That’s it! Search is now available in the application UI at /search.

8.4. Field Types

@FullTextField - For names, descriptions, natural language text @KeywordField - For IDs, codes, statuses, exact matches @IndexedEmbedded - Include fields from nested objects

8.5. FastSearchEntity Configuration

The FastSearchEntity bean controls how your entity appears in search results.

Basic Configuration
@Bean
FastSearchEntity<Client> clientSearchEntity() {
    return new FastSearchEntity<>(Client.class, Client::getDisplayedName);
}

Parameters:

  • Client.class - The entity class to make searchable

  • Client::getDisplayedName - Function that returns the display name for search results

Custom Display Names

Control how search results are displayed:

@Bean
FastSearchEntity<Client> clientSearchEntity() {
    return new FastSearchEntity<>(Client.class, client -> {
        String name = client.getIndividualInfo().getFullName();
        String id = client.getIndividualInfo().getNationalId();
        return name + " (" + id + ")";
    });
}
Layout Templates

Specify custom result templates:

@Bean
FastSearchEntity<Client> clientSearchEntity() {
    FastSearchEntity<Client> entity = new FastSearchEntity<>(Client.class, Client::getDisplayedName);
    entity.setLayoutTemplateName("/client/search :: client-result");
    return entity;
}

This uses /client/search :: client-result template for displaying this entity in search results. Default template is /form/search :: default.

Multiple Entities

Configure multiple entities in one config class:

@Configuration
@ConditionalOnProperty("hibernate.search.enabled")
public class SearchConfig {

    @Bean
    FastSearchEntity<Client> clientSearchEntity() {
        return new FastSearchEntity<>(Client.class, Client::getDisplayedName);
    }

    @Bean
    FastSearchEntity<Application> applicationSearchEntity() {
        return new FastSearchEntity<>(Application.class, app ->
            "Application #" + app.getId());
    }
}

8.6. Entity Labels

Add entity type labels to messages.properties for proper display:

entity.client=Client
entity.application=Application
entity.participant=Participant

8.7. Index Management

Search indexes are created automatically when the application starts.

Manual Index Refresh

Use the Update Indexes button on the /control page to manually refresh search indexes.

Index Recreation

If indexes are dropped or corrupted, they will be automatically recreated when the application starts.

8.8. Search Examples

Users can search with:

john smith             # Find both terms
"john smith"           # Exact phrase
fullName:john          # Search specific field
john*                  # Wildcard search
john AND smith         # Boolean operators

8.9. Common Issues

No search results?

  1. Check @Indexed annotation exists

  2. Verify FastSearchEntity bean is configured

  3. Ensure searchable fields have @FullTextField or @KeywordField

Slow search?

  1. Use @KeywordField for exact matches

  2. Only index fields users actually search


9. Entity Counters and Launchpad Integration

Create dashboard counters that display pending decisions and workflow statistics, providing seamless integration with the Launchpad decision management system.

9.1. What You’ll Learn

After reading this chapter, you’ll know how to:

  • Create entity counters that integrate with Launchpad pending decisions

  • Track workflow states and decision progress through counters

  • Implement counters for entities with pending decision workflows

  • Navigate from dashboard counters directly to Launchpad decision interfaces

  • Configure counter-to-Launchpad routing for efficient workflow management

  • Test and troubleshoot counter-Launchpad integrations

9.2. The Big Picture

Entity counters serve as workflow command centers that bridge dashboard visibility with Launchpad decision-making:

  • Decision Visibility: Show counts of entities with pending decisions

  • Workflow Progress: Track entities at different workflow stages

  • Quick Access: Click counters to navigate directly to Launchpad decision interfaces

  • Priority Management: Surface entities requiring immediate attention

  • Business Intelligence: Monitor decision throughput and bottlenecks

Think of counters as workflow entry points - they identify entities needing decisions and provide direct access to the Launchpad where those decisions are made.

Counters + Launchpad Workflow

The integration works as follows:

  1. Counter Detection: Counter identifies entities with pending decisions or specific workflow states

  2. Dashboard Display: Shows count and entity summary information

  3. Launchpad Navigation: Clicking entities opens them in Launchpad for decision-making

  4. Decision Processing: Users make decisions through Launchpad interface

  5. Counter Updates: Counters automatically reflect workflow progress

9.3. Core Concepts

EntityCounter Interface

The EntityCounter interface defines how to identify and count entities:

public interface EntityCounter<T> {
    boolean isEntityMarked(T entity);
    String getName();
}

Key Methods:

  • isEntityMarked(T entity): Returns true if entity should be counted

  • getName(): Returns counter identifier used for templates and internationalization

Counter Architecture

Counters work by: 1. Entity Evaluation: Check each entity against counter criteria 2. Count Aggregation: Platform counts entities where isEntityMarked() returns true 3. Dashboard Display: Show count and provide link to filtered entity list 4. Navigation: Users click to see entities that match the counter

9.4. Implementation Examples

Simple Status-Based Counter

The ApplicationManualReviewCounter counts applications requiring manual review:

package com.timvero.example.admin.application.counter;

import com.timvero.example.admin.application.entity.Application;
import com.timvero.example.admin.application.entity.ApplicationStatus;
import com.timvero.ground.entity_marker.counter.EntityCounter;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(2000)
public class ApplicationManualReviewCounter implements EntityCounter<Application> {

    @Override
    public boolean isEntityMarked(Application entity) {
        return entity.getStatus() == ApplicationStatus.MANUAL_REVIEW;
    }

    @Override
    public String getName() {
        return "applicationManualReview";
    }
}

Key Implementation Details:

  1. @Component: Registers counter with Spring container

  2. @Order(2000): Controls display order on dashboard (lower numbers appear first)

  3. Status Check: Simple enum comparison for entity filtering

  4. Counter Name: "applicationManualReview" used for templates and i18n

Label-Based Counter

The CreditActiveAndPaidCounter leverages entity labels for complex business logic:

package com.timvero.example.admin.credit.counter;

import com.timvero.example.admin.credit.entity.ExampleCredit;
import com.timvero.example.admin.credit.label.CreditPaidLabel;
import com.timvero.ground.entity_marker.counter.EntityCounter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(3000)
public class CreditActiveAndPaidCounter implements EntityCounter<ExampleCredit> {

    @Autowired
    private CreditPaidLabel creditPaidLabel;

    @Override
    public boolean isEntityMarked(ExampleCredit entity) {
        return creditPaidLabel.isEntityMarked(entity);
    }

    @Override
    public String getName() {
        return "creditActiveAndPaid";
    }
}

This pattern demonstrates:

  1. Label Reuse: Leverages existing CreditPaidLabel logic

  2. Dependency Injection: Uses @Autowired to access label logic

  3. Business Logic Delegation: Keeps counter simple by delegating to specialized label

The Supporting Label

The CreditPaidLabel contains the actual business logic:

package com.timvero.example.admin.credit.label;


import static com.timvero.example.admin.credit.CreditCalculationConfiguration.ACTIVE;

import com.timvero.example.admin.credit.entity.ExampleCredit;
import com.timvero.ground.entity_marker.label.EntityLabel;
import java.util.Optional;
import javax.money.MonetaryAmount;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(2000)
public class CreditPaidLabel implements EntityLabel<ExampleCredit> {

    @Override
    public boolean isEntityMarked(ExampleCredit entity) {
        if (entity.getActualSnapshot() == null) {
            return false;
        }
        Optional<MonetaryAmount> totalDebt = entity.getActualSnapshot().getDebt().getTotal();
        return entity.getActualSnapshot().getStatus().equals(ACTIVE) && totalDebt.isEmpty();
    }

    @Override
    public String getName() {
        return "paid";
    }
}

Complex Business Logic:

  1. Null Safety: Checks for actualSnapshot existence

  2. Status Validation: Verifies credit is in ACTIVE status

  3. Debt Calculation: Confirms total debt is empty (paid off)

  4. Monetary Handling: Works with MonetaryAmount for financial calculations

9.5. Configuration and Setup

Counter Registration

Counters are automatically discovered by Spring component scanning:

@Component
@Order(1000)  // Lower numbers = higher priority (appear first)
public class MyEntityCounter implements EntityCounter<MyEntity> {

    @Override
    public boolean isEntityMarked(MyEntity entity) {
        // Your counting logic here
        return entity.getStatus() == MyEntityStatus.NEEDS_ATTENTION;
    }

    @Override
    public String getName() {
        return "myEntityCounter";  // Used for templates and i18n
    }
}
Internationalization Setup

Add counter labels to your messages file:

File: src/main/resources/messages_en.properties

counter.applicationManualReview=Applications for Manual Review
counter.creditActiveAndPaid=Active and Paid Loans
counter.myEntityCounter=My Custom Counter

Pattern: counter.{counterName}={Display Label}

Display Order Configuration

Use @Order annotation to control counter sequence on dashboard:

@Order(1000)  // Appears first
public class HighPriorityCounter implements EntityCounter<Entity> { ... }

@Order(2000)  // Appears second
public class MediumPriorityCounter implements EntityCounter<Entity> { ... }

@Order(3000)  // Appears third
public class LowPriorityCounter implements EntityCounter<Entity> { ... }

Best Practice: Use increments of 1000 to allow for future insertions.

9.6. Dashboard Integration

Counter Templates

Entity counters use shared templates for consistent display:

Dashboard Counter Template (templates/dashboard/counter/application.html):

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="entity(entities)">
    <ul class="counter-list">
        <li
            class="counter-list__item"
            th:each="entity,iterStat : ${entities}"
        >
            <button class="clickable counter-list__btn" th:href="@{${url} + '/' + ${entity.id}}" th:object="${entity}">
                <p class="d-flex justify-content-between w-100">
                    <span class="text-left text-prompt w-75" th:text="*{displayedName}"></span>
                    <span th:text="*{#ldates.format(createdAt, 'MM/dd/yyyy')}"></span>
                </p>
            </button>
        </li>
    </ul>
</th:block>
</html>

Template Features:

  1. Entity List: Displays entities matching the counter criteria

  2. Clickable Items: Each entity links to detailed view

  3. Date Display: Shows creation date for context

  4. Consistent Styling: Uses platform CSS classes for unified appearance

Dashboard Layout Integration

The main dashboard template integrates all counters:

Dashboard View (templates/dashboard/view.html):

<div class="counters-wrapper">
    <div class="counter__item" th:each="entity,iterStat : ${counterList}" th:object="${entity}">
        <th:block th:insert="~{:: counterCard(${entity})}"/>
        <section
            th:data-for-counter="${entity}"
            th:data-counter-url="'/counter/' + ${entity} + '?count=10'"
            class="mt-5">
        </section>
        <button class="counter__item-btn js-show-full-counter-list" th:text="#{common.viewAll}"></button>
    </div>
</div>

<th:block th:fragment="counterCard(counterName)">
    <div class="counterCard">
        <div class="counterCard-title">
            <h4 th:text="#{'counter.' + ${counterName}}"></h4>
            <div class="d-flex flex-column align-items-end">
                <span class="d-flex counter">
                    <span
                        th:data-name="${counterName}"
                        th:data-title="#{'counter.' + ${counterName}}">
                    </span>
                </span>
            </div>
        </div>
    </div>
</th:block>

Dashboard Integration Points:

  1. Counter Loop: Iterates through all registered counters

  2. Dynamic URLs: Builds counter-specific endpoints

  3. Internationalization: Uses counter names for i18n lookup

  4. JavaScript Integration: Provides hooks for dynamic counter updates

9.7. Performance Considerations

Efficient Counting Logic

Avoid Heavy Operations: Keep isEntityMarked() logic lightweight

// Good: Simple property checks
public boolean isEntityMarked(Application app) {
    return app.getStatus() == ApplicationStatus.MANUAL_REVIEW;
}

// Avoid: Complex database queries in counter logic
public boolean isEntityMarked(Application app) {
    // Don't do this - too expensive for counting
    List<Document> docs = documentRepository.findByApplicationId(app.getId());
    return docs.stream().anyMatch(doc -> doc.getStatus() == DocumentStatus.MISSING);
}

Use Entity Labels: Delegate complex logic to specialized label classes

@Component
public class MyCounter implements EntityCounter<MyEntity> {

    @Autowired
    private MyComplexLabel complexLabel;  // Reuse existing logic

    @Override
    public boolean isEntityMarked(MyEntity entity) {
        return complexLabel.isEntityMarked(entity);  // Delegate
    }
}
Database Query Optimization

Entity Fetching: Ensure counter queries are optimized

// If your counter needs related entities, optimize the query
@Query("SELECT e FROM MyEntity e " +
       "JOIN FETCH e.relatedEntity " +
       "WHERE e.status = :status")
List<MyEntity> findEntitiesForCounter(@Param("status") EntityStatus status);

Pagination: Platform handles pagination automatically for counter views

9.8. Best Practices

Counter Design Guidelines

Single Responsibility: Each counter should track one specific business condition

// Good: Focused on one criteria
public class ApplicationManualReviewCounter implements EntityCounter<Application> {
    public boolean isEntityMarked(Application app) {
        return app.getStatus() == ApplicationStatus.MANUAL_REVIEW;
    }
}

// Avoid: Multiple unrelated criteria
public class MixedCriteriaCounter implements EntityCounter<Application> {
    public boolean isEntityMarked(Application app) {
        return app.getStatus() == ApplicationStatus.MANUAL_REVIEW ||
               app.getAmount().isGreaterThan(THRESHOLD) ||
               app.getCreatedAt().isBefore(CUTOFF_DATE);  // Too many different concerns
    }
}

Meaningful Names: Use descriptive counter names for templates and i18n

// Good: Clear business meaning
public String getName() {
    return "applicationManualReview";  // Clear what this counts
}

// Avoid: Generic or unclear names
public String getName() {
    return "counter1";  // What does this count?
}

Consistent Ordering: Group related counters with logical order values

// Applications
@Order(1000) public class ApplicationSubmittedCounter { ... }
@Order(1100) public class ApplicationManualReviewCounter { ... }
@Order(1200) public class ApplicationApprovedCounter { ... }

// Credits
@Order(2000) public class CreditActiveCounter { ... }
@Order(2100) public class CreditOverdueCounter { ... }
@Order(2200) public class CreditPaidCounter { ... }
Internationalization Best Practices

Descriptive Labels: Make counter purposes clear to users

# Good: Clear business context
counter.applicationManualReview=Applications Requiring Review
counter.overduePayments=Overdue Payments
counter.incompleteDocumentation=Missing Documentation

# Avoid: Technical or vague labels
counter.status1=Status 1 Items
counter.items=Items
counter.counter=Counter

9.9. Troubleshooting

Common Issues

Counter Not Appearing on Dashboard: Counter component not registered

Solution: Ensure counter class is annotated with @Component and is in a
package scanned by Spring component scanning.

Counter Shows Zero Count: Logic never returns true

Solution: Debug the isEntityMarked() method with test data. Check that your
criteria match actual entity states in the database.

Counter Display Label Missing: No internationalization entry

Solution: Add counter.{counterName}=Display Label to messages_en.properties
file. Ensure the counter name matches exactly.

Counter Order Wrong: @Order values not set correctly

Solution: Use @Order annotation with appropriate numeric values. Lower
numbers appear first on the dashboard.

Performance Issues: Counter logic too expensive

Solution: Profile the isEntityMarked() method. Move complex logic to
entity labels or optimize database queries used by the counter.

Counter Updates Delayed: Changes don’t reflect immediately

Solution: Ensure workflow completion events properly trigger entity
updates. Consider adding @Transactional annotations with proper
propagation to avoid race conditions.

Entity counters provide powerful workflow command centers that seamlessly integrate dashboard visibility with Launchpad decision-making. They enable efficient workflow management by surfacing entities requiring attention and providing direct access to decision processing interfaces.

10. Launchpad Integration

Integrate your entities with the Launchpad decision-making system to provide users with unified workflow management and pending decision handling.

10.1. What You’ll Learn

After reading this chapter, you’ll know how to:

  • Integrate entities with the Launchpad system for decision management

  • Create custom decision entity extractors for your business entities

  • Configure Launchpad templates for entity-specific workflow display

  • Handle pending decision workflows through Launchpad interface

  • Provide contextual information for decision-making processes

10.2. The Big Picture

The Launchpad system provides a centralized interface where users can:

  • View and manage pending decisions across different entity types

  • Access entity-specific context and details for informed decision-making

  • Execute workflow actions directly from a unified dashboard

  • Track decision progress and status for multiple business entities

Think of Launchpad as a decision workbench - it aggregates all pending decisions from your application entities and presents them in a consistent, actionable interface.

10.3. Core Components

DecisionEntityExtractor

The DecisionEntityExtractor interface connects your entities to the Launchpad system. It provides the bridge between pending decisions and entity context.

Interface Definition
public interface DecisionEntityExtractor {
    String getDecisionOwnerType();
    LaunchpadEntityDto getByHolderId(Long holderId);
}

Key Methods:

  • getDecisionOwnerType(): Returns the entity type identifier for routing decisions

  • getByHolderId(Long holderId): Retrieves entity context by pending decision holder ID

LaunchpadEntityDto

The LaunchpadEntityDto carries all necessary information for displaying entity context in Launchpad:

public class LaunchpadEntityDto {
    private Long targetEntityId;      // Primary entity ID
    private Object parentEntity;      // Parent/container entity
    private String parentDisplayName; // Parent entity display name
    private Object targetEntity;      // Target entity with pending decisions
    private String targetDisplayName; // Target entity display name
    private String roleDescription;   // Context description (roles, status, etc.)
    private String templatePath;      // Path to Launchpad template fragment
}

10.4. Implementation Example: Participant Launchpad Service

The ParticipantLaunchpadService demonstrates how to integrate participant entities with Launchpad:

Service Implementation
@Component
public class ParticipantLaunchpadService implements DecisionEntityExtractor {

    @Autowired
    private ParticipantRepository repository;

    @Override
    public String getDecisionOwnerType() {
        return Participant.DECISION_OWNER_TYPE;
    }

    @Override
    public LaunchpadEntityDto getByHolderId(Long holderId) {
        Participant participant = repository.findByPendingDecisionHolderId(holderId)
            .orElseThrow(() -> new RuntimeException("No target entity with holder id " + holderId));
        Application application = participant.getApplication();

        String roles = participant.getRoles().stream()
            .map(role -> EnumUtils.getLocalizedValue(role, LocaleContextHolder.getLocale()))
            .collect(Collectors.joining(", "));

        return new LaunchpadEntityDto(participant.getId(), application, application.getDisplayedName(),
            participant, participant.getDisplayedName(), roles,
            "/participant/launchpad-body");
    }
}

Key Implementation Details:

  1. Decision Owner Type: Returns Participant.DECISION_OWNER_TYPE to identify this extractor

  2. Entity Retrieval: Finds participant by pending decision holder ID

  3. Context Building: Constructs display information including roles and parent application

  4. Template Path: Specifies the template fragment path for rendering participant context

Entity Requirements

For entities to work with Launchpad, they must implement HasPendingDecisions:

@Entity
public class Participant extends AbstractAuditable<UUID> implements HasPendingDecisions {

    public static final String DECISION_OWNER_TYPE = "PARTICIPANT";

    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(nullable = false, updatable = false)
    private PendingDecisionHolder pendingDecisionHolder =
        new PendingDecisionHolder(DECISION_OWNER_TYPE);

    @Override
    public PendingDecisionHolder getPendingDecisionHolder() {
        return pendingDecisionHolder;
    }
}

10.5. Template Integration

Launchpad Template Fragment

Create a template fragment for your entity’s Launchpad display:

File: src/main/resources/templates/participant/launchpad-body.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="details" th:object="${entityDto.targetEntity}">
    <th:block th:insert="~{/launchpad/fragment/pending-decisions :: body(${entityDto})}"/>

    <div class="mt-20 border-top">
        <h4 class="mb-5 mt-5" th:text="#{participant.tab.details}"></h4>
        <article
            class="article  pl-10 pr-10"
            th:data-url="@{${@pathRegistry.getPath(entityDto.targetEntity) + '/tab/details'}}"
        ></article>
    </div>

    <div class="mt-20 border-top">
        <h4 class="mb-5 mt-5" th:text="#{participant.tab.profile}"></h4>
        <article
            class="article"
            th:data-url="@{${@pathRegistry.getPath(entityDto.targetEntity) + '/tab/profile'}}"
        ></article>
    </div>

    <div class="mt-20 border-top">
        <h4 class="mb-5 mt-5" th:text="#{participant.tab.documents}"></h4>
        <article
            class="article"
            th:data-url="@{${@pathRegistry.getPath(entityDto.targetEntity) + '/tab/documents'}}"
        ></article>
    </div>
</th:block>
</html>

Template Structure:

  1. Pending Decisions Section: Shows workflow status and available actions

  2. Entity Details Tabs: Provides contextual information for decision-making

  3. Dynamic Content Loading: Uses AJAX to load tab content dynamically

Template Features

Pending Decisions Fragment:

<th:block th:insert="~{/launchpad/fragment/pending-decisions :: body(${entityDto})}"/>

This inserts the standard pending decisions interface with entity-specific context.

Tabbed Content:

<article th:data-url="@{${@pathRegistry.getPath(entityDto.targetEntity) + '/tab/details'}}">

Dynamically loads tab content using the entity’s registered path.

10.6. Configuration

Register Your Extractor

Register your DecisionEntityExtractor as a Spring component:

@Component
public class ParticipantLaunchpadService implements DecisionEntityExtractor {
    // Implementation...
}

The Launchpad system automatically discovers all DecisionEntityExtractor implementations and routes decisions accordingly.

10.7. Advanced Features

Custom Display Logic

Implement sophisticated display logic in your extractor:

@Override
    public LaunchpadEntityDto getByHolderId(Long holderId) {
        Participant participant = repository.findByPendingDecisionHolderId(holderId)
            .orElseThrow(() -> new RuntimeException("No target entity with holder id " + holderId));
        Application application = participant.getApplication();

        String roles = participant.getRoles().stream()
            .map(role -> EnumUtils.getLocalizedValue(role, LocaleContextHolder.getLocale()))
            .collect(Collectors.joining(", "));

        return new LaunchpadEntityDto(participant.getId(), application, application.getDisplayedName(),
            participant, participant.getDisplayedName(), roles,
            "/participant/launchpad-body");
    }

private String buildContextualInfo(Participant participant) {
    // Custom logic to build additional context
    return participant.getStatus() + " | " +
           participant.getPendingDocumentsCount() + " docs pending";
}
Multiple Entity Type Support

Create extractors for different entity types:

@Component
public class ApplicationLaunchpadService implements DecisionEntityExtractor {
    @Override
    public String getDecisionOwnerType() {
        return Application.DECISION_OWNER_TYPE;
    }

    @Override
    public LaunchpadEntityDto getByHolderId(Long holderId) {
        // Implementation for application entities
    }
}

@Component
public class CreditLaunchpadService implements DecisionEntityExtractor {
    @Override
    public String getDecisionOwnerType() {
        return Credit.DECISION_OWNER_TYPE;
    }

    @Override
    public LaunchpadEntityDto getByHolderId(Long holderId) {
        // Implementation for credit entities
    }
}

10.8. Best Practices

Entity Context Guidelines

Provide Rich Context: Include relevant parent/child relationships in the DTO

// Good: Include parent application for participant context
return new LaunchpadEntityDto(
    participant.getId(),
    application,              // Parent context
    application.getDisplayedName(),
    participant,              // Target entity
    participant.getDisplayedName(),
    roles,
    templatePath
);

Meaningful Display Names: Use business-friendly names, not technical IDs

public String getDisplayedName() {
    return String.format("%s (%s)", fullName, roles.iterator().next());
}

Localized Content: Support internationalization for role descriptions

String roles = participant.getRoles().stream()
    .map(role -> EnumUtils.getLocalizedValue(role, LocaleContextHolder.getLocale()))
    .collect(Collectors.joining(", "));
Performance Considerations

Efficient Queries: Use appropriate fetch strategies to avoid N+1 problems

@Query("SELECT p FROM Participant p " +
       "JOIN FETCH p.application " +
       "JOIN FETCH p.roles " +
       "WHERE p.pendingDecisionHolder.id = :holderId")
Optional<Participant> findByPendingDecisionHolderId(@Param("holderId") Long holderId);

Caching: Consider caching frequently accessed context data

@Cacheable("participant-context")
public LaunchpadEntityDto getByHolderId(Long holderId) {
    // Implementation...
}

10.9. Troubleshooting

Common Issues

ExtractorNotFoundException: No extractor found for decision owner type

Solution: Ensure your DecisionEntityExtractor is registered as @Component
and getDecisionOwnerType() returns the correct entity type constant.

Template Not Found: Launchpad template fragment missing

Solution: Verify template path in LaunchpadEntityDto matches actual file location.
Template must be in src/main/resources/templates/ directory.

Entity Not Found: PendingDecisionHolder ID doesn’t match entity

Solution: Check that findByPendingDecisionHolderId query is correct and
entity properly implements HasPendingDecisions interface.

The Launchpad system provides a powerful way to centralize decision management across different entity types, giving users a unified interface for workflow operations while maintaining entity-specific context and functionality.

11. Document Management

This section describes how to implement document management functionality for entities in the platform, including document type associations and UI integration.

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

11.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 = 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

Optional Document Configuration

Documents that can be uploaded without restrictions:

            .predicate(PARTICIPANT_GUARANTOR.or(PARTICIPANT_BORROWER)).build();
    }

    @Bean

This allows OTHER document type to be uploaded for any participant without conditions.

Document Type Definitions

Document types are defined as constants in the configuration:

    public static final SignableDocumentType APPLICATION_CONTRACT =
        new SignableDocumentType("APPLICATION_CONTRACT", ApplicationContractDocumentCategory.TYPE);

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

Entity Requirements

Entities must implement the HasDocuments interface:

public interface HasDocuments extends Persistable<UUID> {
    // No additional methods required
}

11.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 = EntityDocumentType.OTHER;
    public static final EntityDocumentType ID_SCAN = new EntityDocumentType("ID_SCAN");

Predicates are combined using AND logic - all conditions must be true.

11.5. Complete Implementation Example

To implement document management for an entity:

  1. Entity implements HasDocuments:

@Entity
public class Participant implements HasDocuments {
    // Entity implementation
}
  1. 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();
    }
}
  1. 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.

12. Document Templates

This section covers creating and customizing document templates for contracts, reports, and other generated documents in the platform.

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

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

12.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:

  1. Rich Text Formatting - Bold, italic, fonts, colors, alignment

  2. HTML Support - Full HTML markup for advanced formatting

  3. Variable Insertion - Insert dynamic variables using Pebble syntax

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

12.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 %}

12.5. Template Management

Creating Templates via UI
  1. Navigate to Document Templates in admin interface

  2. Create New Template with:

    • Name and description

    • Document type (from your DocumentCategory)

    • Template type (HTML or TXT)

    • Template content

  3. Test Template with sample entity data

Template Storage

Templates are stored in the document_template table:

  • Content - Template markup

  • Type - HTML or TXT

  • Document Type - Links to DocumentCategory

12.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:

  1. Standard Contract Template:

    • Name: "Standard Loan Contract"

    • Document Type: CONTRACT

    • Content: Basic loan terms without collateral sections

  2. Secured Contract Template:

    • Name: "Secured Loan Contract"

    • Document Type: CONTRACT

    • Content: Includes collateral information using conditional logic

  3. 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 %}

12.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"));
    }
}

12.8. Complete Implementation Example

To implement a new document template:

  1. 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);
    }
}
  1. 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
    }
}
  1. Create HTML Template in admin interface using model properties

  2. Generate Documents using DocumentService.generate()

This provides a complete document template system with rich formatting, dynamic content, and signature integration.

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

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

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

13.3. Creating Notification Templates

Templates are created and managed through the admin interface, similar to document templates.

Accessing Template Management

Navigate to System → Notification Templates in the admin interface.

Creating New Template
  1. 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

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

13.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 %}

13.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));
    }
}
Parameter Types
  • integerField("name") - Days, counts, thresholds

  • decimalField("name") - Amounts, percentages

  • stringField("name") - Categories, codes

  • enumField("name", values) - Predefined choices

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

13.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)
        ));
    }
}
Development Gateway

For testing, use built-in development gateways that log instead of sending:

@Bean
@Profile("dev")
public NotificationGateway devEmailGateway(DocumentStorage documentStorage) {
    return new DevEmailGateway(documentStorage);
}

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

Usage in Admin Interface

These labels appear in:

  • Notification Templates list - Event type dropdown and display

  • Template configuration - EventParameter field labels

  • Template testing - Event selection and parameter forms

13.9. Testing Notifications

Template Testing

Use the admin interface to test templates:

  1. Go to Notification Templates

  2. Select a template

  3. Click "Test Template"

  4. Choose a suitable entity from the dropdown (filtered by isSuitableTestEntity())

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

13.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());
            }
        }
    }
}

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

14. Entity Checkers — setup & usage

This section describes how to set up and manage Entity Checkers for automated business rule validation and state management in the application.

14.1. Checker System Architecture

The platform uses Entity Checkers to implement event-driven business logic that automatically responds to entity changes. Checkers are reactive components that listen to DB changes and execute rules when conditions are met.

What are Entity Checkers?
  • Monitor Entity Changes — detect create/update/delete of entities

  • Apply Business Rules — execute predefined logic on matched conditions

  • Maintain Data Consistency — keep related entities in sync

  • Automate Workflows — trigger next steps without manual actions

Checker Class Hierarchy
  • Base Checker Classes — framework abstract classes (e.g. EntityChecker) provide infrastructure

  • Custom Entity Checkers — app-specific implementations e.g. BorrowerStartTreeChecker — manages borrower workflow start

Each checker has three core parts:

  • Listener Registration (registerListeners) — what changes to monitor

  • Availability Check (isAvailable) — when the checker should run

  • Business Logic (perform) — what to do if available

14.2. Listener Registration

registerListeners defines which changes trigger the checker, configured via CheckerListenerRegistry.

CheckerListenerRegistry

CheckerListenerRegistry<E> configures listeners for entity changes where E is the target entity type (e.g., Application, Participant).

Method Parameters Description

entityChange

Class<T> entityClass — entity being monitored
Function<T,E> mapper — map changed entity to target

Create a listener for changes on a different type. See Using entityChange method.

entityChange

(none)

Monitor the same entity type as the checker (shorthand).

updated

String…​ fields

Filter to field updates only.

inserted

(none)

Filter to insert events only.

and

Predicate<T> predicate

Add custom filter predicates; can be chained.

Using entityChange method
Direct Entity Monitoring

Problem: the checker monitors the same type it operates on. Solution:

registry.entityChange().updated("status");
When to use
  • Trigger entity == target entity

  • Direct field monitoring

  • Simple relationship context

Related Entity Monitoring

Problem: monitor a related entity and map to the target.

registry.entityChange(Participant.class, Participant::getApplication)
        .updated("status");
  • Direct JPA relationships (1:1, N:1)

  • Same transaction context

  • Clear getter mapping

Complex Repository-Based Mapping

Problem: mapping requires repository lookup.

registry.entityChange(SignableDocument.class,
        d -> participantRepository.getReferenceById(d.getOwnerId()))
        .updated("status");

Performance: repository lookups add DB queries — use only if no direct association is available.

14.3. Checker Implementation Examples

BorrowerStartTreeChecker

Manages borrower workflow initiation when participants complete required documentation.

Component Description

Target Entity

Participant

Purpose

Auto-start decision tree when borrower completes requirements

Triggers

Document signatures and required uploads

Business Logic

Set status to IN_PROCESS, start decision tree

Listener 1: Application Form Signature Monitor

Purpose: Track completion of form signatures
Trigger: SignableDocument.statusSIGNED
Target: Map document → 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

Purpose: Detect required document uploads
Trigger: New EntityDocument insertions of required types
Target: Map upload → 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
    @Override
    protected boolean isAvailable(Participant participant) {
        return needSignature(participant) && hasSignature(participant);
    }
Availability conditions
  • Participant is a BORROWER

  • Status is NEW

  • Application form is signed

  • All required documents uploaded

Business Logic
    @Override
    protected void perform(Participant participant) {
        participant.setStatus(ParticipantStatus.IN_PROCESS);
        decisionProcessStarter.start(PARTICIPANT_TREE, participant.getId());
    }
Business operations
  • Status UpdateNEWIN_PROCESS

  • Process Initiation — start automated decision tree

  • Transaction Safety — single transaction

14.4. Best Practices

Design & Registration
  • Listen narrowly — subscribe only to fields/states that matter

  • Prefer direct mapping — use entity associations before repo lookups

  • Add predicates — guard with and(…​) to avoid noisy triggers

  • Idempotent perform() — safe on duplicate events/retries

Availability & Logic
  • Cheap isAvailable() — avoid heavy queries here

  • Fail fast — validate required state early

  • Keep perform() focused — one responsibility; delegate services

Observability
  • Structured logs — entity id, event type, fields, decision

  • Metrics — activations, successes, skips, failures

  • Tracing — link to transaction / workflow ids

14.5. Anti-Patterns

  • {bad} Using broad listeners — catching all updates without field filters

  • {bad} Heavy logic in isAvailable() — long DB scans/joins

  • {bad} Side-effects in availability — mutate state during checks

  • {bad} Repository mapping by default — use only when no direct relation

  • {bad} Non-idempotent perform() — repeated activations break state

  • {bad} Silent failures — no logs/metrics on skip/error

14.6. Production Checklist

  • {todo} Narrow listeners to specific fields/states

  • {todo} Add and(…​) predicates to reduce noise

  • {todo} Make perform() idempotent and transactional

  • {todo} Ensure isAvailable() is read-only and fast

  • {todo} Add logs/metrics/tracing for decisions

  • {todo} Cover repo-mapping paths with tests and caching

  • {todo} Backpressure / retry strategy for downstream workflows

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

15.1. Architecture Overview

The TimveroOS data integration follows a three-layer architecture:

  1. DataSource Layer - Fetches raw data from external APIs

  2. DataSourceManager - Manages caching, loading modes, and data lifecycle

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

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

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

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

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

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

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

15.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.
    }
}
KYC Provider Integration
@Service("kycProvider")
public class KYCDataSource
    implements MappedDataSource<KYCSubject, KYCResult> {

    @Override
    public Content getData(KYCSubject subject) throws Exception {
        // Document verification, sanctions screening, etc.
    }
}
Bank Account Verification
@Service("bankVerification")
public class BankVerificationDataSource
    implements MappedDataSource<BankAccountSubject, AccountVerification> {

    @Override
    public Content getData(BankAccountSubject subject) throws Exception {
        // Verify bank account ownership and status
    }
}

15.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);
}
Integration Testing
@Test
public void testDataUnavailableHandling() {
    GithubDataSourceSubject subject = () -> "nonexistentuser12345";

    assertThatThrownBy(() -> githubDataSource.getData(subject))
        .isInstanceOf(DataUnavailableException.class)
        .hasMessageContaining("User not found");
}

15.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
  • {bad} Don’t use DataSources directly - Bypasses caching and lifecycle management

  • {bad} Don’t bypass Feature Store for business logic - Use Feature Store instead of raw DataSource data

  • {bad} Don’t ignore Optional results - Always check if data is present

  • {bad} Don’t hardcode loading modes - Choose based on business requirements

  • {bad} Don’t expose sensitive data - Never log API keys or personal information

  • {bad} Don’t hardcode URLs - Use configuration properties for API endpoints

  • {bad} Don’t ignore exceptions - Handle IOException and DataUnavailableException

Production Checklist
  • {todo} DataSources use meaningful service names

  • {todo} All external calls have appropriate timeouts

  • {todo} Error handling covers DataUnavailableException and IOException

  • {todo} Loading modes are chosen appropriately for each use case

  • {todo} Sensitive data is never logged

  • {todo} DataSource lifespans balance freshness vs cost

  • {todo} Subject interfaces are properly implemented

  • {todo} Target objects handle all expected data formats

16. Workflow Integration

Connect your entities to automated decision-making workflows for credit scoring, risk assessment, and approval processes.

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

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

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

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

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

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

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

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

17. Product Web Components Configuration

Purpose: Automate the setup of web layer components (controllers, actions, tabs) for credit products and additives through declarative configuration.

The ProductWebConfigurationSupport class provides a declarative approach to registering all necessary Spring beans for credit product management, eliminating boilerplate code and ensuring consistent product UI implementation across your application.

17.1. Problem Statement

Building a complete product management UI typically requires:

  • Controllers for listing and viewing products

  • Actions for CRUD operations (create, edit, copy, activate, deactivate)

  • Additive actions for managing product variations

  • Tabs for product details and additives list

  • Proper generic type resolution for Spring dependency injection

17.2. What You’ll Build

To create a complete product management system, you need to implement:

Required Components (you implement these):

  • Product Entity: Your CreditProduct subclass with business-specific fields

  • Additive Entity: Your CreditProductAdditive subclass for product variations

  • Form Classes: DTOs for product and additive creation/editing

  • Form Services: Services implementing EntityFormService and CreditProductAdditiveFormService

  • MapStruct Mappers: Bidirectional mapping between entities and forms

  • Thymeleaf Templates: UI templates for forms, lists, and tabs

  • Filter Class: BaseCreditProductFilter subclass for list filtering

  • Configuration Class: Single class extending ProductWebConfigurationSupport

Auto-Generated Components (framework provides these):

  • Controller: CreditProductController for list and details pages

  • 12+ Action Beans: CRUD operations (create, edit, copy, activate, deactivate, additive management)

  • Tab Components: Product details and additives tabs

  • Type-Safe Wiring: Proper generic type resolution for dependency injection

17.3. Core Concepts

ProductWebConfigurationSupport

Abstract base class that implements BeanDefinitionRegistryPostProcessor to programmatically register Spring beans for credit product web layer.

Key Features:

  • Automatic Bean Registration: Creates all necessary controllers, actions, and tabs

  • Generic Type Resolution: Ensures proper dependency injection with generic parameters

  • Template Customization: Override methods to specify custom Thymeleaf template paths

  • Extensible Design: Add custom components by overriding initDefinitions()

Product Lifecycle States

Credit products follow a three-state lifecycle:

DRAFT:

  • Product is being configured and not yet ready for use

  • Full edit access - all product fields and properties can be modified

  • Additives can be created, edited, activated, and deactivated

  • Not available for offer generation

  • Can be activated when at least one active additive exists

  • Can be copied to create a new DRAFT product

ACTIVE:

  • Product is live and available for offer generation

  • Product configuration is locked - no editing of existing fields

  • Additives cannot be created, edited, or modified

  • Can be deactivated (moved to INACTIVE state)

  • Can be copied to create a new DRAFT product with all active additives

INACTIVE:

  • Product has been deactivated (soft delete)

  • Not available for offer generation

  • Product remains in database for historical reference

  • Cannot be edited or reactivated through standard actions

  • Can be copied to create a new DRAFT product

Standard Components Registered

When you extend ProductWebConfigurationSupport, the following beans are automatically registered:

Component Type Bean Class Purpose

Controller

CreditProductController

List view and product details display

Product Actions

CreateProductAction

Create new credit product

EditProductAction

Edit existing product

CopyProductAction

Copy product with all additives

DeactivateCreditProductAction

Deactivate product (soft delete)

EnableCreditProductAction

Activate/enable product

AddCreditProductAdditiveAction

Add new additive to product

Additive Actions

EditAdditiveAction

Edit product additive

ActivateAdditiveAction

Activate additive

DeactivateAdditiveAction

Deactivate additive

Tabs

CreditProductDetailsTab

Product details tab view

CreditProductAdditivesTab

Product additives tab view

17.4. Complete Implementation Checklist

Follow these steps to implement all required components for your product management system.

✓ Step 1: Product Entity

Create your CreditProduct subclass with business-specific fields

@Entity
public class ExampleCreditProduct extends CreditProduct {
    private CurrencyUnit currency;
    private BigDecimal minAmount;
    private BigDecimal maxAmount;
    private Integer minTerm;
    // ... other fields (maxTerm, lateFeeRate, etc.)
}
See complete example: ExampleCreditProduct.java
✓ Step 2: Additive Entity

Create your CreditProductAdditive subclass for product variations

@Entity
public class ExampleCreditProductAdditive extends CreditProductAdditive {
    private String name;
    private BigDecimal interestRate;
    private BigDecimal minAmount;
    // ... other fields (maxAmount, minTerm, maxTerm, etc.)

    @Override
    public ExampleProductOffer createOffer() {
        return new ExampleProductOffer();
    }
}
See complete example: ExampleCreditProductAdditive.java
✓ Step 3: Product Form Class

Create form DTO for product creation/editing

public class CreditProductForm extends BaseCreditProductForm {
    private CurrencyUnit currency;
    private BigDecimal minAmount;
    private BigDecimal maxAmount;
    // ... validation annotations and other fields
}
See complete example: CreditProductForm.java
✓ Step 4: Additive Form Class

Create form DTO for additive creation/editing

public class ExampleCreditProductAdditiveForm {
    private String name;
    private BigDecimal interestRate;
    private BigDecimal minAmount;
    // ... validation annotations and other fields
}
✓ Step 5: Filter Class

Create filter class for product list filtering

public class ExampleCreditProductFilter extends BaseCreditProductFilter {
}

Default Filters (from BaseCreditProductFilter ):

  • state: Filter products by state (CreditProductState[]) - supports multiple states (IN restriction)

You can extend with custom filters using annotations:

  • @Field / @Fields - specify which entity fields to search

  • @Restriction - define filter type (EQ, IN, LIKE, GT, LT, etc.)

See complete example: ExampleCreditProductFilter.java
✓ Step 6: MapStruct Mappers

Create MapStruct mappers for entity ↔ form conversion

Product Mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface ExampleCreditProductFormMapper
        extends EntityToFormMapper<ExampleCreditProduct, CreditProductForm> {

    @Override
    void toEntity(CreditProductForm form, @MappingTarget ExampleCreditProduct entity);

    @Override
    @InheritInverseConfiguration(name = "toEntity")
    CreditProductForm toForm(ExampleCreditProduct entity);

    @Override
    @InheritConfiguration
    ExampleCreditProduct createEntity(CreditProductForm form);
}

Additive Mapper:

@Mapper(uses = ReferenceMapper.class)
public interface ExampleCreditProductAdditiveFormMapper
        extends EntityToFormMapper<ExampleCreditProductAdditive, ExampleCreditProductAdditiveForm> {

    @Override
    void toEntity(ExampleCreditProductAdditiveForm form,
                  @MappingTarget ExampleCreditProductAdditive entity);

    @Override
    @InheritInverseConfiguration(name = "toEntity")
    ExampleCreditProductAdditiveForm toForm(ExampleCreditProductAdditive entity);

    // Optional: custom mapping logic
    @AfterMapping
    default void afterToForm(@MappingTarget ExampleCreditProductAdditiveForm form,
                            ExampleCreditProductAdditive additive) {
        form.setAdditiveId(additive.getId());
    }
}
✓ Step 7: Product Form Service

Implement form service for product operations

@Service
public class ExampleCreditProductFormService
        extends EntityFormService<ExampleCreditProduct, CreditProductForm, UUID> {

    @Autowired
    private DocumentTemplateFormService documentTemplateService;

    @Override
    protected void assembleEditModel(ExampleCreditProduct entity,
                                     CreditProductForm form,
                                     Map<String, Object> model) {
        // Add data needed for form rendering (dropdowns, selects, etc.)
        model.put("productEngines", SimpleScheduledEngine.NAME);
        model.put("offerEngineTypes", new ExecutionResultType[]{ExampleDataProcessor.TYPE});
        model.put("creditTypes", CreditType.values(CreditType.class));
        model.put("templates", documentTemplateService.getTemplatesMap(
            ApplicationContractDocumentCategory.TYPE));
    }
}

Key Method: assembleEditModel()

This method populates the model with data needed for form rendering:

  • Dropdown Options: Available engines, credit types, templates

  • Reference Data: Any lookup data that forms need (enums, lists, maps)

  • UI Data: Additional information for conditional rendering

See complete example: ExampleCreditProductFormService.java
✓ Step 8: Additive Form Service

Implement form service for additive operations

@Service
public class ExampleCreditProductAdditiveFormServiceImpl
        extends CreditProductAdditiveFormService<
            ExampleCreditProductAdditive,
            ExampleCreditProductAdditiveForm> {

    private final OfferEngineDescriptorRepository offerEngineDescriptorRepository;

    @Override
    public Collection<?> findAdditiveModelsByProduct(CreditProduct product) {
        return product.getAdditives();
    }

    @Override
    protected void assembleEditModel(@Nullable ExampleCreditProductAdditive entity,
                                     ExampleCreditProductAdditiveForm form,
                                     Map<String, Object> model) {
        // Add data for additive form rendering
        model.put("procuringTypes", ProcuringType.values());
    }

    @Override
    public void assembleProductData(Model model, CreditProduct product) {
        // Prepare data for additives tab
        ExampleCreditProduct exampleProduct = (ExampleCreditProduct) product;
        Set<ExecutionResultType> requiredEngineTypes = exampleProduct.getOfferEngineTypes();

        model.addAttribute("productId", product.getId());
        model.addAttribute("offerEngineTypes", requiredEngineTypes);
        model.addAttribute("offerEngineDescriptors",
            Lazy.of(() -> getEngineDescriptorMap(requiredEngineTypes)));
    }
}

Key Methods:

  • assembleEditModel(): Populates model with data for additive edit form (procuring types dropdown)

  • assembleProductData(): Provides product-specific data for additives tab (engines, descriptors)

  • findAdditiveModelsByProduct(): Retrieves all additives for a product

✓ Step 9: Configuration Class

Create configuration class - this triggers automatic bean registration

@Configuration
@ConditionalOnWebApplication
public class ProductWebConfiguration extends ProductWebConfigurationSupport {

    public ProductWebConfiguration() {
        super(ExampleCreditProduct.class,
              CreditProductForm.class,
              CreditProductFilter.class,
              ExampleCreditProductAdditive.class,
              ExampleCreditProductAdditiveForm.class);
    }
}
See complete example: ProductWebConfiguration.java
✓ Step 10: Thymeleaf Templates

Create UI templates for forms, lists, and tabs

You need to create templates at these paths (or override the paths in your configuration):

Template Path Purpose Example

/product/list.html

Product list page with filters and actions

list.html

/product/edit.html

Product create/edit form

edit.html

/product/copy.html

Product copy form

Standard implementation provided

/product/tab/details.html

Product details tab

details.html

/product/tab/additives.html

Product additives tab

additives.html

/product/action/add-product-additive.html

Add/edit additive form

add-product-additive.html

Template Structure Example:

src/main/resources/templates/
  product/
    list.html
    edit.html
    copy.html                    # Standard implementation provided
    tab/
      details.html
      additives.html
    action/
      add-product-additive.html
The copy.html template has a standard implementation provided by the framework. You don’t need to create your own unless you require custom copy functionality beyond the default behavior.
Review the example templates to understand the standard structure, form bindings, and integration with the framework’s UI components.
✓ Step 11: Product State Label (Optional)

Create a label component to display product states in the UI

To show product states as visual labels in list views and detail pages, create a state label class:

@Component
@Order(1000)
public class CreditProductStateLabel extends EntityStatusLabel<ExampleCreditProduct> {

    public CreditProductStateLabel() {
        super(ExampleCreditProduct::getState);
    }

    @Override
    public boolean isEntityMarked(ExampleCreditProduct entity) {
        return entity.getState() != null;
    }

    @Override
    public String getGroup() {
        return "state";
    }
}

Key Points:

  • Extends EntityStatusLabel to automatically render state as a label

  • Define translations in your message files: productStateLabel.DRAFT, productStateLabel.ACTIVE, productStateLabel.INACTIVE

See complete example: CreditProductStateLabel.java

17.5. Template Customization

ProductWebConfigurationSupport provides default template paths, but you can customize them by overriding template path methods:

Available Template Path Methods
Method Default Path Purpose

getListTemplatePath()

/product/list

Product list view

getDetailsTemplatePath()

/product/tab/details

Product details tab

getEditTemplatePath()

/product/edit

Product edit form

getCreateTemplatePath()

/product/edit

Product creation form

getCopyTemplatePath()

/product/copy

Product copy form

getAddAdditiveTemplatePath()

/product/action/add-product-additive

Add additive form

getEditAdditiveTemplatePath()

/product/action/add-product-additive

Edit additive form

getAdditivesTabTemplatePath()

/product/tab/additives

Additives tab view

Customizing Templates

Override template path methods to use custom layouts:

@Configuration
@ConditionalOnWebApplication
public class CustomProductWebConfiguration extends ProductWebConfigurationSupport {

    public CustomProductWebConfiguration() {
        super(MyProduct.class, MyProductForm.class, MyProductFilter.class,
            MyProductAdditive.class, MyAdditiveForm.class);
    }

    @Override
    protected String getListTemplatePath() {
        return "/custom/product/list-view";
    }

    @Override
    protected String getEditTemplatePath() {
        return "/custom/product/edit-form";
    }

    @Override
    protected String getAdditivesTabTemplatePath() {
        return "/custom/product/additives-panel";
    }

    // Override other template paths as needed
}

17.6. Standard Component Details

Product Actions
CreateProductAction

Purpose: Create new credit product

EditProductAction

Purpose: Edit existing product

Availability: Only for products in DRAFT state

Once a product is activated, it cannot be edited through this action.

CopyProductAction

Purpose: Duplicate product with all active additives

Availability: Always available for any product

Behavior: * Copies product properties (code and title can be modified during copy) * Duplicates only active additives * New product starts in DRAFT state

EnableCreditProductAction

Purpose: Activate product for use in offer generation

Availability: Product must meet both conditions: * State is DRAFT * Product has at least one additive

Behavior: Changes product state from DRAFT to ACTIVE, making it available for offer generation.

DeactivateCreditProductAction

Purpose: Deactivate active product

Availability: Only for active products

Behavior: Marks product as inactive; product remains in database but is hidden from offer generation.

AddCreditProductAdditiveAction

Purpose: Add new additive to product

Availability: Only for products in DRAFT state

Additive Actions
EditAdditiveAction

Purpose: Edit product additive

Availability: Only when product is in DRAFT state

Additives can only be edited while their parent product is in draft mode.

ActivateAdditiveAction

Purpose: Activate additive for offer generation

Availability: Additive must in conditions: * Additive is currently inactive * Parent product is in DRAFT state

Behavior: Sets additive’s active flag to true.

DeactivateAdditiveAction

Purpose: Deactivate additive

Availability: Additive must meet both conditions: * Additive is currently active * Parent product is in DRAFT state

Behavior: Sets additive’s active flag to false.

Tabs
CreditProductDetailsTab

Purpose: Display product details in read-only tab format

Renders all product configuration fields including amounts, terms, rates, engine settings, and other product-specific properties.

CreditProductAdditivesTab

Purpose: Display and manage product additives

Shows the list of all additives associated with the product. Provides action buttons for editing, activating, and deactivating additives based on product state and additive status.

17.7. Next Steps

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

18.1. Process Flow

The offer generation follows this sequence:

Product Template Risk Assessment Personalized Offer Select Condition Active Credit

• Amount ranges • Interest rates • Terms • Payment engine

• Credit score • Income data • Workflow results • External data

• Adjusted amounts • Final rates • Secured options

• Participant chooses offer • Payment schedule • Signs contract

• Credit entity • Active servicing

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

18.3. Core Concepts

Credit Products

Credit products define the basic parameters for loan types - amounts, terms, currencies, and fees.

@Entity
public class ExampleCreditProduct extends CreditProduct {
    private CurrencyUnit currency;        // USD, EUR, etc.
    private BigDecimal minAmount;         // $1,000
    private BigDecimal maxAmount;         // $50,000
    private Integer minTerm;              // 6 months
    private Integer maxTerm;              // 60 months
    private BigDecimal lateFeeRate;       // 5% late fee
    private String engineName;            // "SimpleScheduledEngine"

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

Payment Calculation Engines

Credit products integrate with payment calculation engines to generate detailed payment schedules. The engineName field references a Spring service that implements the ScheduledEngine interface to calculate how loan amounts are split into principal and interest payments over time.

// Example: SimpleScheduledEngine for standard annuity payments
@Service("SimpleScheduledEngine")
public class SimpleScheduledEngine implements ScheduledEngine<ExampleCreditCondition> {

    @Override
    public List<PaymentSegment> payments(ExampleCreditCondition condition,
                                       LocalDate interestStart, LocalDate paymentStart,
                                       MonetaryAmount principal, MonetaryAmount interest) {
        // Calculate payment schedule based on loan terms
        // Each PaymentSegment contains payment date, principal/interest breakdown,
        // and remaining balances after payment
        return payments;
    }
}

Engine Purpose:

  • Payment Breakdown: Calculates how much of each payment goes to principal vs interest

  • Schedule Generation: Creates complete payment timeline with remaining balances

  • Algorithm Flexibility: Different engines can implement various payment calculation methods (annuity, differentiated, interest-only, etc.)

Integration:

  • Product Configuration: Credit product’s engineName field matches Spring service name

  • Automatic Discovery: Platform automatically finds and manages all ScheduledEngine beans

  • Validation: System ensures payment calculations are mathematically correct (remaining debt = 0)

Product Additives

Additives are specific configurations within products that define interest rates, procuring types, and offer generation rules.

@Entity
public class ExampleCreditProductAdditive extends CreditProductAdditive {
    private String name;                  // "Prime Rate", "Standard Rate"
    private BigDecimal interestRate;      // 8.5%, 12.9%
    private BigDecimal minAmount;         // Refined amount ranges
    private BigDecimal maxAmount;         // within product limits
    private Integer minTerm;              // Refined term ranges
    private Integer maxTerm;              // within product limits

    @Override
    public ExampleProductOffer createOffer() {
        return new ExampleProductOffer();
    }

    // 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
public class ExampleProductOffer extends ProductOffer {
    private UUID uuid;                    // Unique offer identifier
    private Participant participant;      // Who gets this offer
    private BigDecimal minAmount;         // Personalized min amount
    private BigDecimal maxAmount;         // Personalized max amount
    private Integer minTerm;              // Personalized min term
    private Integer maxTerm;              // Personalized max term

    // Links back to product and participant's application
    public Application getApplication() {
        return participant.getApplication();
    }

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

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

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

18.6. 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:

  1. Offer Generation: Participant gets approved → offers generated via GenerateOffersParticipantAction

  2. Offer Selection: Participant selects offer → SelectRegularConditionAction creates CreditCondition

  3. Contract Generation: System generates contract document for signature

  4. Contract Signature: Participant signs contract → triggers ContractSignChecker

  5. Credit Creation: Checker creates ExampleCredit and initializes calculations

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

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

18.8. Next Steps

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.

19. REST API Integration

TimveroOS provides a complete REST API framework that runs as a separate application alongside your main admin interface. This architecture enables clean separation between internal operations and external integrations.

19.1. Multi-Application Architecture

TimveroOS applications can run multiple Spring Boot contexts simultaneously, each serving different purposes:

public class ExampleApplication {
    public static void main(String[] args) {
        SpringApplicationBuilder parentBuilder = new SpringApplicationBuilder(BaseConfiguration.class, CustomConfiguration.class)
                .web(WebApplicationType.NONE);
        parentBuilder.run(args);

        // Admin interface on port 8081
        parentBuilder.child(WebMvcConfig.class, CustomWebConfiguration.class)
            .properties("server.port=8081")
            .run(args);

        // REST API on port 8082
        parentBuilder.child(PortalWebConfiguration.class)
            .properties("server.port=8082")
            .run(args);
    }
}

This pattern allows you to:

  • Scale independently - API and admin interface can have different resource requirements

  • Secure separately - Different authentication mechanisms for different audiences

  • Deploy flexibly - API can be deployed to different environments or behind different load balancers

  • Version independently - API versioning without affecting admin functionality

19.2. API Application Setup

The API application requires minimal configuration:

@SpringBootApplication
@EnableAutoConfiguration
@ComponentScan(basePackageClasses = {ApiWebConfig.class, PortalWebConfiguration.class})
public class PortalWebConfiguration {
}

Key components provided by the framework:

  • ApiWebConfig - Configures REST endpoints, serialization, and validation

  • OpenAPI Integration - Automatic Swagger documentation generation

  • Exception Handling - Standardized error responses

  • Request/Response Processing - JSON serialization with proper HTTP status codes

19.3. Controller Patterns

TimveroOS API controllers follow enterprise patterns with comprehensive documentation:

@RestController
@RequestMapping("clients")
@SecurityRequirement(name = BASIC_AUTH)
@Tag(name = "Client Management", description = "API for managing clients")
public class ClientController {

    @PostMapping
    @Operation(summary = "Create a new client")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Client created successfully"),
        @ApiResponse(responseCode = "400", description = "Invalid request data")
    })
    public ResponseEntity<CreateClientResponse> createClient(
        @RequestBody @Valid CreateClientRequest form) {
        UUID clientId = clientService.createClient(form);
        return ResponseEntity.ok(new CreateClientResponse(clientId));
    }
}

Framework Advantages:

  • Automatic validation - Jakarta Bean Validation with detailed error messages

  • OpenAPI generation - Complete API documentation without manual maintenance

  • Type safety - Request/response DTOs with MapStruct mapping

  • Consistent error handling - Framework-provided exception responses

19.4. Service Layer Integration

API controllers leverage your existing business logic without duplication:

@Service
public class ClientService {

    @Autowired
    private ClientRequiestMapper mapper;  // MapStruct generated

    @Autowired
    private ClientRepository clientRepository;  // Your existing repository

    @Transactional
    public UUID createClient(@Valid CreateClientRequest form) {
        Client client = mapper.createEntity(form);  // DTO to Entity mapping
        Client savedClient = clientRepository.save(client);
        return savedClient.getId();
    }
}

This approach ensures:

  • Single source of truth - Business logic remains in service layer

  • Consistent validation - Same rules for API and admin interface

  • Transaction management - Proper database transaction handling

  • Audit trails - All operations logged through existing mechanisms

19.5. Authentication & Security

The framework provides configurable authentication:

@Configuration
@EnableWebSecurity
@SecurityScheme(type = SecuritySchemeType.HTTP, name = "basicAuth", scheme = "basic")
public class InternalApiSecurityConfig {

    @Value("${internal.api.username:username}")
    private String internalApiUser;

    @Value("${internal.api.password:password}")
    private String internalApiPassword;
}

Security Features:

  • Basic Authentication - Simple, reliable for internal APIs

  • Configurable credentials - Environment-specific configuration

  • Swagger UI protection - Documentation access control

  • Method-level security - @PreAuthorize support for fine-grained access

Alternative Authentication Methods

The framework supports multiple authentication strategies beyond Basic Auth:

JWT Token Authentication:

@SecurityScheme(
    type = SecuritySchemeType.HTTP,
    name = "bearerAuth",
    scheme = "bearer",
    bearerFormat = "JWT"
)
@Configuration
public class JwtSecurityConfig {

    @Bean
    public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception {
        http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

OAuth2 Integration:

@SecurityScheme(
    type = SecuritySchemeType.OAUTH2,
    name = "oauth2",
    flows = @OAuthFlows(
        authorizationCode = @OAuthFlow(
            authorizationUrl = "${oauth2.authorization-uri}",
            tokenUrl = "${oauth2.token-uri}",
            scopes = {
                @OAuthScope(name = "read", description = "Read access"),
                @OAuthScope(name = "write", description = "Write access")
            }
        )
    )
)

API Key Authentication:

@SecurityScheme(
    type = SecuritySchemeType.APIKEY,
    name = "apiKey",
    in = SecuritySchemeIn.HEADER,
    paramName = "X-API-Key"
)
public class ApiKeySecurityConfig {

    @Bean
    public SecurityFilterChain apiKeyFilterChain(HttpSecurity http) throws Exception {
        http.addFilterBefore(new ApiKeyAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

Multiple Authentication Schemes: You can configure multiple authentication methods simultaneously:

@RestController
@SecurityRequirements({
    @SecurityRequirement(name = "basicAuth"),
    @SecurityRequirement(name = "bearerAuth")
})
public class FlexibleAuthController {
    // Accepts both Basic Auth and JWT tokens
}

Authentication Strategy Selection:

  • Basic Auth - Internal tools, development environments

  • JWT/OAuth2 - External integrations, mobile applications

  • API Keys - Third-party service integrations, webhooks

  • Mutual TLS - High-security B2B integrations

  • Custom schemes - Proprietary authentication systems

19.6. Request/Response Mapping

MapStruct integration provides type-safe, performant mapping:

@Mapper
public interface ClientRequiestMapper {

    @Mapping(target = "individualInfo.nationalId", source = "nationalId")
    @Mapping(target = "contactInfo.email", source = "email")
    @Mapping(target = "participants", ignore = true)
    Client createEntity(CreateClientRequest form);
}

Mapping Advantages:

  • Compile-time generation - No runtime reflection overhead

  • Type safety - Compilation errors for mismatched fields

  • Nested object support - Complex object graph mapping

  • Validation integration - Works seamlessly with Jakarta Bean Validation

19.7. API Documentation

OpenAPI documentation is generated automatically and includes:

  • Interactive Swagger UI - Available at /swagger-ui.html

  • Complete schema definitions - Request/response models with validation rules

  • Authentication flows - Security requirements clearly documented

  • Example requests - Generated from your validation annotations

Access the documentation at: http://localhost:8082/swagger-ui.html

19.8. Advanced Capabilities

Webhook Support:

@RestController
@RequestMapping("webhooks")
public class WebhookController {

    @PostMapping("/docusign")
    public ResponseEntity<Void> handleDocusignWebhook(@RequestBody DocusignEvent event) {
        // Process external webhook
        return ResponseEntity.ok().build();
    }
}

Async Processing:

@PostMapping("/applications")
public ResponseEntity<CreateApplicationResponse> createApplication(@RequestBody CreateApplicationRequest request) {
    UUID applicationId = applicationService.createApplicationAsync(request);
    return ResponseEntity.accepted().body(new CreateApplicationResponse(applicationId));
}

File Upload Support:

@PostMapping(value = "/documents", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<UploadResponse> uploadDocument(@RequestParam("file") MultipartFile file) {
    // Framework handles file processing
    return ResponseEntity.ok(uploadResponse);
}

19.9. Integration Patterns

External System Integration: The API layer serves as an integration point for external systems while maintaining all business rules and validation from your main application.

Mobile Application Backend: Provides clean JSON APIs for mobile applications with proper error handling and validation.

Third-party Service Integration: Webhooks and callbacks from external services (payment processors, document signing, credit bureaus) can be handled through dedicated API endpoints.

Microservices Communication: When scaling to microservices architecture, the API layer provides a stable interface contract between services.

For detailed Spring Boot and OpenAPI configuration, refer to:

20. Docusign Integration

This section describes how to integrate Docusign electronic signature service into your Timvero application for document signing workflows.

20.1. Integration Overview

The framework reduces DocuSign integration to three simple steps:

  1. Entity implements DocusignSigner - delegate to existing entity fields

  2. Service calls framework - one method call handles everything

  3. Optional webhook - extend default for custom business logic

What the framework handles: - DocuSign API authentication and session management - Envelope creation and document upload - Signer management and embedded URL generation - Status polling fallback when webhooks fail - Multi-party signing workflows with routing order - Document template integration for signature placement

What you implement: - Business logic for document selection based on application state - Mapping entity fields to signer information - Custom webhook logic (optional)

20.2. Configuration Setup

Properties Configuration

Add the following properties to your application configuration file (e.g., application.properties, application.yml, or environment-specific property files):

# DocuSign Configuration
docusign.api.base.path=https://demo.docusign.net/restapi
docusign.oauth.base.path=account-d.docusign.com
docusign.rsa.key.file=classpath:/docusign/private.key
docusign.client.id=602c4320-5b82-48bf-b0c9-9d47a84de053
docusign.user.id=c2e5a6c9-fd35-477f-8104-e2e5385d251a
The configuration values shown above are examples only. Your actual configuration will differ and should be obtained from your DocuSign account and environment setup.
Property Descriptions
docusign.api.base.path

Base URL for DocuSign REST API endpoints. The actual URLs may vary depending on your DocuSign environment and region. Refer to the DocuSign platform documentation for current API endpoints.

docusign.oauth.base.path

OAuth base domain for DocuSign JWT authentication. Check the DocuSign platform documentation for the appropriate OAuth endpoints for your environment.

docusign.rsa.key.file

Spring Resource path to the RSA private key file used for JWT authentication. Supports prefixes like classpath:, file:, etc.

docusign.client.id

Integration Key (Client ID) for your DocuSign application. This value can be obtained from your DocuSign account on the platform.

docusign.user.id

DocuSign User ID (GUID) associated with your integration. This value can be obtained from your DocuSign account on the platform.

All DocuSign-specific configuration values (API endpoints, Client ID, User ID) should be obtained from your DocuSign account dashboard. Refer to the DocuSign developer documentation for detailed instructions on locating these values.
Private Key Setup

The DocuSign integration requires an RSA private key for JWT authentication. The docusign.rsa.key.file property uses Spring Resource syntax:

Classpath resource:

docusign.rsa.key.file=classpath:/docusign/private.key

Place file at: src/main/resources/docusign/private.key

File system resource:

docusign.rsa.key.file=file:/path/to/docusign/private.key

Use absolute path to file on server filesystem.

For detailed instructions on generating and configuring DocuSign API credentials and private keys, refer to the DocuSign JWT Authentication Guide.

20.3. Implementation Steps

Step 1: Entity Implementation

Your entity must implement the DocusignSigner interface to provide signer information:

@Entity
public class Participant extends AbstractAuditable implements DocusignSigner {

    @Override
    public String getSignerName() {
        return getClient().getIndividualInfo().getFullName();
    }

    @Override
    public String getSignerEmail() {
        return getClient().getContactInfo().getEmail();
    }

    // For multi-party signing (optional)
    @Override
    public List<DocusignSignerInfo> getAllSigners() {
        // Default: single signer with routing order 1
        return List.of(new DocusignSignerInfo(getId(), getSignerName(), getSignerEmail(), 1, "borrower"));
    }
}

Key points: - Delegate to existing entity fields - no new data storage needed - getAllSigners() enables multi-party workflows and document template integration - Default implementation handles single signer scenarios

Step 2: Service Integration

Implement your business logic and call the framework:

@Service
public class ApplicationPortalService {

    @Autowired
    private DocusignSignatureService docusignSignatureService;

    @Transactional
    public String getSignatureUrl(UUID applicationId, String returnUrl) throws IOException, SignatureException {
        // Your business logic
        Application application = findApplication(applicationId);
        SignableDocument document = selectDocumentBasedOnStatus(application);
        Participant participant = application.getBorrowerParticipant();

        // Framework handles everything else
        return docusignSignatureService.getDocusignUrl(participant, document, returnUrl);
    }
}

Framework call does: - Creates DocuSign envelope if needed - Uploads document to DocuSign - Manages signer creation - Returns embedded signing URL - Handles status polling fallback

Status-based document selection pattern:

SignableDocumentType documentType;
switch (portalStatus) {
    case IN_PROCESS -> documentType = APPLICATION_FORM;
    case PENDING_CONTRACT_SIGNATURE -> documentType = APPLICATION_CONTRACT;
    case null, default -> throw new PreconditionFailedException(
        "Signature is not available for application status: " + portalStatus);
}
Step 3: Controller Endpoint

Standard REST controller delegates to service:

@GetMapping("/signature-url")
@Operation(summary = "Get application signature url")
@ApiResponses(value = {
    @ApiResponse(responseCode = "200", description = "Signature URL retrieved successfully"),
    @ApiResponse(responseCode = "404", description = "Application not found"),
    @ApiResponse(responseCode = "412", description = "Application with incorrect status provided")
})
public ResponseEntity<String> getApplicationSignatureUrl(
    @RequestParam UUID applicationId,
    @RequestParam String returnUrl) throws IOException, SignatureException {

    String signatureUrl = applicationService.getSignatureUrl(applicationId, returnUrl);
    return ResponseEntity.ok(signatureUrl);
}
Step 4: Webhook Implementation (Optional)

The framework provides automatic webhook handling at /callback/docusign/webhook. When documents are signed, the framework automatically processes them without additional code.

Webhook resilience: Framework ignores webhooks for unknown envelopes and polls DocuSign directly when webhooks fail.

Custom Webhook (Optional)

Extend the default webhook for additional business logic:

@RestController
@RequestMapping(value = DocusignWebhookController.PATH, produces = MediaType.APPLICATION_JSON_VALUE)
public class DocusignWebhookController {

    @Autowired
    private DocusignSignatureService docusignSignatureService;

    @PostMapping(value = "/webhook", produces = "application/json;charset=UTF-8")
    public void handleWebhook(@RequestBody DocusignWebhookResponse payload) throws IOException {
        if (payload.getData() != null && payload.getData().getUserId().equals(userId)) {
            String envelopeId = payload.getData().getEnvelopeId();
            try {
                docusignSignatureService.signDocumentByEnvelopeId(envelopeId);
                // Add your custom logic here: notifications, status updates, etc.
            } catch (SignatureException e) {
                // Framework ignores unknown signatures - webhook may arrive for
                // signatures not tracked in our system
            }
        }
    }
}

20.4. Document Templates (Responsive Signing)

DocuSign Responsive Signing allows creating HTML documents with embedded signing fields that automatically adapt to mobile devices. The framework integrates with getAllSigners() to map template roles to actual signers.

Important: Responsive Signing must be enabled by DocuSign support. Contact DocuSign to request "Enable Smart Sections/API for Responsive Signing" for your account.

DocuSign HTML Elements

Signature and identity fields:

<!-- Signature field -->
<ds-signature data-ds-role="borrower"></ds-signature>

<!-- Full name (auto-populated) -->
<ds-fullname data-ds-role="borrower"></ds-fullname>

<!-- Date signed (auto-populated) -->
<ds-date-signed data-ds-role="borrower"></ds-date-signed>

Input fields using standard HTML with DocuSign attributes:

<!-- Text input -->
<input data-ds-type="text" data-ds-role="borrower" />

<!-- Email input with validation -->
<input data-ds-type="email" data-ds-role="borrower" />

<!-- Optional field -->
<input data-ds-type="text" data-ds-role="borrower" required="false" />
Field Requirements

By default, all fields are required. To make a field optional:

<input data-ds-type="text" data-ds-role="borrower" required="false" />
Styling and Customization

All standard HTML styling is supported:

<!-- Styled signature -->
<ds-signature data-ds-role="borrower"
              style="width:300px;height:100px;border:2px solid #000;">
</ds-signature>

<!-- Styled date field -->
<ds-date-signed data-ds-role="borrower"
                style="color:red;font-size:18px;font-weight:bold;">
</ds-date-signed>

<!-- Text input with styling -->
<input data-ds-type="text"
       data-ds-role="borrower"
       style="width:200px;font-family:Arial;"
       id="borrowerPhone"
       class="form-field" />
Complete Template Example
<!DOCTYPE html>
<html>
<head>
    <title>Loan Application</title>
</head>
<body>
    <h1>Personal Loan Agreement</h1>

    <h3>Borrower Information:</h3>
    <p>Email: <input data-ds-type="email" data-ds-role="borrower" /></p>
    <p>Name: <ds-fullname data-ds-role="borrower"></ds-fullname></p>
    <p>Phone: <input data-ds-type="text" data-ds-role="borrower" required="false" /></p>

    <div>
        <br><br>
        Borrower Signature: <ds-signature data-ds-role="borrower"></ds-signature>
    </div>

    <p>Date Signed: <ds-date-signed data-ds-role="borrower"></ds-date-signed></p>
</body>
</html>
Framework Integration

Template roles must match timveroRole values from getAllSigners():

@Override
public List<DocusignSignerInfo> getAllSigners() {
    return List.of(
        new DocusignSignerInfo(borrowerId, borrowerName, borrowerEmail, 1, "borrower"),
        new DocusignSignerInfo(guarantorId, guarantorName, guarantorEmail, 2, "guarantor")
    );
}
<!-- Template uses matching role names -->
<ds-signature data-ds-role="borrower"></ds-signature>
<ds-signature data-ds-role="guarantor"></ds-signature>
Form Data Collection

Input fields automatically populate the formData map in DocusignDocumentSignature:

<input data-ds-type="text" data-ds-role="borrower" name="income" />
<input data-ds-type="email" data-ds-role="borrower" name="contact_email" />

After signing, access the data:

DocusignDocumentSignature signature = (DocusignDocumentSignature) document.getSignature();
Map<String, String> formData = signature.getFormData();
String income = formData.get("income");
String email = formData.get("contact_email");

Key requirements:

  • data-ds-role values must exactly match timveroRole from getAllSigners()

  • Responsive Signing must be enabled for your DocuSign account

  • All fields are required by default unless required="false" is specified

  • Standard HTML styling and attributes are fully supported

Additional Resources

For more detailed information about DocuSign Responsive Signing and HTML templates:

Official DocuSign Documentation:

DocuSign Developer Blog:

Code Examples:

DocuSign Webhook Configuration

Configure webhooks in your DocuSign account:

  1. Go to your DocuSign Admin panel

  2. Navigate to Integrations > Webhooks

  3. Create a new webhook with your endpoint URL

  4. Select relevant events (e.g., "Envelope Completed")

For detailed configuration instructions, see the DocuSign Webhooks Documentation.

21. Covenant Monitoring System

The Covenant Monitoring System provides automated control of the financial conditions of loan agreements through integration with the Metric System and the Feature Store.

21.1. Architecture Overview

The Covenant Monitoring System follows a four-layer architecture:

  • CovenantSpecification — versioned monitoring rules

  • CovenantExecution — scheduling and performing checks

  • CovenantResult — storing check results and managing violations

  • Metric Integration — calculation of indicators via MetricService

Scheduled Task → CovenantSpecification → MetricService → CovenantExecution → CovenantResult
                                              ↓
                                    Feature Store + DataSources

Key Principle: Covenants always operate through the Metric System. Indicators are computed via MetricMapping, and validation conditions are defined through Expression.

Core System Components
Component Description

CovenantSpecification

A versioned configuration of a monitoring rule. Defines the holder, subject, metrics, and validation conditions.

CovenantExecution

A record of an executed check. Contains status, a collection of results, and error information.

CovenantResult

The outcome of a check for a specific subject. Possible states: CLEAN, VIOLATION, RESOLVED, EXCEPTION.

HasCovenant

Marker interface for entities that own covenants (e.g., ExampleCredit).

HasMetric

Marker interface for entities being evaluated (e.g., Participant, Collateral).

CovenantHolderResolver

Links a holder with its subjects and defines the logic for resolving the entities to be validated.

What the Framework Does

Automatic Monitoring:

  • Periodic Execution — checks are performed on a schedule

  • Metric Calculation — via Feature Store and DataSources

  • CovenantExecution Creation — generated for each scheduled check

  • Result Persistence — stores outcomes for all subjects

Lifecycle Management:

  • Producer — creates tasks with status CovenantExecution.NEW

  • Consumer — executes the checks and saves the results

  • Exception Handling — automatic processing of metrics in EXCEPTION state

  • Versioning — managed through lineageId

What is a Covenant?

A Covenant is a contractual financial condition that a borrower agrees to maintain throughout the term of a loan. Covenants are used for risk monitoring and early detection of financial distress.

Typical Covenant Examples:

  • Borrower Income — must not decrease by more than 20% from the baseline value

  • Loan-to-Value (LTV) — loan amount to collateral value ratio < 80%

  • Debt-to-Income (DTI) — debt-to-income ratio < 40%

  • Minimum Liquidity — available funds > €10,000

CovenantSpecification — Rule Configuration

CovenantSpecification defines what to check, who to check, and how often the checks are performed.

Field Description

holderType

The type of entity that owns the covenant (ExampleCredit). Defined via CovenantSpecificationHolderType.

subjectKey

Specifies who is being checked: BORROWER, GUARANTOR, COLLATERAL. Must match a key from CovenantHolderResolver.

metricMapping

The MetricMapping used to compute the current value of the indicator (REGULAR metric).

anchoredMetricMapping

Optional MetricMapping used to obtain the baseline value (ANCHORED metric).

expression

JavaScript/Groovy expression used to evaluate the condition. Returns a boolean: true = CLEAN, false = VIOLATION.

executionType

Execution type. Currently only SCHEDULED (runs periodically).

periodicity

Frequency of execution: DAYS, WEEKS, MONTHS, YEARS.

numberOfPeriods

Number of periods between executions (e.g., 1 for monthly checks).

dateTimeExecution

Date and time of the first scheduled execution. The Producer calculates subsequent ones based on periodicity.

additives

List of product additive IDs. The covenant applies only to loans with these products.

active

Activity flag. Only active specifications are executed.

Versioning:

  • CovenantSpecification extends HistoryEntity — versioning is supported

  • All versions are linked through a shared lineageId

  • When a specification is edited, a new version is created with active=true

  • Previous versions remain in history with active=false

CovenantExecution — Check Record

CovenantExecution is created for each run of a check based on a CovenantSpecification.

Field Description

specification

Reference to the CovenantSpecification being executed.

periodIndex

The index of the period for scheduled executions. Calculated based on dateTimeExecution and periodicity.

status

NEWEVALUATED or DISABLED. The Consumer processes NEW executions and sets the status to EVALUATED upon completion.

covenantResults

A collection of CovenantResult entries for all evaluated subjects.

exception

ExceptionEntity if the check failed (e.g., script error, timeout, etc.).

createdAt

Timestamp when the execution was created.

createdBy

User who triggered the execution (for manual runs) or null (for scheduled ones).

CovenantResult — Check Result

CovenantResult stores the outcome of a check for a specific subject within a CovenantExecution.

Field Description

execution

Reference to the corresponding CovenantExecution.

specification

Reference to the CovenantSpecification (denormalized for faster access).

ownerId

UUID of the holder entity (e.g., ExampleCredit.id).

subjectId

UUID of the evaluated subject (e.g., Participant.id).

metric

Reference to the computed REGULAR metric.

rawContent

The raw result of the expression evaluation (string representation).

state

CLEAN, VIOLATION, RESOLVED, EXCEPTION.

createdAt

Timestamp of result creation.

CovenantResult States:

  • {ok} CLEAN — condition satisfied, no violations

  • {bad} VIOLATION — condition violated

  • {ok} RESOLVED — violation manually resolved by a user

  • {bad} EXCEPTION — error occurred during metric or expression evaluation

HasCovenant and HasMetric

Marker interfaces for type-safe linking of entities.

Metric Types: REGULAR vs ANCHORED

The system uses two types of metrics for different monitoring purposes.

REGULAR Metrics

Purpose: Represents the current value of an indicator during each evaluation.

When calculated: On every covenant execution via MetricService.getMetric().

Examples:

  • Current monthly income of the borrower

  • Current market value of the collateral

  • Current debt-to-income ratio

ANCHORED Metrics

Purpose: Fixed baseline value used for comparison.

When set:

  • At loan origination — automatically via AnchoredMetricService.calculateAnchored()

  • When conditions change — recalculated via recalculateAnchored()

  • Manually by a user — through UpdateAnchoredMetricAction

Examples:

  • Income at the time of loan origination

  • Initial appraised value of the collateral

  • DTI ratio at loan approval

HasCovenant Interface
public interface HasCovenant extends Persistable<UUID> {
}

Purpose: Marks entities that can have covenants (holders).

Examples:

  • ExampleCredit — loans with financial conditions

Required to implement:

  • CovenantHolderResolver<T extends HasCovenant> for this type

  • Define subjects via getCovenantSubjects()

HasMetric Interface
public interface HasMetric extends FeaturedSubject {
    default String details() {
        return getPrimaryId();
    }
}

Purpose: Marks entities that can be evaluated (subjects).

Examples:

  • Participant — borrowers and guarantors

  • Collateral — pledged assets

Required to implement:

  • details() — human-readable description for the UI (name, ID, etc.)

  • getPrimaryId() — unique identifier for logs

CovenantHolderResolver — Linking holder ↔ subjects

CovenantHolderResolver defines relationships between a holder and its subjects.

Method Description

resolveTargets

Stream<T> resolveTargets(CovenantSpecification spec) — find all holder entities to be checked.

getCovenantSubjects

List<SubjectRecord<T>> getCovenantSubjects() — the list of available subject types with extraction functions.

getCovenantSpecifications

Collection<CovenantSpecification> getCovenantSpecifications(T target) — which specs apply to the holder.

21.2. Complete Implementation Example

Below is a complete implementation of the covenant system for a credit product using ExampleCredit as the holder.

Overview

In this example, we implement borrower income monitoring to verify that income has not dropped by more than 20% from the baseline captured at loan origination.

Implementation components:

  • CovenantHolderResolver — links ExampleCredit with Participant subjects

  • MetricMapping — config for computing income via the Feature Store

  • CovenantSpecification — the income verification rule

  • UI Integration — tabs to browse results

Step 1: CovenantHolderResolver Implementation

Create a resolver that links the credit to the participants being evaluated.

@Component
public class CreditCovenantHolderResolver implements CovenantHolderResolver<ExampleCredit> {

    @Autowired
    private ExampleCreditRepository creditRepository;
    @Autowired
    private CovenantSpecificationRepository covenantSpecificationRepository;

    @Override
    public Stream<ExampleCredit> resolveTargets(CovenantSpecification specification) {
        // Find all credits to check by product additive
        return specification.getAdditives().stream()
            .flatMap(a -> creditRepository.getAllByAdditiveId(a));
    }

    @Override
    public List<SubjectRecord<ExampleCredit>> getCovenantSubjects() {
        return List.of(
            // BORROWER subject
            SubjectRecordBuilder.<ExampleCredit>builder()
                .name(ParticipantRole.BORROWER.name())
                .subject(new SubjectSupplierRecord<>(
                    Participant.class,
                    credit -> List.of(credit.getApplication().getBorrowerParticipant())
                ))
                .build(),

            // GUARANTOR subject
            SubjectRecordBuilder.<ExampleCredit>builder()
                .name(ParticipantRole.GUARANTOR.name())
                .subject(new SubjectSupplierRecord<>(
                    Participant.class,
                    credit -> credit.getApplication().getParticipants().stream()
                        .filter(p -> p.getRoles().contains(ParticipantRole.GUARANTOR))
                        .collect(Collectors.toList())
                ))
                .build()
        );
    }

    @Override
    public Collection<CovenantSpecification> getCovenantSpecifications(ExampleCredit target) {
        // Find all active specs for the product additive of this credit
        return covenantSpecificationRepository.findAllByAdditiveIdAndActiveTrue(
            target.getCondition().getSecuredOffer().getOriginalOffer().getProductAdditive().getId()
        );
    }
}

Key Points:

  • @Component — Spring automatically discovers the resolver via its generic type

  • resolveTargets — finds credits by the product additive from the specification

  • getCovenantSubjects — three subject types: BORROWER, GUARANTOR, COLLATERAL

  • getCovenantSpecifications — filters specs by the credit’s product additive

Step 2: Entity Integration

Ensure that entities implement the required interfaces.

ExampleCredit Entity
@Entity
@DiscriminatorValue("1")
public class ExampleCredit extends Credit implements HasCovenant {

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(unique = true, nullable = false)
    private BaseApplication application;

    // ... other fields
}
Participant Entity
@Entity
@Table(name = "participant")
@Audited
public class Participant extends AbstractAuditable
    implements HasMetric {

    @Override
    public String details() {
        return getFullName();  // "John Smith"
    }

    @Transient
    @Override
    public String getPrimaryId() {
        IndividualInfo info = getClient() != null
            ? getClient().getIndividualInfo()
            : getIndividualInfo();
        return info.getNationalId();  // "123456789"
    }

    // ... other fields
}

Important:

  • HasCovenant — marker for holder entities

  • HasMetric — marker for subject entities

  • details() — human-readable name for the UI

  • getPrimaryId() — unique ID for logs and grouping

Step 3: MetricMapping Configuration

Create a MetricMapping in the UI to compute a participant’s monthly income.

Configuration:

Field Value

Name

participant_monthly_income

Entity Type

Participant

Expression

JavaScript code that extracts income

Engine

javascript

Expression for computing income:

// Extract income from the Feature Store
var incomeData = features.get("tink", "monthly_income");

// If no banking data, fall back to manually provided values
if (incomeData == null) {
    var annualIncome = entity.getTotalAnnualIncome();
    if (annualIncome != null) {
        return annualIncome.getNumber().doubleValue() / 12;
    }
    return null;
}

// Return monthly income from banking data
return incomeData;

What happens:

  • features.get() — loads data from the Feature Store (e.g., Tink bank data)

  • Fallback — if no external data is available, use entity.getTotalAnnualIncome()

  • Automatic typingMetricService infers the FeatureDataType from the result

Step 4: CovenantSpecification Configuration

Create a CovenantSpecification in the UI to validate income.

Core parameters:

Field Value

Name

Borrower Income Monitoring

Holder Type

ExampleCredit

Subject Key

BORROWER

Metric Mapping

participant_monthly_income (REGULAR)

Anchored Metric Mapping

participant_monthly_income (ANCHORED)

Execution Type

SCHEDULED

Periodicity

MONTHS

Number of Periods

1 (monthly)

Date Time Execution

2025-01-01T00:00:00 (first run)

Additives

Select the product additives to which the rule applies

Validation expression:

// Ensure income has not dropped by more than 20%
// metric   — current income (REGULAR)
// anchored — baseline income at origination (ANCHORED)

if (anchored == null) {
    // If no baseline is set, skip the check
    return true;
}

// Income must be >= 80% of the baseline
return metric >= anchored * 0.8;

Bindings available in the expression:

Binding Description

covenantHolder

ExampleCredit entity

subject

Participant entity (borrower)

metric

Metric object containing the current income (REGULAR)

participant_monthly_income

Direct access to the metric value (metric.getContent())

anchored

Value of the ANCHORED metric or null

Step 5: ANCHORED Metric Setup

Set the baseline income value at the time of loan origination.

Automatic Setup

When the credit transitions to an active state, invoke:

@Component
public class CreditChecker extends EntityChecker<ExampleCredit, UUID> {

    @Autowired
    private AnchoredMetricService anchoredMetricService;

    @Override
    protected void registerListeners(CheckerListenerRegistry<ExampleCredit> registry) {
        registry.entityChange().inserted();
    }

    @Override
    protected boolean isAvailable(ExampleCredit credit) {
        return true;
    }

    @Override
    protected void perform(ExampleCredit credit) {
        TransactionUtils.afterTransaction(() -> {
            anchoredMetricService.calculateAnchored(credit);
        });
    }
}

What happens:

  • Finds all CovenantSpecifications with anchoredMetricMapping != null

  • Resolves subjects via CovenantHolderResolver

  • Computes metrics via MetricService

  • Persists them as MetricType.ANCHORED

Manual Setup via UI

A user can update an ANCHORED metric with UpdateAnchoredMetricAction:

@RequestMapping("/update-anchored-metric")
@Controller
@Order(10000)
public class UpdateAnchoredMetricAction extends AbstractUpdateAnchoredMetricAction<ExampleCredit> {
    // Inherits full functionality
}

UI flow:

  • Open the credit → Actions → Update Anchored Metric

  • Select the subject (borrower)

  • Select the metric (participant_monthly_income)

  • Enter a new value and data type

  • Save — a new ANCHORED metric is created

Step 6: UI Integration

Add tabs to display check results.

Tab with the Latest Results
@RequestMapping("/covenant-result")
@Controller
@Order(9500)
public class CreditCovenantResultTab extends AbstractCovenantResultTab<ExampleCredit> {

    @Autowired
    private CovenantResultRepository covenantResultRepository;

    @Override
    protected List<CovenantResult> getCovenantResults(UUID id) {
        // Shows only the latest result for each subject
        return covenantResultRepository.findLatestByOwnerGroupedBySubjectId(id);
    }
}
Tab with Full History
@RequestMapping("/covenant-result-archive")
@Controller
@Order(10500)
public class CreditCovenantResultArchiveTab extends AbstractCovenantResultTab<ExampleCredit> {

    @Autowired
    private CovenantResultRepository covenantResultRepository;

    @Override
    protected List<CovenantResult> getCovenantResults(UUID id) {
        // Show all check results
        return covenantResultRepository.findAllByOwnerId(id);
    }
}
Tab for ANCHORED Metrics
@RequestMapping("/anchored-metric")
@Controller
@Order(10000)
public class CreditAnchoredMetricTab extends HasCovenantAnchoredMetricTab<ExampleCredit> {

    @Override
    public boolean isVisible(ExampleCredit entity) {
        return true;  // Always visible
    }
}

Action to calculate the covenant manually:

@RequestMapping("/calculate-covenant")
@Controller
@Order(11000)
public class CalculateCovenantAction extends AbstractCalculateCovenantAction<ExampleCredit> {
    // Inherits full functionality
}
Localization

Add translations for UI elements in messages.properties.

#---------------Actions-------------
credit.action.update-anchored-metric=Update Anchored Metric
credit.action.calculate-covenant=Calculate Covenant
#---------------Tabs----------------
credit.tab.covenant-result=Covenant Results
credit.tab.covenant-result-archive=Covenant Results Archive
credit.tab.anchored-metric=Anchored Metrics
Step 7: Testing

Validate the implementation via the UI.

Test Scenario 1: Creating a Specification
  • Go to /covenant-specification → Create New

  • Fill in all fields (see Step 4)

  • Use the Test button with a real credit

  • Verify the expression returns true/false

  • Save the specification

Test Scenario 2: Setting the ANCHORED Metric
  • Open a credit in status NEW

  • Activate the credit → calculateAnchored() is invoked

  • Navigate to the Anchored Metrics tab

  • Verify that income is stored with type ANCHORED

Test Scenario 3: Manual Execution
  • Open the credit → ActionsCalculate Covenant

  • Select the specification Borrower Income Monitoring

  • Execute → a CovenantExecution is created

  • Go to the Covenant Results tab

  • Verify the state: CLEAN or VIOLATION

Test Scenario 4: Scheduled Execution
  • Wait for the configured dateTimeExecution

  • The Producer creates CovenantExecution.NEW

  • The Consumer processes it and creates CovenantResults

  • Check logs to confirm execution

  • Review results in the UI

21.3. Expression Writing Guide

An expression defines the covenant condition. It is JavaScript or Groovy code that must return a boolean result.

Expression Basics

Expression requirements:

  • Returns booleantrue for CLEAN, false for VIOLATION

  • Short and readable — avoid overly complex logic

  • Null-safe — always check for null before use

  • Deterministic — same input → same output

Supported scripting engines:

  • JavaScript — recommended; fast and simple

  • Groovy — for advanced logic with Java interop

Available Bindings

An expression has access to the following variables:

Binding Type Description

covenantHolder

HasCovenant (e.g., ExampleCredit)

The entity that owns the covenant. All fields accessible via getters.

subject

HasMetric (e.g., Participant)

The entity being evaluated. Fields accessible via getters.

metric

Metric object

Metric object with methods getName(), getContent(), and getDataType().

<metricName>

Depends on the metric

Direct access to the metric value. The name matches MetricMapping.name.

anchored

Depends on the metric or null

Value of the ANCHORED metric. null if not set.

Expression Examples
Example 1: Debt-to-Income Check

Requirement: DTI < 40%.

// Get the monthly payment from the credit schedule
var monthlyPayment = covenantHolder.getSchedule().getPaymentAmount().getNumber().doubleValue();

// Get the current monthly income from the metric
var monthlyIncome = metric;

if (monthlyIncome == null || monthlyIncome <= 0) {
    return false;  // No income = VIOLATION
}

// DTI must be < 40%
var dti = monthlyPayment / monthlyIncome;
return dti < 0.4;
Example 2: Combined Check

Requirement: Income remains stable AND DTI is acceptable.

// Check 1: income has not dropped by more than 20%
var incomeStable = anchored != null && metric >= anchored * 0.8;

// Check 2: DTI < 40%
var monthlyPayment = covenantHolder.getSchedule().getPaymentAmount().getNumber().doubleValue();
var dtiAcceptable = (monthlyPayment / metric) < 0.4;

// Both conditions must pass
return incomeStable && dtiAcceptable;
Null Safety Patterns

Always check for null before using any values.

Early Return
// Check anchored
if (anchored == null) {
    return true;  // Or false, depending on business requirements
}

// Check metric
if (metric == null) {
    return false;  // No data = violation
}

// Main logic
return metric >= anchored * 0.8;
Testing Expressions

Use the built-in expression tester in the UI for debugging.

Test Flow
  • Open CovenantSpecification → Edit or Create

  • Write the expression in the code editor

  • Select a test entity from the dropdown (holder)

  • Click Evaluate → result appears in the console

Test Output Format
<subject.primaryId> : <result>

Examples:

123456789 : true
987654321 : false
111222333 : Exception: Cannot read property 'doubleValue' of null
Performance Considerations

Each expression is executed for every subject during every evaluation.

Best Practices
  • Avoid loops — prefer simple comparisons

  • Minimize method calls — cache results in local variables

  • Do not perform DB queries — use only available bindings

  • Keep it simple — move complex logic to MetricMapping

Good Example
// Fast and efficient
return metric >= anchored * 0.8;
Bad Example
// Slow — multiple method calls
for (var i = 0; i < subject.getApplication().getParticipants().size(); i++) {
    var p = subject.getApplication().getParticipants().get(i);
    if (p.getStatus().equals(ParticipantStatus.ACTIVE)) {
        // Complex logic inside loop
    }
}
Best Practices
Expression Design
  • Return boolean explicitlyreturn true or return false

  • Use meaningful variable namesincome, baseline, not x, y

  • Test with real data — use the UI tester before saving

Anti-Patterns
  • {bad} Not checking null — causes EXCEPTION instead of VIOLATION

  • {bad} Overly nested logic — makes expressions unreadable

  • {bad} Iterating over collections — poor performance impact

  • {bad} DB queries inside expression — use only MetricMapping + Feature Store

  • {bad} Mutation inside expression — expressions must only evaluate, not modify state

  • {bad} Using console.log — not available in production, only for debugging

  • {bad} Hardcoded entity IDs — expressions must work for any entity

  • {bad} Ignoring currency — always validate the currency for MonetaryAmount

Expression Checklist

Before saving a CovenantSpecification, verify the following:

  • {todo} Expression returns a boolean

  • {todo} All null cases are handled

  • {todo} Tested with real data via UI

  • {todo} Works for all subject types (BORROWER, GUARANTOR, etc.)

  • {todo} Handles the case when anchored = null

  • {todo} No magic numbers — use clear constants

  • {todo} Comments added for non-trivial logic

  • {todo} Under 20 lines of code

21.4. Lifecycle & Scheduling

The Covenant Monitoring System uses a producer–consumer pattern to automatically execute checks on a schedule.

Overview

Execution Flow:

CovenantSpecification (active=true)
    ↓
Producer Task (scheduled) → creates CovenantExecution.NEW
    ↓
Consumer Task (scheduled) → processes NEW → saves Results
    ↓
CovenantResult (CLEAN / VIOLATION / EXCEPTION)

Key Components:

  • CovenantExecutionProducerTask — scheduled task that creates executions

  • CovenantExecutionProducer — business logic responsible for creation

  • CovenantExecutionConsumer — scheduled task that processes executions

  • CovenantExecutionService — performs checks and persists results

Producer Task

The Producer creates CovenantExecution records with status NEW for further processing.

Configuration
# application.properties
covenant.execution.producer.initialDelay=PT1M
covenant.execution.producer.fixedDelay=PT5M

Properties:

Property Description

initialDelay

Delay before the first execution after application startup. Format: ISO-8601 Duration (PT1M = 1 minute).

fixedDelay

Interval between the completion of the previous run and the start of the next one. Recommended: PT5M (5 minutes).

Key Points:

  • Idempotent — repeated runs do not create duplicates (checked via exists)

  • Period-based — only one execution per period

  • REQUIRES_NEW — independent transaction for each specification

  • Synchronous loadgetSync() ensures consistency

Consumer Task

The Consumer processes executions with status NEW and saves the results.

Configuration
# application.properties
covenant.execution.consumer.initialDelay=PT2M
covenant.execution.consumer.fixedDelay=PT1M

Properties:

Property Description

initialDelay

Delay before the first run. Should be greater than the Producer’s delay to allow time for execution creation.

fixedDelay

Interval between runs. Recommended: PT1M (1 minute) for fast processing.

Processing Strategy:

  • One at a time — processes one execution at a time

  • FIFO orderORDER BY id DESC LIMIT 1 (can be changed to ASC)

  • Separate transaction — each execution runs in its own transaction

  • Fail-safe — failure in one execution does not block others

21.5. Best Practices

Guidelines for designing, implementing, and operating the Covenant Monitoring System.

CovenantSpecification Design
Naming Conventions
  • Descriptive names — use “Borrower Income Stability > 80%” instead of “Income Check”

  • Include thresholds — explicitly state numeric thresholds in names

  • Subject prefix — start with “Borrower”, “Guarantor”, or “Collateral”

  • Version suffix — add “v2”, “Q1 2025”, etc., for multiple versions

Examples:

Good examples:

  • Borrower Monthly Income > 80% of Baseline

  • Guarantor Active Status Check

  • Collateral LTV < 80%

  • Borrower DTI < 40% (Revised Q1 2025)

Bad examples:

  • {bad} Inc Check

  • {bad} GUAR_STS

  • {bad} LTV

Expression Design
  • Keep it simple — under 20 lines, one main logical condition

  • Null-safe first — check for null at the beginning

  • Use constants — avoid magic numbers

  • Comment thresholds — explain where threshold values come from

  • Return explicit boolean — always use return true/false

  • Handle missing anchored — define behavior when the baseline is null

  • Test all edge cases — null, zero, and negative values

Good example:

// Borrower monthly income must not drop below 80% of baseline
// Threshold agreed with Risk Management on 2025-01-15

if (anchored == null) {
    return true;  // Skip check if baseline not set
}

if (metric == null || metric <= 0) {
    return false;  // No income data = violation
}

var THRESHOLD = 0.8;  // 20% drop allowed
return metric >= anchored * THRESHOLD;

Bad example:

// Don’t do this
return metric >= anchored * 0.8;  // What if anchored is null?
CovenantHolderResolver Implementation
Resolver Design
  • Single responsibility — one resolver per holder type

  • Immutable subjects list — use List.of() for getCovenantSubjects()

  • Efficient resolveTargets — use indexed queries

  • Filter inactive — exclude cancelled or closed entities

  • Handle lazy loading — use JOIN FETCH when needed

Good example:

@Override
public Stream<ExampleCredit> resolveTargets(CovenantSpecification specification) {
    // Efficient query with filtering
    return specification.getAdditives().stream()
        .flatMap(additiveId -> creditRepository
            .findByAdditiveIdAndStatusIn(
                additiveId,
                List.of(CreditStatus.ACTIVE, CreditStatus.GRACE_PERIOD)
            )
            .stream()
        );
}

Bad example:

@Override
public Stream<ExampleCredit> resolveTargets(CovenantSpecification specification) {
    // Inefficient — loads all credits
    return creditRepository.findAll().stream()
        .filter(c -> specification.getAdditives().contains(c.getAdditive().getId()));
}
Subject Extraction
  • Return empty list — never return null when no subjects exist

  • Filter active only — exclude deleted or inactive entities

  • Handle relationships — use proper JPA fetching

  • Cache if possible — subjects rarely change

  • Log unexpected states — when subjects are missing but expected

21.6. Anti-Patterns

Common mistakes when working with the Covenant Monitoring System and how to avoid them.

Expression Anti-Patterns
Not Checking Null Values

Problem: The expression throws a NullPointerException instead of returning VIOLATION.

Bad example:

// Dangerous — if anchored == null, this will throw an exception
return metric >= anchored * 0.8;

Why it’s bad:

  • {bad} Creates a CovenantResult with state = EXCEPTION

  • {bad} Masks the real violation

  • {bad} Makes problem analysis harder

  • {bad} Requires manual intervention

Good example:

// Safe — explicit null check
if (anchored == null) {
    return true;  // Or false, depending on business logic
}

if (metric == null) {
    return false;  // No data = violation
}

return metric >= anchored * 0.8;
CovenantHolderResolver Anti-Patterns
Loading All Entities

Problem: The resolver loads all entities without any filtering.

Bad example:

@Override
public Stream<ExampleCredit> resolveTargets(CovenantSpecification specification) {
    // Loads ALL credits from the database
    return creditRepository.findAll().stream()
        .filter(c -> c.getStatus() == CreditStatus.ACTIVE);
}

Why it’s bad:

  • {bad} Huge memory footprint

  • {bad} Very slow on large datasets

  • {bad} Execution timeouts

  • {bad} Risk of OutOfMemoryError when scaling

Good example:

@Override
public Stream<ExampleCredit> resolveTargets(CovenantSpecification specification) {
    // Efficient query with filtering at the DB level
    return specification.getAdditives().stream()
        .flatMap(additiveId -> creditRepository
            .findByAdditiveIdAndStatus(additiveId, CreditStatus.ACTIVE)
            .stream()
        );
}
Using REGULAR Instead of ANCHORED

Problem: Attempting to compare two REGULAR metrics instead of REGULAR vs ANCHORED.

Bad example:

CovenantSpecification:
Metric Mapping: participant_monthly_income (REGULAR)
Anchored Metric Mapping: null
Expression:
// Tries to compare with a previous value
// But there is no anchored metric!
return metric >= previousValue * 0.8;  // previousValue is unknown

Why it’s bad:

  • {bad} No baseline for comparison

  • {bad} Expression has no access to history

  • {bad} anchored = null → check is skipped

Good example:

CovenantSpecification:
Metric Mapping: participant_monthly_income (REGULAR)
Anchored Metric Mapping: participant_monthly_income (ANCHORED)
Expression:
if (anchored == null) {
    return true;
}
return metric >= anchored * 0.8;

21.7. Production Checklist

Final checklist before launching the Covenant Monitoring System in production.

CovenantSpecification Configuration
  • {todo} Specification has a clear, descriptive name

  • {todo} Subject key matches the keys defined in the resolver

  • {todo} Metric mapping exists and is active

  • {todo} Expression tested with real data via the UI tester

  • {todo} Expression handles all null cases

  • {todo} Periodicity matches the frequency of data updates

  • {todo} DateTimeExecution is set correctly

  • {todo} Product additives selected properly

  • {todo} Only one active version (unless A/B testing)

  • {todo} Expression < 20 lines of code

  • {todo} Threshold values documented (e.g., source of 0.8, 0.4, etc.)

CovenantHolderResolver Implementation
  • {todo} Resolver is registered as a Spring @Component

  • {todo} resolveTargets() uses indexed queries

  • {todo} resolveTargets() filters only active/relevant entities

  • {todo} getCovenantSubjects() returns an immutable List

  • {todo} Subject suppliers return List (not null) for empty collections

  • {todo} Subject suppliers use eager fetch or JOIN FETCH

  • {todo} getCovenantSpecifications() filters by active=true

  • {todo} No N+1 query problems during subject extraction

  • {todo} Resolver covered by unit tests

MetricMapping Configuration
  • {todo} MetricMapping has a clear, descriptive name

  • {todo} Entity type matches the subject class

  • {todo} Expression returns a consistent type

  • {todo} Expression handles DataUnavailableException

  • {todo} Fallback chain defined (external → manual → null)

  • {todo} Expression tested through the UI

  • {todo} Feature Store integration configured

  • {todo} DataSource timeout properly set

  • {todo} MetricMapping versioned if modified

  • {todo} Active = true for all used mappings

Expression Validation
  • {todo} Checks for null before using anchored

  • {todo} Checks for null before using metric

  • {todo} Returns explicit boolean (true/false)

  • {todo} No magic numbers present

  • {todo} Uses constants with meaningful names

  • {todo} Comments explain threshold values

  • {todo} Tested for edge cases (null, zero, negative)

  • {todo} Works for all subject types

ANCHORED Metrics Setup
  • {todo} ANCHORED metrics set during disbursement/activation

  • {todo} calculateAnchored() called at the correct lifecycle event

  • {todo} Manual update via UI works correctly

  • {todo} Update requires approval (role-based access)

  • {todo} No automated scheduled updates

  • {todo} ANCHORED MetricMapping exists for all related specifications

Scheduled Tasks Configuration
  • {todo} Producer initialDelay and fixedDelay configured

  • {todo} Consumer initialDelay and fixedDelay configured

  • {todo} Consumer initialDelay > Producer initialDelay

  • {todo} SchedulerLock lockAtMostFor and lockAtLeastFor correctly configured

UI Integration
  • {todo} Covenant result tabs added to holder entities

  • {todo} ANCHORED metric tab added to holder entities

  • {todo} Calculate Covenant action available

  • {todo} Update ANCHORED metric action available

  • {todo} Resolve action works for VIOLATION states

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

22.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, HasCovenant {

    @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

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

22.3. Credit Operations Framework

Built-in Operation Types

The loan module provides standard operation types that work with any credit implementation:

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

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

Snapshot Management

Credit snapshots provide point-in-time state tracking:

// Credit snapshot contains:
public class CreditSnapshot {
    private LocalDate date;           // Snapshot date
    private CreditStatus status;      // Credit status at this date
    private Debt debt;               // Account balances at this date
}

22.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" />

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

22.7. Extending the Credit System

Creating New Credit Types

To implement different lending products:

  1. Extend Credit Entity: Create new entity with specific discriminator value

  2. Define Condition Structure: Create condition entity with product-specific terms

  3. Implement Operations: Create custom payment and charge operation types

  4. Configure Account Types: Define debt account structure for the product

  5. Create Controllers: Implement UI controllers and actions

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

Customization Examples
Auto Loan Implementation
@Entity
@DiscriminatorValue("3")
public class AutoLoan extends Credit {

    @OneToOne
    private VehicleApplication application;

    @OneToOne
    private AutoLoanCondition condition;

    @Column
    private String vehicleVin;

    @Column
    private MonetaryAmount vehicleValue;
}
Credit Card Implementation
@Entity
@DiscriminatorValue("4")
public class CreditCard extends Credit {

    @Column
    private MonetaryAmount creditLimit;

    @Column
    private BigDecimal aprRate;

    @Column
    private MonetaryAmount availableCredit;

    @Column
    private LocalDate statementDate;
}

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

23. 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 example project demonstrates one possible implementation of credit operations. Real-world implementations may differ significantly based on business requirements, regulatory needs, and the types of financial products being offered.

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

23.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:

  1. Synchronization Phase: PreCalculateSynchronizer implementations (like AccrualOperationService) ensure all necessary operations exist

  2. Date Range Processing: The system processes each date from the start date to the target date

  3. Daily Operation Processing: For each date, operations are sorted by their getOrder() value and processed sequentially

  4. Snapshot Creation: After processing all operations for a date, a CreditSnapshot is created and stored

  5. 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:

  1. Handler Discovery: The system finds the appropriate CreditOperationHandler for each operation type

  2. Snapshot Application: Each handler modifies the current Snapshot to reflect the operation’s effect

  3. Debt Tracking: The system tracks how each operation changes the debt balances

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

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

Operation Status Lifecycle

Operations follow a defined status lifecycle:

PENDING → APPROVED → (Applied to Credit)
    ↓
DECLINED → (Rejected, not applied)
    ↓
CANCELED → (Removed from processing)

23.4. Implementing Custom Operations

To implement custom operations, follow the same patterns demonstrated in the example project. You’ll need to:

  1. Create the Operation Entity - Extend CreditOperation with your specific fields and behavior

  2. Implement the Operation Handler - Create a service that implements CreditOperationHandler<YourOperation>

  3. Configure the Handler - Add the handler as a Spring bean in your configuration

  4. Define Processing Order - Set appropriate getOrder() and isEndDayOperation() values

The example project’s ChargeOperation and ChargeOperationService demonstrate the simplest implementation pattern, while AccrualOperation and AccrualOperationService show more complex synchronization behavior.

23.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. The payment distribution order is configured in CreditCalculationConfiguration:

    @Bean
    CreditPaymentOperationHandler 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 the PRINCIPAL 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
  1. Event Detection: When payments or past due operations are processed, the synchronizer identifies dates needing accrual calculations

  2. Accrual Creation: AccrualOperation entities are automatically created for these dates

  3. Engine Calculation: During credit calculation, accrual engines compute the exact amounts based on outstanding balances, rates, and time periods

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

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

23.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 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 Operation Handler
@Bean
AccrualOperationService accrualOperationService() {
    return new AccrualOperationService();
}

The accrual service coordinates with accrual engines to calculate time-based charges.

Accrual Engine Configuration

Accrual engines handle specific types of time-based calculations:

Interest Accrual Engine
@Bean
InterestAccrualEngine interestAccrualEngine() {
    return new InterestAccrualEngine();
}

Calculates interest on the PRINCIPAL balance and adds it to the INTEREST account.

Late Fee Accrual Engine
@Bean
LateFeeAccrualEngine lateFeeAccrualEngine() {
    return new LateFeeAccrualEngine();
}

Calculates late fees on past due balances and adds them to the LATE_FEE account.

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
  1. Centralized Configuration: Keep all operation configuration in a single class for maintainability

  2. Meaningful Constants: Use descriptive names for account types and statuses

  3. Documented Relationships: Clearly document how accounts relate to each other

  4. Environment-Specific Beans: Use Spring profiles or conditions for product-specific configurations

  5. Validation: Implement configuration validation to catch setup errors early

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

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

23.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
  1. Credit Creation: A new credit is created with defined terms (principal amount, interest rate, payment schedule)

  2. Principal Disbursement: A ChargeOperation adds the loan principal to the PRINCIPAL account

  3. Time Progression: As time passes, the calculation engine processes each day

  4. Interest Accrual: AccrualOperationService automatically creates AccrualOperation entities for interest calculation

  5. Balance Updates: Interest is calculated and added to the INTEREST account

Key Insights
  • Automatic Synchronization: Accrual operations are created automatically when needed

  • Daily Processing: The calculation engine processes operations chronologically

  • Time-based Calculations: Interest accrues based on outstanding principal and elapsed time

From the Test Case

The chargeOperation() test in CalculationTest demonstrates this pattern:

Credit Start → Charge Principal → Calculate to Today → Verify Interest Accrued

The test verifies that interest is correctly calculated using the formula: principal × (rate / 100) × (days / 360)

Scenario 2: Payment Processing and Distribution

This scenario shows how borrower payments are processed and distributed across debt accounts.

The Flow
  1. Outstanding Debt: Credit has balances in multiple accounts (principal, interest, fees)

  2. Payment Received: An ExampleCreditPayment operation is created

  3. Payment Distribution: The payment handler applies the payment according to the configured waterfall

  4. Balance Updates: Debt accounts are reduced according to priority order

  5. 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
  1. Payment Due Date: A scheduled payment date arrives

  2. Insufficient Payment: Borrower makes no payment or insufficient payment

  3. Past Due Operation: System automatically creates PastDueOperation

  4. Account Transfers: Current debt moves to past due accounts

  5. Late Fee Accrual: LateFeeAccrualEngine begins calculating fees on past due amounts

  6. 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:

  1. Scan for Trigger Events: Finds all payment and past due operations

  2. Identify Required Dates: Determines which dates need accrual calculations

  3. Create Missing Operations: Adds AccrualOperation entities for missing dates

  4. Cancel Unnecessary Operations: Removes accrual operations for dates without triggers

Result

The system ensures accurate interest calculations even when:

  • Payments are made on irregular dates

  • Past due events change the debt structure

  • Operations are added or modified after the fact

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

3. Business Rule Flexibility

The framework accommodates complex business rules:

  • Different payment priorities for different products

  • Time-sensitive processing (end-of-day vs intraday)

  • Automatic synchronization of dependent operations

4. Audit and Traceability

Every operation maintains complete audit trails:

  • Who created the operation and when

  • What the operation changed (debt account effects)

  • Why the operation was created (business event triggers)

Testing Real-World Scenarios

The example project’s CalculationTest demonstrates how to test these scenarios:

  1. Setup Realistic Conditions: Create credits with proper terms and schedules

  2. Apply Operations in Sequence: Mirror real-world event timing

  3. Verify All Effects: Check not just primary changes but secondary effects

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

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

23.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:

1. Credit Lifecycle Testing

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());
2. Operation Interaction Testing

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());
}
3. Accrual Calculation Testing

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());
}
4. Past Due Processing Testing

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));
    });
}
Calculation Execution
public void calculate(UUID creditId, LocalDate from, LocalDate today) {
    transactionTemplateBuilder.requiresNew().executeWithoutResult(status -> {
        calculationService.calculate(creditId, from, today);
    });
}
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());
Accrual Verification
Debt accruals = accrualService.calculateCurrentAccurals(credit);
assertEquals(expectedLateFee, accruals.getAccount(LATE_FEE).get());
assertEquals(2, accruals.getAccounts().keySet().size()); // Verify account count
State Verification
assertEquals(expectedDate, credit.getActualSnapshot().getDate());
assertEquals(calculationDate, credit.getCalculationDate());
assertTrue(credit.getActualSnapshot().getDebt().getTotal().isEmpty()); // For zero balance
Best Practices for Operation Testing
  1. Use Realistic Data: Test with actual financial amounts and rates that reflect real-world scenarios

  2. Test Operation Sequences: Verify that operations work correctly when applied in different orders

  3. Verify Time-based Calculations: Ensure accruals calculate correctly across different time periods

  4. Test Edge Cases: Include scenarios like overpayments, zero balances, and boundary conditions

  5. Use Proper Transactions: Each operation should be in its own transaction to mirror production behavior

  6. Verify All Accounts: Check not just the primary effects but also secondary account impacts

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

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

24.1. The Basic Concept

Think of payment transactions as a receipt system:

  1. Customer pays → Create transaction record (like writing a receipt)

  2. Process payment → Send to bank/payment processor

  3. Payment succeeds → Create credit operation (update the loan balance)

  4. Keep records → Maintain complete audit trail

This two-step process (transaction → operation) ensures every balance change has a real-world payment behind it.

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

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

24.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:

  1. Transaction Creation: BorrowerTransaction entity created with payment details

  2. Gateway Submission: PaymentTransactionService submits to appropriate gateway

  3. Async Processing: Transaction processed asynchronously to avoid blocking

  4. Status Updates: Transaction status updated based on gateway response

  5. Success Handling: BorrowerTransactionService.handle() creates credit operation

  6. Error Handling: Failed transactions trigger appropriate error responses

24.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:

Real-Time API Gateway Pattern

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

SOAP Web Service Gateway Pattern

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

Batch File Gateway Pattern

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.

24.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);
}
Access Control
@PreAuthorize("hasPermission(#method, 'USE')")
public TransactionResult processPayment(PaymentMethod method, MonetaryAmount amount) {
    // Role-based access control for payment method usage
}

24.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:

  1. Transaction Record - Shows the real-world payment attempt

  2. Gateway Response - Stored in transaction.trace field

  3. Operation Link - transaction.operation points to the credit operation

  4. Credit Update - Operation appears in credit’s operation list

This means you can always trace a balance change back to the original payment.

24.8. Processing Payments Asynchronously

Payment processing happens in the background so users don’t have to wait. When someone submits a payment, the system:

  1. Creates transaction record - Saves it immediately

  2. Returns to user - Shows "processing" message

  3. Processes in background - Calls payment gateway

  4. Updates status - Success or failure

  5. 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
Monitoring

Since processing happens in background, you need to monitor:

  • Failed transactions - Show in admin dashboard for investigation

  • Stuck transactions - Alert if too many stay "IN_PROGRESS"

  • Gateway errors - Monitor payment processor uptime

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

24.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();
}

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

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

Security Best Practices
  • Token Management - Secure handling of payment method tokens

  • Credential Security - Proper storage and rotation of gateway credentials

  • Data Minimization - Store only necessary payment data

  • Audit Logging - Complete logging of all payment activities

Performance Optimization
  • Async Processing - Non-blocking transaction processing

  • Connection Pooling - Efficient gateway connection management

  • Caching - Cache gateway configuration and metadata

  • Batch Processing - Group transactions where possible (NACHA)

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