Compare commits

..
Author SHA1 Message Date
sonaliandClaude Sonnet 5.5 e95e981bcd feat(loan-products): Gold and Warehouse Receipt product fields with validation
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
- Liquibase 0604 to 0607: columns on m_product_loan_advance_ext for Loan against Gold (LTV cap, purity,
  rate source, weights, appraiser rule, packet prefix, auction notice, revaluation on NPA and its charge,
  partial release, third-party gold, disbursement mode and cash limit, LTV breach alert, auction reserve
  price, redeem before auction) and for Warehouse Receipt (receipt type, WDRA and assay rules, tenure
  within receipt validity, repository pledge, commodity groups, valuation price source, max loan per
  quintal, stock inspection, LTV alert, partial release, disposal notice and reserve price).
- LoanProductAdvanceExt and LoanProductAdvanceExtService: the new fields are accepted, saved and read
  back (so Edit and View can use them).
- Validation on the merged state: the LTV cap must cover the LTV the margin gives, the breach alert may
  not sit below the cap, tenure is at most 12 months, redeem-before-sale cannot be switched off, a gold
  cash disbursement limit is at most 20,000, a warehouse needs a WDRA-registered warehouse and an assay
  certificate, and the receipt type, commodity groups and price source must be known values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:55:48 +05:30
sonali 0191461102 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into feature/advanceLoanProduct
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
2026-10-16 10:36:17 +05:30
sonali 4c40784a0e implemented the Edit prodict functionality for the RD,FD,DDS,LIC 2026-10-16 10:33:57 +05:30
niteshandClaude Sonnet 5.5 6be32272c1 feat(cashier): maker-checker on cash transfer
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and
posted only when a different manager or head cashier approves it.

- POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same
  checks run again at approval, since the vault can change in between.
- Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a
  reason; the maker cannot approve their own request.
- An identical request from the same maker is refused while the first is still pending.
- The direct POST /cashtransfer is closed so the approval cannot be bypassed.
- Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:22 +05:30
niteshandClaude Sonnet 5.5 b47a34b5ef feat(vouchers): voucher correction for cash deposits posted to the wrong account
A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back:
the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a
manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the
same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it.

- VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction,
  approve/reject correction, close request, voided transactions.
- The wrong account's balance is checked when the cashier enters the correction and again at approval.
- A cashier whose customer has already used the money closes the request and deposits separately.
- Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for
  a manager or head cashier other than the maker.
- Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher,
  since the correction status is read from m_voucher_correction alone.
- Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status
  and leave a revoked voucher out of their totals, so its cash is not counted twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:21 +05:30
niteshandClaude Sonnet 5.5 04d1eb20f0 feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same
maker-checker path as a savings withdrawal:
- the request carries gl_account_id, contra_gl_id, office_id and currency_code
  (new nullable columns, changeset 0600) instead of a savings account;
- at submit it is validated: the paying GL must be a mapped cash vault GL and the
  slip's own GL must not be a cash GL;
- at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry;
- the cashier pays it against its token, and the denomination and voucher lines are
  recorded under the journal entry's id. KYC debit checks are skipped for GL requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:01 +05:30
43 changed files with 2676 additions and 61 deletions
@@ -113,7 +113,7 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
.value(incomeFromRecoveryAccountId).notNull().integerGreaterThanZero();
.value(incomeFromRecoveryAccountId).ignoreIfNull().integerGreaterThanZero();
final Long writeOffAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
@@ -128,7 +128,7 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
}
@@ -46,17 +46,27 @@ public class DelinquencyRange extends AbstractAuditableWithUTCDateTimeCustom<Lon
@Column(name = "max_age_days", nullable = true)
private Integer maximumAgeDays;
/**
* Which ECL stage (1, 2 or 3) this range maps to under the RBI 2026 Directions. Nullable for ranges created
* before this column existed; a range with no stage is treated as informational only and does not drive
* staging or provisioning.
*/
@Column(name = "stage", nullable = true)
private Integer stage;
@Version
private Long version;
protected DelinquencyRange(@NotNull String classification, @NotNull Integer minimumAgeDays, Integer maximumAgeDays) {
protected DelinquencyRange(@NotNull String classification, @NotNull Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
this.classification = classification;
this.minimumAgeDays = minimumAgeDays;
this.maximumAgeDays = maximumAgeDays;
this.stage = stage;
}
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge) {
return new DelinquencyRange(classification, minimumAge, maximumAge);
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge,
Integer stage) {
return new DelinquencyRange(classification, minimumAge, maximumAge, stage);
}
}
@@ -53,12 +53,20 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
paymentTypeRepositoryWrapper, codeValueRepository);
}
/** Only the GLs the request names are mapped; the bank maps nine and leaves the other placeholders unmapped. */
private boolean isMapped(final JsonElement element, final String paramName) {
return this.fromApiJsonHelper.extractLongNamed(paramName, element) != null;
}
/***
* Set of abstractions for saving Loan Products to GL Account Mappings
***/
public void saveLoanToAssetAccountMapping(final JsonElement element, final String paramName, final Long productId,
final int placeHolderTypeId) {
if (!isMapped(element, paramName)) {
return;
}
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.ASSET, PortfolioProductType.LOAN);
}
@@ -72,16 +80,25 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
public void saveLoanToIncomeAccountMapping(final JsonElement element, final String paramName, final Long productId,
final int placeHolderTypeId) {
if (!isMapped(element, paramName)) {
return;
}
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.INCOME, PortfolioProductType.LOAN);
}
public void saveLoanToExpenseAccountMapping(final JsonElement element, final String paramName, final Long productId,
final int placeHolderTypeId) {
if (!isMapped(element, paramName)) {
return;
}
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.EXPENSE, PortfolioProductType.LOAN);
}
public void saveLoanToLiabilityAccountMapping(final JsonElement element, final String paramName, final Long productId,
final int placeHolderTypeId) {
if (!isMapped(element, paramName)) {
return;
}
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.LIABILITY, PortfolioProductType.LOAN);
}
@@ -29,6 +29,7 @@ public final class DelinquencyApiConstants {
public static final String RANGES_PARAM_NAME = "ranges";
public static final String MINIMUMAGEDAYS_PARAM_NAME = "minimumAgeDays";
public static final String MAXIMUMAGEDAYS_PARAM_NAME = "maximumAgeDays";
public static final String STAGE_PARAM_NAME = "stage";
public static final String LOCALE_PARAM_NAME = "locale";
}
@@ -35,5 +35,6 @@ public class DelinquencyRangeRequest implements Serializable {
private String classification;
private Integer minimumAgeDays;
private Integer maximumAgeDays;
private Integer stage;
private String locale;
}
@@ -34,13 +34,15 @@ public class DelinquencyRangeData implements Serializable {
private String classification;
private Integer minimumAgeDays;
private Integer maximumAgeDays;
/** ECL stage (1, 2 or 3) this range maps to under the RBI 2026 Directions; null on ranges predating this field. */
private Integer stage;
public static DelinquencyRangeData instance(String classification, Integer minimumAgeDays, Integer maximumAgeDays) {
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays);
public static DelinquencyRangeData instance(String classification, Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays, stage);
}
public static DelinquencyRangeData reference(Long id) {
return new DelinquencyRangeData(id, "", 0, 0);
return new DelinquencyRangeData(id, "", 0, 0, null);
}
}
@@ -305,7 +305,7 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
throw new DelinquencyRangeInvalidAgesException(errorMessage, data.getMinimumAgeDays(), data.getMaximumAgeDays());
}
DelinquencyRange newDelinquencyRange = DelinquencyRange.instance(data.getClassification(), data.getMinimumAgeDays(),
data.getMaximumAgeDays());
data.getMaximumAgeDays(), data.getStage());
return repositoryRange.saveAndFlush(newDelinquencyRange);
} else {
throw new PlatformDataIntegrityException("error.msg.data.integrity.issue.entity.duplicated",
@@ -327,6 +327,10 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
delinquencyRange.setMaximumAgeDays(data.getMaximumAgeDays());
changes.put(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, data.getMaximumAgeDays());
}
if (!java.util.Objects.equals(data.getStage(), delinquencyRange.getStage())) {
delinquencyRange.setStage(data.getStage());
changes.put(DelinquencyApiConstants.STAGE_PARAM_NAME, data.getStage());
}
if (!changes.isEmpty()) {
delinquencyRange = repositoryRange.saveAndFlush(delinquencyRange);
}
@@ -56,7 +56,8 @@ public class DelinquencyRangeParseAndValidator extends ParseAndValidator {
jsonHelper.checkForUnsupportedParameters(element,
List.of(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME,
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.LOCALE_PARAM_NAME));
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.STAGE_PARAM_NAME,
DelinquencyApiConstants.LOCALE_PARAM_NAME));
final String localeValue = jsonHelper.extractStringNamed(DelinquencyApiConstants.LOCALE_PARAM_NAME, element);
dataValidator.reset().parameter(DelinquencyApiConstants.LOCALE_PARAM_NAME).value(localeValue).notBlank();
@@ -65,13 +66,15 @@ public class DelinquencyRangeParseAndValidator extends ParseAndValidator {
final String classification = jsonHelper.extractStringNamed(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, element);
final Integer minimumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME, element, locale);
final Integer maximumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, element, locale);
final Integer stage = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.STAGE_PARAM_NAME, element, locale);
dataValidator.reset().parameter(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME).value(classification).notBlank();
dataValidator.reset().parameter(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME).value(minimumAge).notBlank()
.integerGreaterThanNumber(0);
dataValidator.reset().parameter(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME).value(maximumAge).ignoreIfNull()
.integerGreaterThanNumber(0);
dataValidator.reset().parameter(DelinquencyApiConstants.STAGE_PARAM_NAME).value(stage).ignoreIfNull().inMinMaxRange(1, 3);
return dataValidator.hasError() ? null : DelinquencyRangeData.instance(classification, minimumAge, maximumAge);
return dataValidator.hasError() ? null : DelinquencyRangeData.instance(classification, minimumAge, maximumAge, stage);
}
}
@@ -137,6 +137,141 @@ public class LoanProductAdvanceExt {
@Column(name = "npa_trigger")
private String npaTrigger;
/** Months after disbursement before overdue days (SMA) start counting: Advances 9, Loans (EMI) 1. */
@Column(name = "sma_start_after_months")
private Integer smaStartAfterMonths;
/** CC/OD: stock statement older than this many days makes drawings an NPA trigger (RBI: 90). */
@Column(name = "stock_statement_validity_days")
private Integer stockStatementValidityDays;
// ---- Loan against Gold (pledged ornaments) ---------------------------
@Column(name = "gold_max_ltv_pct")
private BigDecimal goldMaxLtvPct;
@Column(name = "gold_min_purity_carat")
private Integer goldMinPurityCarat;
@Column(name = "gold_rate_source")
private String goldRateSource;
@Column(name = "gold_max_per_gram")
private BigDecimal goldMaxPerGram;
@Column(name = "gold_min_net_weight_g")
private BigDecimal goldMinNetWeightG;
@Column(name = "gold_max_net_weight_per_customer_g")
private BigDecimal goldMaxNetWeightPerCustomerG;
@Column(name = "gold_allowed_items")
private String goldAllowedItems;
@Column(name = "gold_appraiser_required")
private Boolean goldAppraiserRequired;
@Column(name = "gold_valuation_certificate_required")
private Boolean goldValuationCertificateRequired;
@Column(name = "gold_max_valuation_per_appraiser")
private BigDecimal goldMaxValuationPerAppraiser;
@Column(name = "gold_packet_prefix")
private String goldPacketPrefix;
@Column(name = "gold_auction_notice_days")
private Integer goldAuctionNoticeDays;
@Column(name = "gold_revalue_on_npa")
private Boolean goldRevalueOnNpa;
@Column(name = "gold_revaluation_charge_applicable")
private Boolean goldRevaluationChargeApplicable;
@Column(name = "gold_partial_release_allowed")
private Boolean goldPartialReleaseAllowed;
@Column(name = "gold_third_party_allowed")
private Boolean goldThirdPartyAllowed;
@Column(name = "gold_disbursement_mode")
private String goldDisbursementMode;
@Column(name = "gold_cash_disbursement_limit")
private BigDecimal goldCashDisbursementLimit;
@Column(name = "gold_ltv_breach_alert_pct")
private BigDecimal goldLtvBreachAlertPct;
@Column(name = "gold_auction_reserve_pct")
private BigDecimal goldAuctionReservePct;
@Column(name = "gold_redeem_before_auction")
private Boolean goldRedeemBeforeAuction;
// ---- Loan against Warehouse Receipt (pledged commodity) ----------------
@Column(name = "wr_max_ltv_pct")
private BigDecimal wrMaxLtvPct;
@Column(name = "wr_receipt_type")
private String wrReceiptType;
@Column(name = "wr_wdra_registered")
private Boolean wrWdraRegistered;
@Column(name = "wr_assay_certificate_required")
private Boolean wrAssayCertificateRequired;
@Column(name = "wr_tenure_within_receipt_validity")
private Boolean wrTenureWithinReceiptValidity;
@Column(name = "wr_pledge_marked_before_disbursement")
private Boolean wrPledgeMarkedBeforeDisbursement;
@Column(name = "wr_commodity_groups")
private String wrCommodityGroups;
@Column(name = "wr_valuation_price_source")
private String wrValuationPriceSource;
@Column(name = "wr_max_loan_per_quintal")
private BigDecimal wrMaxLoanPerQuintal;
@Column(name = "wr_stock_inspection_months")
private Integer wrStockInspectionMonths;
@Column(name = "wr_ltv_breach_alert_pct")
private BigDecimal wrLtvBreachAlertPct;
@Column(name = "wr_partial_release_allowed")
private Boolean wrPartialReleaseAllowed;
@Column(name = "wr_disposal_notice_days")
private Integer wrDisposalNoticeDays;
@Column(name = "wr_disposal_reserve_pct")
private BigDecimal wrDisposalReservePct;
@Column(name = "wr_redeem_before_disposal")
private Boolean wrRedeemBeforeDisposal;
// ---- common eligibility & document checklist (Advances and Loans) ----
@Column(name = "elig_member_required")
private Boolean memberRequired;
@Column(name = "elig_guarantor_must_be_member")
private Boolean guarantorMustBeMember;
@Column(name = "elig_income_multiple")
private BigDecimal incomeMultiple;
@Column(name = "elig_min_vintage_months")
private Integer minVintageMonths;
/** Comma separated document codes the borrower must submit for this product. */
@Column(name = "required_documents", length = 1000)
private String requiredDocuments;
@Column(name = "interest_after_npa")
private String interestAfterNpa;
@@ -464,6 +599,43 @@ public class LoanProductAdvanceExt {
@Column(name = "loan_balance_transfer_out_allowed")
private Boolean balanceTransferOutAllowed;
// ---- SICR & LGD (RBI 2026 ECL framework; floor % itself lives in the Prudential Floor master, not here) ----
@Column(name = "sicr_days_threshold")
private Integer sicrDaysThreshold;
@Column(name = "sicr_revolving_days_threshold")
private Integer sicrRevolvingDaysThreshold;
@Column(name = "sicr_rating_downgrade_notches")
private Integer sicrRatingDowngradeNotches;
@Column(name = "sicr_pricing_increase_trigger_pct")
private BigDecimal sicrPricingIncreaseTriggerPct;
@Column(name = "sicr_assessment_basis")
private String sicrAssessmentBasis;
@Column(name = "sicr_rebuttal_allowed")
private Boolean sicrRebuttalAllowed;
@Column(name = "npa_out_of_order_basis")
private String npaOutOfOrderBasis;
@Column(name = "crop_duration")
private String cropDuration;
@Column(name = "use_own_lgd_model")
private Boolean useOwnLgdModel;
@Column(name = "lgd_secured_pct")
private BigDecimal lgdSecuredPct;
@Column(name = "lgd_unsecured_pct")
private BigDecimal lgdUnsecuredPct;
@Column(name = "eligible_collateral_type")
private String eligibleCollateralType;
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
@@ -0,0 +1,41 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.prudentialfloor.data;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@ToString
@AllArgsConstructor
@Getter
@Setter
public class PrudentialFloorData implements Serializable {
private Long id;
private String productCategory;
private Integer stage;
private Integer durationFromDays;
private Integer durationToDays;
private BigDecimal securedFloorPct;
private BigDecimal unsecuredFloorPct;
}
@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.prudentialfloor.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* One row of the RBI 2026 ECL "prudential floor" table (Directions para 82): the minimum provisioning percentage
* a bank must hold for a given loan product category and stage. Seeded centrally from the regulation rather than
* entered per product - a loan product only ever declares its category ({@code loanProductCategory} on
* {@link org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt}) and its stage (via its
* Delinquency Bucket); the applicable floor % is always looked up here, never duplicated onto the product or the
* range.
*
* Stage 1 and Stage 2 rows apply regardless of duration ({@code durationFromDays}/{@code durationToDays} are
* null and {@code securedFloorPct} equals {@code unsecuredFloorPct}, since the Directions do not split those two
* stages by security). Stage 3 rows are further split by how long the exposure has been in Stage 3
* ({@code durationToDays} null means "and beyond") and by secured/unsecured portion.
*/
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "m_prudential_floor")
public class PrudentialFloor extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "product_category", nullable = false)
private String productCategory;
/** 1, 2 or 3. */
@Column(name = "stage", nullable = false)
private Integer stage;
@Column(name = "duration_from_days", nullable = true)
private Integer durationFromDays;
/** Null means open-ended ("after 4 years" etc.). */
@Column(name = "duration_to_days", nullable = true)
private Integer durationToDays;
@Column(name = "secured_floor_pct", nullable = false)
private BigDecimal securedFloorPct;
@Column(name = "unsecured_floor_pct", nullable = false)
private BigDecimal unsecuredFloorPct;
@Version
private Long version;
private PrudentialFloor(@NotNull String productCategory, @NotNull Integer stage, Integer durationFromDays, Integer durationToDays,
@NotNull BigDecimal securedFloorPct, @NotNull BigDecimal unsecuredFloorPct) {
this.productCategory = productCategory;
this.stage = stage;
this.durationFromDays = durationFromDays;
this.durationToDays = durationToDays;
this.securedFloorPct = securedFloorPct;
this.unsecuredFloorPct = unsecuredFloorPct;
}
public static PrudentialFloor instance(@NotNull String productCategory, @NotNull Integer stage, Integer durationFromDays,
Integer durationToDays, @NotNull BigDecimal securedFloorPct, @NotNull BigDecimal unsecuredFloorPct) {
return new PrudentialFloor(productCategory, stage, durationFromDays, durationToDays, securedFloorPct, unsecuredFloorPct);
}
/** True when {@code daysInStage} falls within this row's duration band (always true for a Stage 1/2 row). */
public boolean coversDuration(Integer daysInStage) {
if (this.durationFromDays == null) {
return true;
}
if (daysInStage == null) {
return false;
}
boolean aboveFrom = daysInStage >= this.durationFromDays;
boolean belowTo = this.durationToDays == null || daysInStage <= this.durationToDays;
return aboveFrom && belowTo;
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.prudentialfloor.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PrudentialFloorRepository extends JpaRepository<PrudentialFloor, Long> {
List<PrudentialFloor> findByProductCategoryAndStage(String productCategory, Integer stage);
List<PrudentialFloor> findByProductCategoryOrderByStageAscDurationFromDaysAsc(String productCategory);
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.prudentialfloor.mapper;
import java.util.List;
import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig;
import org.apache.fineract.portfolio.prudentialfloor.data.PrudentialFloorData;
import org.apache.fineract.portfolio.prudentialfloor.domain.PrudentialFloor;
import org.mapstruct.Mapper;
@Mapper(config = MapstructMapperConfig.class)
public interface PrudentialFloorMapper {
PrudentialFloorData map(PrudentialFloor source);
List<PrudentialFloorData> map(List<PrudentialFloor> sources);
}
@@ -79,6 +79,17 @@ public class MakercheckersApiResource {
}
@GET
@Path("/mine")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List my pending Maker Checker Entries", description = "Requests the requestor submitted that still await approval. Unlike the checker inbox it needs no checker permission, so a maker can see what is pending. Example: makercheckers/mine?entityName=LOANPRODUCT")
public List<AuditData> retrieveMyPendingCommands(@Context final UriInfo uriInfo, @BeanParam MakerCheckerRequest makerCheckerRequest) {
final SQLBuilder extraCriteria = getExtraCriteria(makerCheckerRequest);
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return readPlatformService.retrieveMyPendingEntries(extraCriteria, settings.isIncludeJson());
}
@GET
@Path("/searchtemplate")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -33,6 +33,9 @@ public interface AuditReadPlatformService {
List<AuditData> retrieveAllEntriesToBeChecked(SQLBuilder extraCriteria, boolean includeJson);
/** Requests the current user submitted that still await a checker, whatever the user's own checker permissions. */
List<AuditData> retrieveMyPendingEntries(SQLBuilder extraCriteria, boolean includeJson);
AuditData retrieveAuditEntry(Long auditId);
AuditSearchData retrieveSearchTemplate(String useType);
@@ -214,10 +214,17 @@ public class AuditReadPlatformServiceImpl implements AuditReadPlatformService {
return retrieveEntries("makerchecker", extraCriteria, " order by aud.id, mk.username", includeJson);
}
@Override
public List<AuditData> retrieveMyPendingEntries(final SQLBuilder extraCriteria, final boolean includeJson) {
extraCriteria.addCriteria("aud.status = ", 2);
extraCriteria.addCriteria("aud.maker_id = ", this.context.authenticatedUser().getId());
return retrieveEntries("makerchecker-mine", extraCriteria, " order by aud.id, mk.username", includeJson);
}
private List<AuditData> retrieveEntries(final String useType, final SQLBuilder extraCriteria, final String groupAndOrderBySQL,
final boolean includeJson) {
if ((!useType.equals("audit") && !useType.equals("makerchecker"))) {
if ((!useType.equals("audit") && !useType.equals("makerchecker") && !useType.equals("makerchecker-mine"))) {
throw new PlatformDataIntegrityException("error.msg.invalid.auditSearchTemplate.useType",
"Invalid Audit Search Template UseType: " + useType);
}
@@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -36,6 +37,7 @@ public class CashReportApiResource {
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService;
private final VoucherService voucherService;
private final Gson gson = new Gson();
/**
@@ -329,7 +331,18 @@ public class CashReportApiResource {
BigDecimal priorCreditTotal = BigDecimal.ZERO;
BigDecimal priorDebitTotal = BigDecimal.ZERO;
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
for (final Map<String, Object> row : rows) {
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
@@ -350,7 +363,7 @@ public class CashReportApiResource {
// was already earned, and the day it belongs to has been counted and signed off. Adding
// it to this day's totals would make them disagree with the ledger for either date.
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
@@ -57,9 +57,23 @@ public class CashTransferApiResource {
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
* {@link #postTransfer}.
*/
@POST
public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
"A cash transfer is raised as a request and posted when a manager approves it.");
}
/**
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
* when it is approved, since the vault can change in between.
*/
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
@@ -102,6 +116,9 @@ public class CashTransferApiResource {
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
if (dryRun) {
return "{}";
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
@@ -112,15 +129,9 @@ public class CashTransferApiResource {
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Recorded against the person who raised the request, not the one who approved it.
header.setPerformedBy(performedBy);
header.setPerformedByUsername(performedByUsername);
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
@@ -159,6 +170,171 @@ public class CashTransferApiResource {
return gson.toJson(result);
}
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
@POST
@Path("requests")
public String submitRequest(final String body) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
postTransfer(body, user.getId(), user.getUsername(), true);
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
// a second click would otherwise release the same cash twice once both were approved.
final Integer identical = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
REQUEST_PENDING, user.getId(), body);
if (identical != null && identical > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
"An identical cash transfer request from you is already waiting for approval.");
}
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final JsonArray denominations = json.getAsJsonArray("denominations");
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_cash_transfer_request
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
status, maker_id, maker_name, maker_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_PENDING);
return gson.toJson(result);
}
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
@GET
@Path("requests")
public String listRequests(@QueryParam("status") final String status,
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
final List<Object> params = new java.util.ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND maker_id = ?");
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
for (final Map<String, Object> row : rows) {
// The notes asked for, so the approver sees what they are releasing without opening anything.
final Object requestBody = row.remove("request_body");
row.put("denominations", requestBody == null ? new JsonArray()
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
}
return gson.toJson(rows);
}
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
@POST
@Path("requests/{id}/approve")
public String approveRequest(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "approved");
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
if (makerId != null && makerId.equals(user.getId())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
}
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
final int updated = jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
WHERE id = ? AND status = ?
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
transferId, id, REQUEST_PENDING);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
"This request was already actioned by another manager.");
}
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_APPROVED);
if (transferId != null) {
result.addProperty("cashTransferId", transferId);
}
if (postedJson.has("glWarning")) {
result.add("glWarning", postedJson.get("glWarning"));
}
return gson.toJson(result);
}
@POST
@Path("requests/{id}/reject")
public String rejectRequest(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "rejected");
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
final String reason = textOrNull(json, "reason");
if (reason == null || reason.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
}
jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
WHERE id = ? AND status = ?
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
reason.trim(), id, REQUEST_PENDING);
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_REJECTED);
return gson.toJson(result);
}
private static final String REQUEST_PENDING = "PENDING";
private static final String REQUEST_APPROVED = "APPROVED";
private static final String REQUEST_REJECTED = "REJECTED";
private Map<String, Object> loadRequest(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
}
return rows.get(0);
}
private void requirePending(final Map<String, Object> request, final String action) {
if (!REQUEST_PENDING.equals(request.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
}
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void requireChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
}
}
private static String textOrNull(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -48,6 +48,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -110,11 +111,20 @@ public class ClerkWithdrawalRequestApiResource {
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
// A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is
// paid from instead of a savings account; it is approved and disbursed exactly like any other slip.
final boolean isGl = "GL".equalsIgnoreCase(accountType);
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root);
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root);
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "Account number is required");
}
if (savingsAccountId == null) {
if (isGl) {
validateGlWithdrawalAccounts(glAccountId, contraGlId);
} else if (savingsAccountId == null) {
throw apiError(400, "Savings account id is required for a withdrawal request");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
@@ -124,7 +134,9 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(400, "Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, null);
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
if (!isGl) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -132,8 +144,9 @@ public class ClerkWithdrawalRequestApiResource {
INSERT INTO m_clerk_withdrawal_request
(account_no, account_type, savings_account_id, client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at,
gl_account_id, contra_gl_id, office_id, currency_code)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -142,7 +155,9 @@ public class ClerkWithdrawalRequestApiResource {
txnDate != null ? Date.valueOf(txnDate) : null,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now())
denominationData, activityLog, Timestamp.from(Instant.now()),
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
isGl ? (currencyCode != null ? currencyCode : "INR") : null
);
// Globally unique voucher number from the central m_voucher register
@@ -198,7 +213,8 @@ public class ClerkWithdrawalRequestApiResource {
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
+ " denomination_data, activity_log, submitted_at, actioned_at,"
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
+ " cashier_id, disbursed_by, disbursed_at, voucher_no,"
+ " gl_account_id, contra_gl_id, office_id, currency_code"
+ " FROM m_clerk_withdrawal_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -252,7 +268,8 @@ public class ClerkWithdrawalRequestApiResource {
payment_type_name, description, tok_no, txn_date, status,
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()
@@ -276,7 +293,8 @@ public class ClerkWithdrawalRequestApiResource {
txn_date, status, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -295,7 +313,8 @@ public class ClerkWithdrawalRequestApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
amount, payment_type_id, payment_type_name, description, tok_no, status,
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
ORDER BY submitted_at DESC LIMIT 1
@@ -455,13 +474,16 @@ public class ClerkWithdrawalRequestApiResource {
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
}
final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type"));
final Long savingsAccountId = record.get("savings_account_id") != null
? ((Number) record.get("savings_account_id")).longValue() : null;
// Checked again on approval, not only on submission: a request can sit in the queue for days,
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
// below refuses a dormant account anyway, but that failure would surface to the manager as a
// rejected transaction after the approval was recorded; this says why, before anything moves.
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
if (!isGlRequest) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
@@ -469,7 +491,11 @@ public class ClerkWithdrawalRequestApiResource {
final String description = (String) record.get("description");
String txnId = null;
if (savingsAccountId != null) {
if (isGlRequest) {
// Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal.
validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id")));
txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description);
} else if (savingsAccountId != null) {
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
@@ -477,14 +503,18 @@ public class ClerkWithdrawalRequestApiResource {
}
// Link voucher to the created savings txn and stamp voucher_no on it
if (txnId != null) {
if (txnId != null && !isGlRequest) {
voucherService.linkSavingsTransaction(
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
}
// The sides of the slip. A withdrawal is money leaving the member, so their account is
// debited and the till that pays it out is credited.
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
if (isGlRequest) {
addGlWithdrawalVoucherLines(record, amount);
} else {
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
}
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
@@ -689,8 +719,8 @@ public class ClerkWithdrawalRequestApiResource {
if (denomMap == null || denomMap.size() == 0) return;
final Object txnIdObj = record.get("txn_id");
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
if (txnId == null) return; // no savings txn linked — cannot record denomination
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
if (txnId == null) return; // no txn linked — cannot record denomination
final Object saObj = record.get("savings_account_id");
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
@@ -793,6 +823,117 @@ public class ClerkWithdrawalRequestApiResource {
return rows.isEmpty() ? null : rows.get(0);
}
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
/**
* A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one
* Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under
* the id of its own journal entry in the cashier's position and closing report.
*/
private Long parseTxnKey(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Long.valueOf(raw);
} catch (final NumberFormatException ignored) {
try {
final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw;
return Long.parseUnsignedLong(hex, 16);
} catch (final NumberFormatException ignored2) {
return null;
}
}
}
/** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the
* GL it is paid from must be one of the branch's mapped cash accounts. */
private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) {
if (glAccountId == null || contraGlId == null) {
throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request");
}
final List<Long> cashGlIds = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)",
Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue());
if (!cashGlIds.contains(contraGlId)) {
throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL.");
}
if (cashGlIds.contains(glAccountId)) {
throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip.");
}
}
/** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is
* switched off for manual postings). Returns the journal entry's transaction id. */
private String executeGlWithdrawalJournal(final Map<?, ?> record, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", toLong(record.get("gl_account_id")));
debit.addProperty("amount", amount);
final JsonObject credit = new JsonObject();
credit.addProperty("glAccountId", toLong(record.get("contra_gl_id")));
credit.addProperty("amount", amount);
final JsonArray debits = new JsonArray();
debits.add(debit);
final JsonArray credits = new JsonArray();
credits.add(credit);
final Object office = record.get("office_id");
final Object currency = record.get("currency_code");
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L);
payload.addProperty("transactionDate", date);
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR");
if (note != null && !note.isBlank()) {
payload.addProperty("comments", note);
}
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
payload.add("credits", credits);
payload.add("debits", debits);
final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
// The request raised its own voucher at submission; the journal command must not raise a second.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getTransactionId();
}
/** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */
private void addGlWithdrawalVoucherLines(final Map<?, ?> record, final BigDecimal amount) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank()) {
return;
}
try {
final Long glId = toLong(record.get("gl_account_id"));
final Long contraId = toLong(record.get("contra_gl_id"));
final Map<String, Object> gl = glAccountForVoucher(glId);
final Map<String, Object> contra = glAccountForVoucher(contraId);
voucherService.addLines(voucherNo, List.of(
VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount),
VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e);
}
}
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
}
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
.orElse(Map.of());
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -800,7 +941,8 @@ public class ClerkWithdrawalRequestApiResource {
txn_date, status, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -679,8 +679,9 @@ public final class LoanProductDataValidator {
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
// not mapped by the bank (only the nine GLs are); optional
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
final Long incomeFromInterestId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.INTEREST_ON_LOANS.getValue(), element);
@@ -700,7 +701,7 @@ public final class LoanProductDataValidator {
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
.value(incomeFromRecoveryAccountId).notNull().integerGreaterThanZero();
.value(incomeFromRecoveryAccountId).ignoreIfNull().integerGreaterThanZero();
final Long writeOffAccountId = this.fromApiJsonHelper
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
@@ -9,11 +9,15 @@ package org.apache.fineract.portfolio.loanproduct.service;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
@@ -43,7 +47,8 @@ public class LoanProductAdvanceExtService {
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
"penalAppliedOn", "penalGraceDays", "npaTrigger", "smaStartAfterMonths", "stockStatementValidityDays", "memberRequired", "guarantorMustBeMember",
"incomeMultiple", "minVintageMonths", "requiredDocuments", "interestAfterNpa", "undebitedInterestGlId",
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
@@ -76,7 +81,17 @@ public class LoanProductAdvanceExtService {
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed",
// SICR & LGD (RBI 2026 ECL framework)
"sicrDaysThreshold", "sicrRevolvingDaysThreshold", "sicrRatingDowngradeNotches", "sicrPricingIncreaseTriggerPct",
"sicrAssessmentBasis", "sicrRebuttalAllowed", "npaOutOfOrderBasis", "cropDuration", "useOwnLgdModel", "lgdSecuredPct",
"lgdUnsecuredPct", "eligibleCollateralType",
// Loan against Gold
"goldMaxLtv", "goldMinPurityCarat", "goldRateSource", "goldMaxPerGram", "goldMinNetWeight", "goldMaxNetWeightPerCustomer", "goldAllowedItems", "goldAppraiserRequired", "goldValuationCertificateRequired", "goldMaxValuationPerAppraiser", "goldPacketPrefix", "goldAuctionNoticeDays", "goldRevalueOnNpa", "goldRevaluationChargeApplicable",
"goldPartialReleaseAllowed", "goldThirdPartyAllowed", "goldDisbursementMode", "goldCashDisbursementLimit",
"goldLtvBreachAlert", "goldAuctionReservePct", "goldRedeemBeforeAuction",
// Loan against Warehouse Receipt
"wrMaxLtv", "wrReceiptType", "wrWdraRegistered", "wrAssayCertificateRequired", "wrTenureWithinReceiptValidity", "wrPledgeMarkedBeforeDisbursement", "wrCommodityGroups", "wrValuationPriceSource", "wrMaxLoanPerQuintal", "wrStockInspectionMonths", "wrLtvBreachAlert", "wrPartialReleaseAllowed", "wrDisposalNoticeDays", "wrDisposalReservePct", "wrRedeemBeforeDisposal" };
/**
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
@@ -90,10 +105,205 @@ public class LoanProductAdvanceExtService {
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
applyScalarFields(ext, command, changes);
validateGold(ext, command);
validateWarehouse(ext, command);
this.repository.save(ext);
return changes;
}
/** Longest tenure of a bullet Gold loan, in months. */
private static final int GOLD_MAX_TENURE_MONTHS = 12;
/** The most that may be paid out in cash against one gold loan (Income Tax Sec 269SS). */
private static final BigDecimal GOLD_CASH_LIMIT = BigDecimal.valueOf(20000);
/**
* Loan against Gold rules, checked on the merged state (what is stored plus what this command changes), so a
* partial edit cannot leave the product inconsistent: the LTV cap must cover the LTV the margin gives, the
* purity floor is 18 to 24 carat, and a bullet gold loan runs at most 12 months.
*/
private void validateGold(final LoanProductAdvanceExt ext, final JsonCommand command) {
if (!"gold".equals(ext.getProductTypeSubGroup())) {
return;
}
final List<ApiParameterError> errors = new ArrayList<>();
final BigDecimal hundred = BigDecimal.valueOf(100);
final BigDecimal cap = ext.getGoldMaxLtvPct();
if (cap == null || cap.signum() <= 0 || cap.compareTo(hundred) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.maxLtv.invalid",
"Maximum LTV cap is required and must be above 0 and at most 100", "goldMaxLtv"));
} else if (ext.getMarginPct() != null && hundred.subtract(ext.getMarginPct()).compareTo(cap) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.margin.ltvAboveCap",
"Margin gives an LTV above the Maximum LTV cap; raise the margin or the cap", "margin"));
}
final Integer purity = ext.getGoldMinPurityCarat();
if (purity == null || purity < 18 || purity > 24) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.minPurity.invalid",
"Minimum purity must be between 18 and 24 carat", "goldMinPurityCarat"));
}
if (isBlank(ext.getGoldRateSource())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.rateSource.required",
"Valuation rate source is required", "goldRateSource"));
}
if (isBlank(ext.getGoldAllowedItems())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.allowedItems.required",
"Allowed items is required", "goldAllowedItems"));
}
if (ext.getGoldAuctionNoticeDays() == null || ext.getGoldAuctionNoticeDays() < 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.auctionNotice.invalid",
"Auction notice period is required and cannot be negative", "goldAuctionNoticeDays"));
}
if (ext.getGoldMinNetWeightG() != null && ext.getGoldMaxNetWeightPerCustomerG() != null
&& ext.getGoldMinNetWeightG().compareTo(ext.getGoldMaxNetWeightPerCustomerG()) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.netWeight.range",
"Minimum net weight cannot exceed the maximum per customer", "goldMinNetWeight"));
}
final BigDecimal alert = ext.getGoldLtvBreachAlertPct();
if (alert == null || alert.signum() <= 0 || alert.compareTo(hundred) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.ltvAlert.invalid",
"LTV breach alert is required and must be above 0 and at most 100", "goldLtvBreachAlert"));
} else if (cap != null && alert.compareTo(cap) < 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.ltvAlert.belowCap",
"LTV breach alert cannot be below the Maximum LTV cap", "goldLtvBreachAlert"));
}
final BigDecimal reserve = ext.getGoldAuctionReservePct();
if (reserve == null || reserve.signum() <= 0 || reserve.compareTo(hundred) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.auctionReserve.invalid",
"Auction reserve price is required and must be above 0 and at most 100 percent of market value",
"goldAuctionReservePct"));
}
if (!Boolean.TRUE.equals(ext.getGoldRedeemBeforeAuction())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.redeem.mandatory",
"The customer's right to redeem before the auction cannot be switched off", "goldRedeemBeforeAuction"));
}
final String mode = ext.getGoldDisbursementMode();
if (!"account_credit".equals(mode) && !"account_credit_cash_limit".equals(mode)) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.disbursementMode.invalid",
"Disbursement mode must be account credit, or account credit with a cash limit",
"goldDisbursementMode"));
} else if ("account_credit_cash_limit".equals(mode)) {
final BigDecimal cashLimit = ext.getGoldCashDisbursementLimit();
if (cashLimit == null || cashLimit.signum() < 0 || cashLimit.compareTo(GOLD_CASH_LIMIT) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.cashLimit.invalid",
"Cash disbursement limit must be between 0 and 20,000 (Income Tax Sec 269SS)",
"goldCashDisbursementLimit"));
}
}
if (command.parameterExists("maxNumberOfRepayments")) {
final Integer maxTenure = command.integerValueOfParameterNamed("maxNumberOfRepayments");
if (maxTenure != null && maxTenure > GOLD_MAX_TENURE_MONTHS) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.tenure.exceeds",
"A gold loan runs at most " + GOLD_MAX_TENURE_MONTHS + " months", "maxNumberOfRepayments"));
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
}
}
/** Longest tenure of a warehouse receipt loan, in months. */
private static final int WAREHOUSE_MAX_TENURE_MONTHS = 12;
private static final java.util.Set<String> WR_RECEIPT_TYPES = java.util.Set.of("e_nwr", "paper_nwr", "both");
private static final java.util.Set<String> WR_COMMODITY_GROUPS = java.util.Set.of("cereals", "pulses", "oilseeds", "spices",
"others");
private static final java.util.Set<String> WR_PRICE_SOURCES = java.util.Set.of("mandi_apmc", "msp", "exchange_ncdex",
"bank_assessed");
/**
* Loan against Warehouse Receipt rules, checked on the merged state like the gold rules: the LTV cap must cover the
* LTV the margin gives, the alert level must not sit below the cap, the receipt must come from a WDRA-registered
* warehouse with an assay certificate, the customer's right to redeem stays on, and the loan runs at most 12 months.
*/
private void validateWarehouse(final LoanProductAdvanceExt ext, final JsonCommand command) {
if (!"warehouse".equals(ext.getProductTypeSubGroup())) {
return;
}
final List<ApiParameterError> errors = new ArrayList<>();
final BigDecimal hundred = BigDecimal.valueOf(100);
final BigDecimal cap = ext.getWrMaxLtvPct();
if (cap == null || cap.signum() <= 0 || cap.compareTo(hundred) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.maxLtv.invalid",
"Maximum LTV cap is required and must be above 0 and at most 100", "wrMaxLtv"));
} else if (ext.getMarginPct() != null && hundred.subtract(ext.getMarginPct()).compareTo(cap) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.margin.ltvAboveCap",
"Margin gives an LTV above the Maximum LTV cap; raise the margin or the cap", "margin"));
}
if (!WR_RECEIPT_TYPES.contains(ext.getWrReceiptType())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.receiptType.invalid",
"Receipt type must be e-NWR, paper NWR or both", "wrReceiptType"));
}
if (!Boolean.TRUE.equals(ext.getWrWdraRegistered())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.wdra.mandatory",
"The warehouse must be WDRA-registered; this cannot be switched off", "wrWdraRegistered"));
}
if (!Boolean.TRUE.equals(ext.getWrAssayCertificateRequired())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.assay.mandatory",
"A quality / assay certificate is mandatory; this cannot be switched off", "wrAssayCertificateRequired"));
}
if (isBlank(ext.getWrCommodityGroups())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.commodityGroups.required",
"Pick at least one eligible commodity group", "wrCommodityGroups"));
} else {
for (final String group : ext.getWrCommodityGroups().split("\\|")) {
if (!WR_COMMODITY_GROUPS.contains(group)) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.commodityGroups.invalid",
"Unknown commodity group: " + group, "wrCommodityGroups"));
break;
}
}
}
if (!WR_PRICE_SOURCES.contains(ext.getWrValuationPriceSource())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.priceSource.invalid",
"Valuation price source is required", "wrValuationPriceSource"));
}
if (ext.getWrMaxLoanPerQuintal() != null && ext.getWrMaxLoanPerQuintal().signum() <= 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.maxPerQuintal.invalid",
"Max loan per quintal must be above 0", "wrMaxLoanPerQuintal"));
}
if (ext.getWrStockInspectionMonths() == null || ext.getWrStockInspectionMonths() < 1) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.inspection.invalid",
"Stock inspection frequency must be at least 1 month", "wrStockInspectionMonths"));
}
final BigDecimal alert = ext.getWrLtvBreachAlertPct();
if (alert == null || alert.signum() <= 0 || alert.compareTo(hundred) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.ltvAlert.invalid",
"LTV breach alert is required and must be above 0 and at most 100", "wrLtvBreachAlert"));
} else if (cap != null && alert.compareTo(cap) < 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.ltvAlert.belowCap",
"LTV breach alert cannot be below the Maximum LTV cap", "wrLtvBreachAlert"));
}
if (ext.getWrDisposalNoticeDays() == null || ext.getWrDisposalNoticeDays() < 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.disposalNotice.invalid",
"Disposal notice period is required and cannot be negative", "wrDisposalNoticeDays"));
}
final BigDecimal reserve = ext.getWrDisposalReservePct();
if (reserve == null || reserve.signum() <= 0 || reserve.compareTo(hundred) > 0) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.disposalReserve.invalid",
"Disposal reserve price is required and must be above 0 and at most 100 percent of market value",
"wrDisposalReservePct"));
}
if (!Boolean.TRUE.equals(ext.getWrRedeemBeforeDisposal())) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.redeem.mandatory",
"The customer's right to redeem before the disposal cannot be switched off", "wrRedeemBeforeDisposal"));
}
if (command.parameterExists("maxNumberOfRepayments")) {
final Integer maxTenure = command.integerValueOfParameterNamed("maxNumberOfRepayments");
if (maxTenure != null && maxTenure > WAREHOUSE_MAX_TENURE_MONTHS) {
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.tenure.exceeds",
"A warehouse receipt loan runs at most " + WAREHOUSE_MAX_TENURE_MONTHS + " months",
"maxNumberOfRepayments"));
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
}
}
private static boolean isBlank(final String value) {
return value == null || value.isBlank();
}
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
.orElseGet(LinkedHashMap::new);
@@ -155,6 +365,14 @@ public class LoanProductAdvanceExtService {
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
setInteger(command, "smaStartAfterMonths", ext::getSmaStartAfterMonths, ext::setSmaStartAfterMonths, changes);
setBoolean(command, "memberRequired", ext::getMemberRequired, ext::setMemberRequired, changes);
setBoolean(command, "guarantorMustBeMember", ext::getGuarantorMustBeMember, ext::setGuarantorMustBeMember, changes);
setDecimal(command, "incomeMultiple", ext::getIncomeMultiple, ext::setIncomeMultiple, changes);
setInteger(command, "minVintageMonths", ext::getMinVintageMonths, ext::setMinVintageMonths, changes);
setString(command, "requiredDocuments", ext::getRequiredDocuments, ext::setRequiredDocuments, changes);
setInteger(command, "stockStatementValidityDays", ext::getStockStatementValidityDays,
ext::setStockStatementValidityDays, changes);
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
@@ -163,6 +381,48 @@ public class LoanProductAdvanceExtService {
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
setDecimal(command, "goldMaxLtv", ext::getGoldMaxLtvPct, ext::setGoldMaxLtvPct, changes);
setInteger(command, "goldMinPurityCarat", ext::getGoldMinPurityCarat, ext::setGoldMinPurityCarat, changes);
setString(command, "goldRateSource", ext::getGoldRateSource, ext::setGoldRateSource, changes);
setDecimal(command, "goldMaxPerGram", ext::getGoldMaxPerGram, ext::setGoldMaxPerGram, changes);
setDecimal(command, "goldMinNetWeight", ext::getGoldMinNetWeightG, ext::setGoldMinNetWeightG, changes);
setDecimal(command, "goldMaxNetWeightPerCustomer", ext::getGoldMaxNetWeightPerCustomerG, ext::setGoldMaxNetWeightPerCustomerG, changes);
setString(command, "goldAllowedItems", ext::getGoldAllowedItems, ext::setGoldAllowedItems, changes);
setBoolean(command, "goldAppraiserRequired", ext::getGoldAppraiserRequired, ext::setGoldAppraiserRequired, changes);
setBoolean(command, "goldValuationCertificateRequired", ext::getGoldValuationCertificateRequired, ext::setGoldValuationCertificateRequired, changes);
setDecimal(command, "goldMaxValuationPerAppraiser", ext::getGoldMaxValuationPerAppraiser, ext::setGoldMaxValuationPerAppraiser, changes);
setString(command, "goldPacketPrefix", ext::getGoldPacketPrefix, ext::setGoldPacketPrefix, changes);
setInteger(command, "goldAuctionNoticeDays", ext::getGoldAuctionNoticeDays, ext::setGoldAuctionNoticeDays, changes);
setBoolean(command, "goldRevalueOnNpa", ext::getGoldRevalueOnNpa, ext::setGoldRevalueOnNpa, changes);
setBoolean(command, "goldRevaluationChargeApplicable", ext::getGoldRevaluationChargeApplicable,
ext::setGoldRevaluationChargeApplicable, changes);
setBoolean(command, "goldPartialReleaseAllowed", ext::getGoldPartialReleaseAllowed,
ext::setGoldPartialReleaseAllowed, changes);
setBoolean(command, "goldThirdPartyAllowed", ext::getGoldThirdPartyAllowed, ext::setGoldThirdPartyAllowed,
changes);
setString(command, "goldDisbursementMode", ext::getGoldDisbursementMode, ext::setGoldDisbursementMode, changes);
setDecimal(command, "goldCashDisbursementLimit", ext::getGoldCashDisbursementLimit,
ext::setGoldCashDisbursementLimit, changes);
setDecimal(command, "goldLtvBreachAlert", ext::getGoldLtvBreachAlertPct, ext::setGoldLtvBreachAlertPct, changes);
setDecimal(command, "goldAuctionReservePct", ext::getGoldAuctionReservePct, ext::setGoldAuctionReservePct,
changes);
setBoolean(command, "goldRedeemBeforeAuction", ext::getGoldRedeemBeforeAuction, ext::setGoldRedeemBeforeAuction,
changes);
setDecimal(command, "wrMaxLtv", ext::getWrMaxLtvPct, ext::setWrMaxLtvPct, changes);
setString(command, "wrReceiptType", ext::getWrReceiptType, ext::setWrReceiptType, changes);
setBoolean(command, "wrWdraRegistered", ext::getWrWdraRegistered, ext::setWrWdraRegistered, changes);
setBoolean(command, "wrAssayCertificateRequired", ext::getWrAssayCertificateRequired, ext::setWrAssayCertificateRequired, changes);
setBoolean(command, "wrTenureWithinReceiptValidity", ext::getWrTenureWithinReceiptValidity, ext::setWrTenureWithinReceiptValidity, changes);
setBoolean(command, "wrPledgeMarkedBeforeDisbursement", ext::getWrPledgeMarkedBeforeDisbursement, ext::setWrPledgeMarkedBeforeDisbursement, changes);
setString(command, "wrCommodityGroups", ext::getWrCommodityGroups, ext::setWrCommodityGroups, changes);
setString(command, "wrValuationPriceSource", ext::getWrValuationPriceSource, ext::setWrValuationPriceSource, changes);
setDecimal(command, "wrMaxLoanPerQuintal", ext::getWrMaxLoanPerQuintal, ext::setWrMaxLoanPerQuintal, changes);
setInteger(command, "wrStockInspectionMonths", ext::getWrStockInspectionMonths, ext::setWrStockInspectionMonths, changes);
setDecimal(command, "wrLtvBreachAlert", ext::getWrLtvBreachAlertPct, ext::setWrLtvBreachAlertPct, changes);
setBoolean(command, "wrPartialReleaseAllowed", ext::getWrPartialReleaseAllowed, ext::setWrPartialReleaseAllowed, changes);
setInteger(command, "wrDisposalNoticeDays", ext::getWrDisposalNoticeDays, ext::setWrDisposalNoticeDays, changes);
setDecimal(command, "wrDisposalReservePct", ext::getWrDisposalReservePct, ext::setWrDisposalReservePct, changes);
setBoolean(command, "wrRedeemBeforeDisposal", ext::getWrRedeemBeforeDisposal, ext::setWrRedeemBeforeDisposal, changes);
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
changes);
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
@@ -289,6 +549,20 @@ public class LoanProductAdvanceExtService {
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
// SICR & LGD (RBI 2026 ECL framework)
setInteger(command, "sicrDaysThreshold", ext::getSicrDaysThreshold, ext::setSicrDaysThreshold, changes);
setInteger(command, "sicrRevolvingDaysThreshold", ext::getSicrRevolvingDaysThreshold, ext::setSicrRevolvingDaysThreshold, changes);
setInteger(command, "sicrRatingDowngradeNotches", ext::getSicrRatingDowngradeNotches, ext::setSicrRatingDowngradeNotches, changes);
setDecimal(command, "sicrPricingIncreaseTriggerPct", ext::getSicrPricingIncreaseTriggerPct, ext::setSicrPricingIncreaseTriggerPct,
changes);
setString(command, "sicrAssessmentBasis", ext::getSicrAssessmentBasis, ext::setSicrAssessmentBasis, changes);
setBoolean(command, "sicrRebuttalAllowed", ext::getSicrRebuttalAllowed, ext::setSicrRebuttalAllowed, changes);
setString(command, "npaOutOfOrderBasis", ext::getNpaOutOfOrderBasis, ext::setNpaOutOfOrderBasis, changes);
setString(command, "cropDuration", ext::getCropDuration, ext::setCropDuration, changes);
setBoolean(command, "useOwnLgdModel", ext::getUseOwnLgdModel, ext::setUseOwnLgdModel, changes);
setDecimal(command, "lgdSecuredPct", ext::getLgdSecuredPct, ext::setLgdSecuredPct, changes);
setDecimal(command, "lgdUnsecuredPct", ext::getLgdUnsecuredPct, ext::setLgdUnsecuredPct, changes);
setString(command, "eligibleCollateralType", ext::getEligibleCollateralType, ext::setEligibleCollateralType, changes);
}
// ---- typed setters: only write params that are present, and only report real changes ----------
@@ -403,6 +677,13 @@ public class LoanProductAdvanceExtService {
map.put("penalAppliedOn", ext.getPenalAppliedOn());
map.put("penalGraceDays", ext.getPenalGraceDays());
map.put("npaTrigger", ext.getNpaTrigger());
map.put("smaStartAfterMonths", ext.getSmaStartAfterMonths());
map.put("memberRequired", ext.getMemberRequired());
map.put("guarantorMustBeMember", ext.getGuarantorMustBeMember());
map.put("incomeMultiple", ext.getIncomeMultiple());
map.put("minVintageMonths", ext.getMinVintageMonths());
map.put("requiredDocuments", ext.getRequiredDocuments());
map.put("stockStatementValidityDays", ext.getStockStatementValidityDays());
map.put("interestAfterNpa", ext.getInterestAfterNpa());
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
map.put("renewalAllowed", ext.getRenewalAllowed());
@@ -411,6 +692,42 @@ public class LoanProductAdvanceExtService {
map.put("preclosureAllowed", ext.getPreclosureAllowed());
map.put("lockInMonths", ext.getLockInMonths());
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
map.put("goldMaxLtv", ext.getGoldMaxLtvPct());
map.put("goldMinPurityCarat", ext.getGoldMinPurityCarat());
map.put("goldRateSource", ext.getGoldRateSource());
map.put("goldMaxPerGram", ext.getGoldMaxPerGram());
map.put("goldMinNetWeight", ext.getGoldMinNetWeightG());
map.put("goldMaxNetWeightPerCustomer", ext.getGoldMaxNetWeightPerCustomerG());
map.put("goldAllowedItems", ext.getGoldAllowedItems());
map.put("goldAppraiserRequired", ext.getGoldAppraiserRequired());
map.put("goldValuationCertificateRequired", ext.getGoldValuationCertificateRequired());
map.put("goldMaxValuationPerAppraiser", ext.getGoldMaxValuationPerAppraiser());
map.put("goldPacketPrefix", ext.getGoldPacketPrefix());
map.put("goldAuctionNoticeDays", ext.getGoldAuctionNoticeDays());
map.put("goldRevalueOnNpa", ext.getGoldRevalueOnNpa());
map.put("goldRevaluationChargeApplicable", ext.getGoldRevaluationChargeApplicable());
map.put("goldPartialReleaseAllowed", ext.getGoldPartialReleaseAllowed());
map.put("goldThirdPartyAllowed", ext.getGoldThirdPartyAllowed());
map.put("goldDisbursementMode", ext.getGoldDisbursementMode());
map.put("goldCashDisbursementLimit", ext.getGoldCashDisbursementLimit());
map.put("goldLtvBreachAlert", ext.getGoldLtvBreachAlertPct());
map.put("goldAuctionReservePct", ext.getGoldAuctionReservePct());
map.put("goldRedeemBeforeAuction", ext.getGoldRedeemBeforeAuction());
map.put("wrMaxLtv", ext.getWrMaxLtvPct());
map.put("wrReceiptType", ext.getWrReceiptType());
map.put("wrWdraRegistered", ext.getWrWdraRegistered());
map.put("wrAssayCertificateRequired", ext.getWrAssayCertificateRequired());
map.put("wrTenureWithinReceiptValidity", ext.getWrTenureWithinReceiptValidity());
map.put("wrPledgeMarkedBeforeDisbursement", ext.getWrPledgeMarkedBeforeDisbursement());
map.put("wrCommodityGroups", ext.getWrCommodityGroups());
map.put("wrValuationPriceSource", ext.getWrValuationPriceSource());
map.put("wrMaxLoanPerQuintal", ext.getWrMaxLoanPerQuintal());
map.put("wrStockInspectionMonths", ext.getWrStockInspectionMonths());
map.put("wrLtvBreachAlert", ext.getWrLtvBreachAlertPct());
map.put("wrPartialReleaseAllowed", ext.getWrPartialReleaseAllowed());
map.put("wrDisposalNoticeDays", ext.getWrDisposalNoticeDays());
map.put("wrDisposalReservePct", ext.getWrDisposalReservePct());
map.put("wrRedeemBeforeDisposal", ext.getWrRedeemBeforeDisposal());
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
map.put("insuranceRequired", ext.getInsuranceRequired());
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
@@ -509,6 +826,19 @@ public class LoanProductAdvanceExtService {
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
map.put("topUpAllowed", ext.getTopUpAllowed());
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
// SICR & LGD (RBI 2026 ECL framework)
map.put("sicrDaysThreshold", ext.getSicrDaysThreshold());
map.put("sicrRevolvingDaysThreshold", ext.getSicrRevolvingDaysThreshold());
map.put("sicrRatingDowngradeNotches", ext.getSicrRatingDowngradeNotches());
map.put("sicrPricingIncreaseTriggerPct", ext.getSicrPricingIncreaseTriggerPct());
map.put("sicrAssessmentBasis", ext.getSicrAssessmentBasis());
map.put("sicrRebuttalAllowed", ext.getSicrRebuttalAllowed());
map.put("npaOutOfOrderBasis", ext.getNpaOutOfOrderBasis());
map.put("cropDuration", ext.getCropDuration());
map.put("useOwnLgdModel", ext.getUseOwnLgdModel());
map.put("lgdSecuredPct", ext.getLgdSecuredPct());
map.put("lgdUnsecuredPct", ext.getLgdUnsecuredPct());
map.put("eligibleCollateralType", ext.getEligibleCollateralType());
return map;
}
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.prudentialfloor.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.prudentialfloor.data.PrudentialFloorData;
import org.apache.fineract.portfolio.prudentialfloor.service.PrudentialFloorReadPlatformService;
import org.springframework.stereotype.Component;
/**
* Read-only: this master is seeded from the RBI 2026 Directions (para 82) rather than created per bank, so it
* ships without a create/update command path for now - a Board-approved "tighten only" override is a follow-up.
*/
@RequiredArgsConstructor
@Path("/v1/prudentialfloors")
@Component
@Tag(name = "Prudential Floor Master", description = "RBI 2026 ECL stage-wise prudential floor lookup, by loan product category")
public class PrudentialFloorsApiResource {
private final PlatformSecurityContext securityContext;
private final PrudentialFloorReadPlatformService readPlatformService;
public static final String PRUDENTIAL_FLOOR = "PRUDENTIAL_FLOOR";
@GET
@Consumes({ MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List all Prudential Floor rows", description = "Optionally filter by product category")
public List<PrudentialFloorData> getPrudentialFloors(
@QueryParam("productCategory") @Parameter(description = "productCategory") final String productCategory) {
securityContext.authenticatedUser().validateHasReadPermission(PRUDENTIAL_FLOOR);
if (productCategory == null || productCategory.isBlank()) {
return this.readPlatformService.retrieveAll();
}
return this.readPlatformService.retrieveForCategory(productCategory);
}
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.prudentialfloor.service;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.prudentialfloor.data.PrudentialFloorData;
import org.apache.fineract.portfolio.prudentialfloor.domain.PrudentialFloor;
import org.apache.fineract.portfolio.prudentialfloor.domain.PrudentialFloorRepository;
import org.apache.fineract.portfolio.prudentialfloor.mapper.PrudentialFloorMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Read-only lookup over the RBI 2026 prudential-floor master (para 82). A loan product never stores its own
* floor %: it declares a product category and a stage (the latter via its Delinquency Bucket), and the
* applicable floor is resolved here at reporting/provisioning time, so every product using the same category
* always sees the same, currently-seeded regulatory minimum.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PrudentialFloorReadPlatformService {
private final PrudentialFloorRepository repository;
private final PrudentialFloorMapper mapper;
public List<PrudentialFloorData> retrieveAll() {
return mapper.map(repository.findAll());
}
public List<PrudentialFloorData> retrieveForCategory(final String productCategory) {
return mapper.map(repository.findByProductCategoryOrderByStageAscDurationFromDaysAsc(productCategory));
}
/**
* The floor applicable to one exposure: for Stage 1/2 the single row for that category+stage; for Stage 3
* the row whose duration band contains {@code daysInStage} (days since the exposure entered Stage 3, not
* days overdue). Returns {@code null} when the category/stage/duration combination has not been seeded -
* callers should fall back to the residual "any other loan product category" floor (para 82 item (xv)) in
* that case rather than treat it as zero.
*/
public PrudentialFloorData retrieveFloor(final String productCategory, final Integer stage, final Integer daysInStage) {
final List<PrudentialFloor> candidates = repository.findByProductCategoryAndStage(productCategory, stage);
return candidates.stream().filter(row -> row.coversDuration(daysInStage)).findFirst().map(mapper::map).orElse(null);
}
}
@@ -0,0 +1,623 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.voucher.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the
* cashier's back.
*
* <ol>
* <li>The cashier picks the day's voucher and asks to <b>unpass</b> it (UNPASS_PENDING). Nothing moves.</li>
* <li>A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply
* waiting for its correction entry.</li>
* <li>The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number
* (CORRECTION_PENDING).</li>
* <li>A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is
* reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the
* original voucher is revoked and the cashier's denominations follow the deposit.</li>
* </ol>
*
* The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is
* approved, because the customer may have drawn the money in between.
*/
@Path("/v1/vouchers/corrections")
@Component
@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.")
@RequiredArgsConstructor
public class VoucherCorrectionApiResource {
public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION";
private static final String UNPASS_PENDING = "UNPASS_PENDING";
private static final String UNPASSED = "UNPASSED";
private static final String UNPASS_REJECTED = "UNPASS_REJECTED";
private static final String CORRECTION_PENDING = "CORRECTION_PENDING";
private static final String COMPLETED = "COMPLETED";
private static final String CLOSED = "CLOSED";
private static final int SAVINGS_STATUS_ACTIVE = 300;
private static final int DEPOSIT_TYPE_SAVINGS = 100;
private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy");
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final KycRestrictionGuard kycRestrictionGuard;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Today's vouchers the cashier may unpass ──────────────────────────────────
/** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */
@GET
@Path("eligible")
@Produces(MediaType.APPLICATION_JSON)
public String eligible(@QueryParam("search") final String search) {
requireUnpasser(this.context.authenticatedUser());
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
final List<Object> params = new ArrayList<>();
params.add("%/" + day + "/%");
final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL);
if (search != null && !search.isBlank()) {
sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)");
final String like = "%" + search.trim() + "%";
params.add(like);
params.add(like);
params.add(like);
}
sql.append(" ORDER BY v.id DESC");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
}
private static final String ELIGIBLE_SQL = """
SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt",
v.created_by_username AS "cashier", v.savings_txn_id AS "txnId",
t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo",
c.display_name AS "clientName", pt.value AS "paymentType"
FROM m_voucher v
JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id
JOIN m_savings_account sa ON sa.id = t.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE v.voucher_type = 'DEPOSIT'
AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION'
AND v.voucher_no LIKE ?
AND t.is_reversed = false
AND t.transaction_type_enum = 1
AND pt.is_cash_payment = true
AND sa.deposit_type_enum = 100
AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc
WHERE vc.original_voucher_no = v.voucher_no
AND vc.status NOT IN ('UNPASS_REJECTED'))
""";
// ── Step 1: cashier unpasses a voucher ───────────────────────────────────────
@POST
@Path("unpass")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String unpass(final String body) {
final AppUser user = this.context.authenticatedUser();
requireUnpasser(user);
final JsonElement root = fromJsonHelper.parse(body);
final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root);
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (voucherNo == null || voucherNo.isBlank()) {
throw apiError(400, "Voucher number is required");
}
if (reason == null || reason.isBlank()) {
throw apiError(400, "Reason is required");
}
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%",
voucherNo.trim());
if (rows.isEmpty()) {
throw apiError(409, "Voucher " + voucherNo
+ " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify.");
}
final Map<String, Object> v = rows.get(0);
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_voucher_correction
(original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name,
amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log)
VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?),
(SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?),
?,?,?,?,?,?)
RETURNING id
""", Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"),
v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(),
Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim()));
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING));
}
// ── Lists ────────────────────────────────────────────────────────────────────
/**
* Corrections by status. {@code status} may be a comma-separated list. The approval page asks for
* UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1");
final List<Object> params = new ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)");
params.add(user.getId());
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
}
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public String get(@PathParam("id") final Long id) {
this.context.authenticatedUser();
return gson.toJson(load(id));
}
// ── Step 2: manager approves / rejects the unpass ────────────────────────────
@POST
@Path("{id}/unpass/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approveUnpass(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, UNPASS_PENDING, "approved");
requireNotMaker(user, r.get("unpass_by_id"), "unpass request");
final int updated = jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?,
unpass_approved_at = ?, activity_log = ?
WHERE id = ? AND status = ?
""", UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING);
if (updated == 0) {
throw apiError(409, "This request was already actioned by another manager.");
}
// Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved.
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
}
@POST
@Path("{id}/unpass/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String rejectUnpass(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, UNPASS_PENDING, "rejected");
final String reason = requireReason(body);
jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED,
reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id);
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED));
}
// ── Step 3: cashier enters the correct account ───────────────────────────────
/** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */
@GET
@Path("account-lookup")
@Produces(MediaType.APPLICATION_JSON)
public String accountLookup(@QueryParam("accountNo") final String accountNo) {
this.context.authenticatedUser();
final Map<String, Object> account = findSavingsAccount(accountNo);
if (account == null) {
throw apiError(404, "No savings account found for '" + accountNo + "'.");
}
return gson.toJson(account);
}
@POST
@Path("{id}/correction")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submitCorrection(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
// The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it.
if (cashierRoleHelper.getCurrentCashierId() == null) {
throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier.");
}
final Map<String, Object> r = load(id);
requireStatus(r, UNPASSED, "corrected");
final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body));
final Map<String, Object> correct = validateCorrectAccount(r, accountNo);
checkWrongAccountBalance(r);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?,
correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?,
correction_at = ?, rejection_reason = NULL, activity_log = ?
WHERE id = ? AND status = ?
""", CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"),
user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED);
return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING));
}
// ── Step 4: manager approves / rejects the correction ────────────────────────
@POST
@Path("{id}/correction/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approveCorrection(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, CORRECTION_PENDING, "approved");
requireNotMaker(user, r.get("correction_by_id"), "correction");
// Both sides are checked again: a lot can happen between the cashier saving and the manager approving.
validateCorrectAccount(r, (String) r.get("correct_account_no"));
checkWrongAccountBalance(r);
final Long wrongId = toLong(r.get("wrong_savings_account_id"));
final Long correctId = toLong(r.get("correct_savings_account_id"));
final Long origTxnId = toLong(r.get("original_txn_id"));
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
final String origVoucher = (String) r.get("original_voucher_no");
final Integer stillLive = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false",
Integer.class, origTxnId, wrongId);
if (stillLive == null || stillLive == 0) {
throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected.");
}
// 1. Take the deposit off the wrong account, 2. put the same cash into the right one.
final JsonObject undoPayload = new JsonObject();
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(
new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build()));
final String newTxnId = depositInto(correctId, amount, r);
final Long newTxnKey = Long.valueOf(newTxnId);
// 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side.
final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user);
jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?",
r.get("correction_by_id"), r.get("correction_by_id"), newVoucher);
voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey);
addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"),
amount);
// 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this
// correction (original_voucher_no / new_voucher_no) and read from there by the registers.
// 5. The notes the cashier took in follow the deposit to its new transaction and account.
jdbcTemplate.update("""
UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ?
WHERE txn_id = ? AND savings_account_id = ?
""", newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?,
correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ?
WHERE id = ? AND status = ?
""", COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey,
log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING);
return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher));
}
/** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */
@POST
@Path("{id}/correction/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String rejectCorrection(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, CORRECTION_PENDING, "rejected");
final String reason = requireReason(body);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL,
correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ?
WHERE id = ?
""", UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id);
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
}
/**
* The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has
* already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from
* their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher
* stays as it was.
*/
@POST
@Path("{id}/close")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String close(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
if (cashierRoleHelper.getCurrentCashierId() == null) {
throw apiError(403, "Only a cashier can close a voucher correction.");
}
final Map<String, Object> r = load(id);
requireStatus(r, UNPASSED, "closed");
final String reason = requireReason(body);
final int updated = jdbcTemplate.update(
"UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED,
reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED);
if (updated == 0) {
throw apiError(409, "This request was already actioned.");
}
return gson.toJson(Map.of("resourceId", id, "status", CLOSED));
}
// ── For the passbook and transaction list of the wrong account ───────────────
/** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */
@GET
@Path("voided-transactions")
@Produces(MediaType.APPLICATION_JSON)
public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) {
this.context.authenticatedUser();
final JsonArray ids = new JsonArray();
jdbcTemplate.queryForList(
"SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED,
savingsAccountId).forEach(ids::add);
return gson.toJson(ids);
}
// ── helpers ──────────────────────────────────────────────────────────────────
/** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */
private void requireUnpasser(final AppUser user) {
final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS");
final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim()));
if (!admin && !clerk) {
throw apiError(403, "Only an administrator or a clerk can unpass a voucher.");
}
}
private Map<String, Object> validateCorrectAccount(final Map<String, Object> r, final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "The correct account number is required");
}
final Map<String, Object> correct = findSavingsAccount(accountNo);
if (correct == null) {
throw apiError(404, "No savings account found for '" + accountNo + "'.");
}
if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) {
throw apiError(400, "The correct account is the same account the deposit was posted to.");
}
if (!Boolean.TRUE.equals(correct.get("canReceive"))) {
throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit.");
}
final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class,
r.get("wrong_savings_account_id")).stream().findFirst().orElse(null);
if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) {
throw apiError(409, "The correct account is in a different currency.");
}
kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT);
return correct;
}
/** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */
private void checkWrongAccountBalance(final Map<String, Object> r) {
final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?",
BigDecimal.class, r.get("wrong_savings_account_id"));
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
if (balance == null || balance.compareTo(amount) < 0) {
throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹"
+ (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money.");
}
}
private Map<String, Object> findSavingsAccount(final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName",
sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode",
(sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive"
FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.account_no = ?
""", SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim());
return rows.isEmpty() ? null : rows.get(0);
}
private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map<String, Object> r) {
final Object txnDate = r.get("txn_date");
final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))
: LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (r.get("payment_type_id") != null) {
payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue());
}
payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no"));
final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command));
if (result.getResourceId() == null) {
throw apiError(500, "The corrected deposit could not be posted.");
}
return result.getResourceId().toString();
}
/** The new voucher keeps the original's till side and names the right account on the credit side. */
private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName,
final String accountNo, final BigDecimal amount) {
try {
final List<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> l : jdbcTemplate.queryForList(
"SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no",
origVoucher)) {
lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"),
amount));
}
if (lines.isEmpty()) {
lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount));
}
lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount));
voucherService.addLines(newVoucher, lines);
} catch (final RuntimeException e) {
// The deposit is already posted; a voucher without its sides is better than undoing a correct posting.
org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class)
.error("Could not record the sides of correction voucher {}", newVoucher, e);
}
}
private Map<String, Object> load(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id);
if (rows.isEmpty()) {
throw new NotFoundException("Voucher correction not found: " + id);
}
return rows.get(0);
}
private void requireStatus(final Map<String, Object> r, final String expected, final String action) {
if (!expected.equals(r.get("status"))) {
throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status"));
}
}
private void requireNotMaker(final AppUser user, final Object makerId, final String what) {
if (makerId != null && toLong(makerId).equals(user.getId())) {
throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager.");
}
}
private String requireReason(final String body) {
final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}"));
if (reason == null || reason.isBlank()) {
throw apiError(400, "Reason is required");
}
return reason.trim();
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections.");
}
}
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
private String log(final String existing, final String action, final AppUser user, final String note) {
final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class);
final JsonObject e = new JsonObject();
e.addProperty("action", action);
e.addProperty("by", user.getUsername());
e.addProperty("at", Instant.now().toString());
if (note != null) {
e.addProperty("note", note);
}
entries.add(e);
return gson.toJson(entries);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -628,9 +628,15 @@ public class VoucherVerificationApiResource {
BigDecimal total = BigDecimal.ZERO;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> marks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
if (amount != null) {
final Map<String, Object> mark = marks.get((String) r.get("voucher_no"));
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
// A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it
// is left out of the total rather than counted twice.
if (amount != null && !revoked) {
total = total.add(amount);
}
@@ -638,6 +644,7 @@ public class VoucherVerificationApiResource {
m.put("voucherNo", r.get("voucher_no"));
m.put("voucherType", r.get("voucher_type"));
m.put("amount", amount);
putVoucherMark(m, mark);
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
// The day the posting belongs to, and whether that differs from the day it was written.
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
@@ -847,6 +854,8 @@ public class VoucherVerificationApiResource {
int raisedCount = 0;
int verifiedCount = 0;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> myWorkMarks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
@@ -869,7 +878,10 @@ public class VoucherVerificationApiResource {
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
? "JV" : rawSide;
if (amount != null) {
final Map<String, Object> mark = myWorkMarks.get(voucherNo);
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
// A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted.
if (amount != null && !revoked) {
if ("CR".equals(side)) {
receivedTotal = receivedTotal.add(amount);
} else if ("DR".equals(side)) {
@@ -897,6 +909,7 @@ public class VoucherVerificationApiResource {
m.put("raisedBy", r.get("created_by_username"));
m.put("branchName", r.get("branch_name"));
m.put("verificationStatus", r.get("verification_status"));
putVoucherMark(m, mark);
m.put("verifiedByName", r.get("verified_by_name"));
m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
m.put("debitParty", r.get("debit_party"));
@@ -968,6 +981,14 @@ public class VoucherVerificationApiResource {
* returns an empty list rather than an error: the voucher is real, its posting simply was not
* captured at the time, and reporting it as missing would be wrong.
*/
/** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */
private void putVoucherMark(final Map<String, Object> row, final Map<String, Object> mark) {
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
}
@GET
@Path("detail")
public String detail(@QueryParam("voucherNo") final String voucherNo) {
@@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource {
out.put("createdBy", h.get("created_by_username"));
out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString());
out.put("verificationStatus", h.get("verification_status"));
putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo));
out.put("lines", lines);
out.put("totalDebit", totalDebit);
out.put("totalCredit", totalCredit);
@@ -595,6 +595,66 @@ public class VoucherService {
""", voucherNo);
}
/**
* Where each of these vouchers stands, for the registers that list vouchers.
*
* A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a
* new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING,
* UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in
* replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its
* money now sits on the replacement.
*/
public Map<String, Map<String, Object>> voucherMarks(final java.util.Collection<String> voucherNos) {
final Map<String, Map<String, Object>> marks = new java.util.HashMap<>();
final List<String> nos = voucherNos == null ? List.of()
: voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList();
if (nos.isEmpty()) {
return marks;
}
final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?"));
// Read from the correction register alone: the voucher itself carries no status, so nothing about a correction
// is ever written onto m_voucher and the voucher rows stay exactly as they were raised.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT v.voucher_no,"
+ " (SELECT vc.new_voucher_no FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
+ " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no,"
+ " (SELECT vc.original_voucher_no FROM m_voucher_correction vc"
+ " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
+ " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no,"
+ " (SELECT vc.status FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no"
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')"
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_status,"
// The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed.
+ " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END"
+ " FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no"
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')"
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_note"
+ " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")",
nos.toArray());
for (final Map<String, Object> r : rows) {
final String status;
if (r.get("revoked_by_voucher_no") != null) {
status = "REVOKED";
} else if (r.get("correction_status") != null) {
status = (String) r.get("correction_status");
} else if (r.get("replaces_voucher_no") != null) {
status = "CORRECTED";
} else {
status = "ACTIVE";
}
final Map<String, Object> m = new java.util.LinkedHashMap<>();
m.put("voucherStatus", status);
m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no"));
m.put("replacesVoucherNo", r.get("replaces_voucher_no"));
m.put("correctionNote", r.get("correction_note"));
marks.put((String) r.get("voucher_no"), m);
}
return marks;
}
/**
* Looks up the voucher for the given request and returns its voucher_no,
* or null if none exists (e.g. records created before vouchers existed).
@@ -623,6 +623,17 @@
<include file="parts/0595_seed_loan_rate_register_gl_accounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0596_enable_delinquency_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0597_add_loan_product_loans_ext_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0598_add_loan_product_sma_start_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0599_enable_loan_product_update_maker_checker.xml" relativeToChangelogFile="true"/>
<!-- RBI 2026 ECL framework: SMA/NPA asset classification, prudential floor master, SICR/LGD product config. -->
<include file="parts/0599_add_delinquency_range_stage.xml" relativeToChangelogFile="true"/>
<include file="parts/0600_create_prudential_floor_master.xml" relativeToChangelogFile="true"/>
<include file="parts/0601_add_loan_product_advance_ext_sicr_lgd_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0604_add_gold_loan_product_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0605_add_gold_revaluation_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0606_add_gold_servicing_and_auction_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0607_add_warehouse_receipt_product_fields.xml" relativeToChangelogFile="true"/>
<!-- DDS: the scheme's own tables, settings and permissions. Kept together and last, because
each one builds on the core the block above sets up and none of it builds on DDS. -->
@@ -174,4 +174,8 @@
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
<include file="parts/0600_clerk_withdrawal_request_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- SMA counting start (Advances: 9 months after disbursement, Loans/EMI: 1 month) and the CC/OD stock
statement validity window that feeds the NPA trigger. -->
<changeSet author="cbsnexora" id="0598_add_loan_product_sma_start_fields">
<addColumn tableName="m_product_loan_advance_ext">
<column name="sma_start_after_months" type="INT"/>
<column name="stock_statement_validity_days" type="INT"/>
<column name="elig_member_required" type="BOOLEAN"/>
<column name="elig_guarantor_must_be_member" type="BOOLEAN"/>
<column name="elig_income_multiple" type="DECIMAL(10,2)"/>
<column name="elig_min_vintage_months" type="INT"/>
<column name="required_documents" type="VARCHAR(1000)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- RBI 2026 ECL framework: a delinquency range only ever decided an SMA/NPA label by day-count; it now also
records which ECL stage (1, 2 or 3) that label maps to, so a product's bucket can drive staging directly
instead of a parallel lookup. Nullable: existing ranges keep working as plain labels until re-classified. -->
<changeSet author="cbsnexora" id="0599_add_delinquency_range_stage">
<addColumn tableName="m_delinquency_range">
<column name="stage" type="INT" defaultValueComputed="NULL"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Maker-checker on editing a loan or advance product.
Creating a product already goes to a checker (CREATE_LOANPRODUCT is gated). An edit changes rates,
limits, margin, GL accounts and lien rules that live accounts depend on, so it must not take effect on
one person's click either. UPDATE_LOANPRODUCT is gated with can_maker_checker = true: an edit lands in
m_portfolio_command_source as PENDING and is applied when a checker approves it. UPDATE_LOANPRODUCT_CHECKER
already exists and is what approving requires. Roles are left as they are.
-->
<changeSet id="0599_loan_product_update_maker_checker" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code = 'UPDATE_LOANPRODUCT';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) goes through the same request, manager approval and
cashier disbursement as a savings withdrawal, so the request carries the GL account and the cash GL it is paid
from. All four columns are nullable: savings, current and deposit requests do not use them.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0600_clerk_withdrawal_request_add_gl_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_clerk_withdrawal_request"/>
<not><columnExists tableName="m_clerk_withdrawal_request" columnName="gl_account_id"/></not>
</preConditions>
<addColumn tableName="m_clerk_withdrawal_request">
<column name="gl_account_id" type="BIGINT"/>
<column name="contra_gl_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="currency_code" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- RBI 2026 ECL framework (Directions para 82): the stage-wise prudential floor, keyed by loan product
category rather than by loan product. Seeded below with a representative set of categories; the
remaining categories and the category-specific Stage 3 table variants (unsecured retail, housing/other
real estate, government-guaranteed) are a follow-up seed, not a schema change. created_by/
last_modified_by use 1 (the system user) since this is regulatory data, not something a maker entered. -->
<changeSet author="cbsnexora" id="0600_create_prudential_floor_master_table">
<createTable tableName="m_prudential_floor">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="product_category" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="stage" type="INT">
<constraints nullable="false"/>
</column>
<column name="duration_from_days" type="INT" defaultValueComputed="NULL"/>
<column name="duration_to_days" type="INT" defaultValueComputed="NULL"/>
<column name="secured_floor_pct" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="unsecured_floor_pct" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="version" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex indexName="IX_m_prudential_floor_category_stage" tableName="m_prudential_floor">
<column name="product_category"/>
<column name="stage"/>
</createIndex>
</changeSet>
<changeSet author="cbsnexora" id="0600_add_prudential_floor_permission">
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_PRUDENTIAL_FLOOR"/>
<column name="entity_name" value="PRUDENTIAL_FLOOR"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="cbsnexora" id="0600_seed_prudential_floor_stage_1_2">
<!-- Stage 1 / Stage 2: a single floor per category (not split secured/unsecured in the Directions),
so secured_floor_pct = unsecured_floor_pct here. -->
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.25"/><column name="unsecured_floor_pct" valueNumeric="0.25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.25"/><column name="unsecured_floor_pct" valueNumeric="0.25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="1.00"/><column name="unsecured_floor_pct" valueNumeric="1.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="1.50"/><column name="unsecured_floor_pct" valueNumeric="1.50"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.25"/><column name="unsecured_floor_pct" valueNumeric="0.25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="1.50"/><column name="unsecured_floor_pct" valueNumeric="1.50"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Loan against Term Deposit / LIC / KVP"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Loan against Term Deposit / LIC / KVP"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
</changeSet>
<changeSet author="cbsnexora" id="0600_seed_prudential_floor_stage_3_standard">
<!-- Stage 3 ("standard" duration table, para 82(5) first table): applied here to every seeded category
alike as a starting point. The Directions in fact vary this table by category group (housing/real
estate and unsecured retail use their own bands) - narrowing this per category is a follow-up seed,
not a schema change, since duration_from_days/duration_to_days already carry whatever band a row
needs. -->
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="10"/><column name="unsecured_floor_pct" valueNumeric="25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="20"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="30"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
<insert tableName="m_prudential_floor"><column name="product_category" value="Loan against Term Deposit / LIC / KVP"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- RBI 2026 ECL framework, Penal/SMA/NPA wizard step: SICR trigger configuration and the LGD backstop /
own-model override. The prudential floor % itself is NOT stored here - it is looked up from
m_prudential_floor by product category, so every product stays in sync with a single regulatory table. -->
<changeSet author="cbsnexora" id="0601_add_loan_product_advance_ext_sicr_lgd_fields">
<addColumn tableName="m_product_loan_advance_ext">
<!-- SICR trigger configuration -->
<column name="sicr_days_threshold" type="INT"/>
<column name="sicr_revolving_days_threshold" type="INT"/>
<column name="sicr_rating_downgrade_notches" type="INT"/>
<column name="sicr_pricing_increase_trigger_pct" type="DECIMAL(19,6)"/>
<column name="sicr_assessment_basis" type="VARCHAR(30)"/>
<column name="sicr_rebuttal_allowed" type="BOOLEAN"/>
<!-- NPA trigger extras -->
<column name="npa_out_of_order_basis" type="VARCHAR(60)"/>
<column name="crop_duration" type="VARCHAR(20)"/>
<!-- LGD backstop / override -->
<column name="use_own_lgd_model" type="BOOLEAN"/>
<column name="lgd_secured_pct" type="DECIMAL(19,6)"/>
<column name="lgd_unsecured_pct" type="DECIMAL(19,6)"/>
<column name="eligible_collateral_type" type="VARCHAR(200)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Voucher correction: a cash deposit posted to the wrong savings account is unpassed, approved, re-entered against
the right account by the cashier and approved again. m_voucher_correction carries that request through its steps;
m_voucher gains a status so the original voucher can be REVOKED once its replacement exists.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0601_voucher_add_status" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_voucher" columnName="status"/></not>
</preConditions>
<addColumn tableName="m_voucher">
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="revoked_at" type="TIMESTAMP"/>
<column name="revoked_by_voucher_no" type="VARCHAR(30)"/>
</addColumn>
</changeSet>
<changeSet id="0601_create_voucher_correction" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_voucher_correction"/></not>
</preConditions>
<createTable tableName="m_voucher_correction">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="original_voucher_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="original_txn_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_savings_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_account_no" type="VARCHAR(50)"/>
<column name="wrong_client_name" type="VARCHAR(255)"/>
<column name="amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="payment_type_id" type="BIGINT"/>
<column name="txn_date" type="DATE"/>
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="reason" type="VARCHAR(1000)"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="unpass_by_id" type="BIGINT"/>
<column name="unpass_by_name" type="VARCHAR(255)"/>
<column name="unpass_at" type="TIMESTAMP"/>
<column name="unpass_approved_by_id" type="BIGINT"/>
<column name="unpass_approved_by_name" type="VARCHAR(255)"/>
<column name="unpass_approved_at" type="TIMESTAMP"/>
<column name="correct_savings_account_id" type="BIGINT"/>
<column name="correct_account_no" type="VARCHAR(50)"/>
<column name="correct_client_id" type="BIGINT"/>
<column name="correct_client_name" type="VARCHAR(255)"/>
<column name="correction_by_id" type="BIGINT"/>
<column name="correction_by_name" type="VARCHAR(255)"/>
<column name="correction_at" type="TIMESTAMP"/>
<column name="correction_approved_by_id" type="BIGINT"/>
<column name="correction_approved_by_name" type="VARCHAR(255)"/>
<column name="correction_approved_at" type="TIMESTAMP"/>
<column name="new_voucher_no" type="VARCHAR(30)"/>
<column name="new_txn_id" type="BIGINT"/>
<column name="activity_log" type="TEXT"/>
</createTable>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_voucher">
<column name="original_voucher_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
The voucher itself carries no correction status: whether a voucher was unpassed, revoked or closed is read from
m_voucher_correction. 0601 had added status, revoked_at and revoked_by_voucher_no to m_voucher; nothing writes them
any more, so they are dropped and the voucher table is back to exactly what the registers raised.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0602_voucher_drop_status_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_voucher" columnName="status"/>
</preConditions>
<dropColumn tableName="m_voucher" columnName="status"/>
<dropColumn tableName="m_voucher" columnName="revoked_at"/>
<dropColumn tableName="m_voucher" columnName="revoked_by_voucher_no"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Cash Transfer is maker-checker: the person who allocates cash from the vault to a cashier raises a request, and the
allocation is posted only when a different person approves it. m_cash_transfer stays the table of posted transfers;
this table holds the requests, with the whole request body so the approval posts exactly what was asked for.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0603_create_cash_transfer_request" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cash_transfer_request"/></not>
</preConditions>
<createTable tableName="m_cash_transfer_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="from_teller_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="to_cashier_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="from_party" type="VARCHAR(255)"/>
<column name="to_party" type="VARCHAR(255)"/>
<column name="note" type="VARCHAR(1000)"/>
<column name="total_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="request_body" type="TEXT"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="maker_id" type="BIGINT"/>
<column name="maker_name" type="VARCHAR(255)"/>
<column name="maker_at" type="TIMESTAMP"/>
<column name="checker_id" type="BIGINT"/>
<column name="checker_name" type="VARCHAR(255)"/>
<column name="checker_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="cash_transfer_id" type="BIGINT"/>
</createTable>
<createIndex tableName="m_cash_transfer_request" indexName="idx_cash_transfer_request_status">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Loan against Gold: valuation basis, appraiser rule, safe custody and auction notice. Pledge (not lien) security. -->
<changeSet author="cbsnexora" id="0604_add_gold_loan_product_fields">
<addColumn tableName="m_product_loan_advance_ext">
<column name="gold_max_ltv_pct" type="DECIMAL(6,2)"/>
<column name="gold_min_purity_carat" type="INT"/>
<column name="gold_rate_source" type="VARCHAR(40)"/>
<column name="gold_max_per_gram" type="DECIMAL(19,2)"/>
<column name="gold_min_net_weight_g" type="DECIMAL(10,2)"/>
<column name="gold_max_net_weight_per_customer_g" type="DECIMAL(10,2)"/>
<column name="gold_allowed_items" type="VARCHAR(40)"/>
<column name="gold_appraiser_required" type="BOOLEAN"/>
<column name="gold_valuation_certificate_required" type="BOOLEAN"/>
<column name="gold_max_valuation_per_appraiser" type="DECIMAL(19,2)"/>
<column name="gold_packet_prefix" type="VARCHAR(20)"/>
<column name="gold_auction_notice_days" type="INT"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Gold loans are short term, so the gold is not revalued on a schedule: it is revalued when the loan turns NPA,
and the customer may be charged for it. -->
<changeSet author="cbsnexora" id="0605_add_gold_revaluation_rules">
<addColumn tableName="m_product_loan_advance_ext">
<column name="gold_revalue_on_npa" type="BOOLEAN"/>
<column name="gold_revaluation_charge_applicable" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Loan against Gold: servicing rules (partial release, third-party gold), disbursement mode with the cash limit,
the LTV level that flags a falling gold rate, and the auction reserve price / right to redeem. -->
<changeSet author="cbsnexora" id="0606_add_gold_servicing_and_auction_rules">
<addColumn tableName="m_product_loan_advance_ext">
<column name="gold_partial_release_allowed" type="BOOLEAN"/>
<column name="gold_third_party_allowed" type="BOOLEAN"/>
<column name="gold_disbursement_mode" type="VARCHAR(40)"/>
<column name="gold_cash_disbursement_limit" type="DECIMAL(19,2)"/>
<column name="gold_ltv_breach_alert_pct" type="DECIMAL(6,2)"/>
<column name="gold_auction_reserve_pct" type="DECIMAL(6,2)"/>
<column name="gold_redeem_before_auction" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Loan against Warehouse Receipt: the receipt rules (type, WDRA warehouse, assay, validity, repository pledge),
the commodity groups and valuation price, stock inspection, LTV alert, and the disposal rules after NPA. -->
<changeSet author="cbsnexora" id="0607_add_warehouse_receipt_product_fields">
<addColumn tableName="m_product_loan_advance_ext">
<column name="wr_max_ltv_pct" type="DECIMAL(6,2)"/>
<column name="wr_receipt_type" type="VARCHAR(20)"/>
<column name="wr_wdra_registered" type="BOOLEAN"/>
<column name="wr_assay_certificate_required" type="BOOLEAN"/>
<column name="wr_tenure_within_receipt_validity" type="BOOLEAN"/>
<column name="wr_pledge_marked_before_disbursement" type="BOOLEAN"/>
<column name="wr_commodity_groups" type="VARCHAR(200)"/>
<column name="wr_valuation_price_source" type="VARCHAR(30)"/>
<column name="wr_max_loan_per_quintal" type="DECIMAL(19,2)"/>
<column name="wr_stock_inspection_months" type="INT"/>
<column name="wr_ltv_breach_alert_pct" type="DECIMAL(6,2)"/>
<column name="wr_partial_release_allowed" type="BOOLEAN"/>
<column name="wr_disposal_notice_days" type="INT"/>
<column name="wr_disposal_reserve_pct" type="DECIMAL(6,2)"/>
<column name="wr_redeem_before_disposal" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -173,7 +173,7 @@ public class LoanAccountDelinquencyRangeEventSerializerTest {
when(loanAccountData.getId()).thenReturn(1L);
when(loanAccountData.getAccountNo()).thenReturn("0001");
when(loanAccountData.getExternalId()).thenReturn(ExternalIdFactory.produce("externalId"));
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10));
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10, null));
when(loanAccountData.getCurrency()).thenAnswer(
a -> new CurrencyData(loanCurrency.getCode(), loanCurrency.getDigitsAfterDecimal(), loanCurrency.getInMultiplesOf()));
when(loanForProcessing.getCurrency()).thenReturn(loanCurrency);
@@ -238,7 +238,7 @@ public class LoanAccountDelinquencyRangeEventSerializerTest {
when(loanAccountData.getId()).thenReturn(1L);
when(loanAccountData.getAccountNo()).thenReturn("0001");
when(loanAccountData.getExternalId()).thenReturn(ExternalIdFactory.produce("externalId"));
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10));
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10, null));
when(loanAccountData.getCurrency()).thenAnswer(
a -> new CurrencyData(loanCurrency.getCode(), loanCurrency.getDigitsAfterDecimal(), loanCurrency.getInMultiplesOf()));
when(loanForProcessing.getCurrency()).thenReturn(loanCurrency);
@@ -174,9 +174,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
Loan loanForProcessing = Mockito.mock(Loan.class);
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
@@ -217,9 +217,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
Loan loanForProcessing = Mockito.mock(Loan.class);
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
@@ -281,9 +281,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
Loan loanForProcessing = Mockito.mock(Loan.class);
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
@@ -346,9 +346,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
Loan loanForProcessing = Mockito.mock(Loan.class);
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
@@ -425,9 +425,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
Loan loanForProcessing = Mockito.mock(Loan.class);
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
@@ -510,9 +510,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
// given
Loan loanForProcessing = Mockito.mock(Loan.class);
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
@@ -597,9 +597,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
// given
Loan loanForProcessing = Mockito.mock(Loan.class);
when(loanForProcessing.getId()).thenReturn(1L);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
range1.setId(1L);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
range2.setId(2L);
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");