feat(chequebook): silent balance hold instead of activation-time charge
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 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 - 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 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 - 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
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 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 - 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 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 - 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
- Removed the "CHEQUE_BOOK" facility charge from account-activation auto-apply (both savings and current) — the per-leaf issuance charge is now the only cheque-book charge, applied at request/approval time only. - Cheque book request now reserves the charge amount via a direct on-hold-funds field update instead of Fineract's native hold-amount command, so no "Amount on hold"/"Release Amount" transaction row is ever created — nothing shows in the passbook/transactions list, and the reservation is released the same way (silently) on approval (then charged) or rejection (returned), for both savings and current accounts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5ed95c27e8
commit
383dfdc6e9
-12
@@ -85,14 +85,6 @@ public class ChequeBookPendingRequest extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "activity_log")
|
||||
private String activityLog;
|
||||
|
||||
/**
|
||||
* The hold-amount transaction (savings/current) placed on the account when this request was submitted, so
|
||||
* the cheque-book issuance charge is reserved for the pending duration — released on approval (then
|
||||
* charged) or on rejection (returned). Null for request types other than REQUEST_BOOK.
|
||||
*/
|
||||
@Column(name = "hold_transaction_id")
|
||||
private Long holdTransactionId;
|
||||
|
||||
public static ChequeBookPendingRequest submit(final ChequeBookPendingRequestType requestType, final String payloadJson,
|
||||
final AppUser maker, final String activityLog) {
|
||||
final ChequeBookPendingRequest request = new ChequeBookPendingRequest();
|
||||
@@ -107,10 +99,6 @@ public class ChequeBookPendingRequest extends AbstractPersistableCustom<Long> {
|
||||
return request;
|
||||
}
|
||||
|
||||
public void recordHold(final Long holdTransactionId) {
|
||||
this.holdTransactionId = holdTransactionId;
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
return ChequeBookPendingRequestStatus.PENDING.name().equals(this.status);
|
||||
}
|
||||
|
||||
+30
-32
@@ -168,47 +168,45 @@ public class ChequeBookIssuanceChargeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves {@code chargeAmount} on the account via Fineract's native hold-amount command — called right
|
||||
* after {@link #assertBalanceCovers} passes at request-submit time, so the money is set aside for the
|
||||
* pending duration rather than being spendable elsewhere while the request awaits a checker. Returns the
|
||||
* hold transaction's id (to be stored on the pending request row) so it can be released later — at
|
||||
* approval (then charged) or at rejection (returned to the customer). Returns {@code null} if there's no
|
||||
* charge to hold for (nothing to reserve).
|
||||
* Reserves {@code chargeAmount} on the account by directly incrementing its on-hold-funds field — a
|
||||
* plain entity mutation, not Fineract's native hold-amount command, so it creates no transaction row
|
||||
* and never appears in the passbook/transactions list. Called right after {@link #assertBalanceCovers}
|
||||
* passes at request-submit time, so the money is set aside for the pending duration rather than being
|
||||
* spendable elsewhere while the request awaits a checker.
|
||||
*/
|
||||
public Long placeHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount,
|
||||
final LocalDate today) {
|
||||
public void placeHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount) {
|
||||
if (chargeAmount == null || chargeAmount.signum() <= 0) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
if (accountType == ChequeAccountType.SAVINGS) {
|
||||
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
|
||||
account.holdAmount(chargeAmount);
|
||||
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
|
||||
} else {
|
||||
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
|
||||
account.setOnHoldFundsDerived(safeBalance(account.getOnHoldFundsDerived()).add(chargeAmount));
|
||||
this.currentAccountRepository.saveAndFlush(account);
|
||||
}
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("transactionAmount", chargeAmount);
|
||||
payload.addProperty("transactionDate", today.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")));
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("reasonForBlock", "Reserved for cheque book issuance charge");
|
||||
payload.addProperty("lienAllowed", false);
|
||||
|
||||
final CommandWrapper cmd = accountType == ChequeAccountType.SAVINGS
|
||||
? new CommandWrapperBuilder().holdAmount(accountId).withJson(payload.toString()).build()
|
||||
: new CommandWrapperBuilder().holdAmountCurrentAccount(accountId).withJson(payload.toString()).build();
|
||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
return result != null ? result.getResourceId() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a previously-placed hold. Safe to call with a {@code null} id (nothing was held, e.g. the
|
||||
* charge rate was zero at submit time) — becomes a no-op.
|
||||
* Releases a previously-placed hold — reverses the same field increment, again with no transaction row.
|
||||
* Safe to call with a zero/null amount (nothing was held) — becomes a no-op.
|
||||
*/
|
||||
public void releaseHold(final ChequeAccountType accountType, final Long accountId, final Long holdTransactionId) {
|
||||
if (holdTransactionId == null) {
|
||||
public void releaseHold(final ChequeAccountType accountType, final Long accountId, final BigDecimal chargeAmount) {
|
||||
if (chargeAmount == null || chargeAmount.signum() <= 0) {
|
||||
return;
|
||||
}
|
||||
final CommandWrapper cmd = accountType == ChequeAccountType.SAVINGS
|
||||
? new CommandWrapperBuilder().releaseAmount(accountId, holdTransactionId).build()
|
||||
: new CommandWrapperBuilder().releaseAmountCurrentAccount(accountId, holdTransactionId).build();
|
||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
if (accountType == ChequeAccountType.SAVINGS) {
|
||||
final SavingsAccount account = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
|
||||
account.releaseOnHoldAmount(chargeAmount);
|
||||
this.savingsAccountRepositoryWrapper.saveAndFlush(account);
|
||||
} else {
|
||||
final CurrentAccount account = this.currentAccountRepositoryWrapper.findOneWithNotFoundDetection(accountId);
|
||||
final BigDecimal newHold = safeBalance(account.getOnHoldFundsDerived()).subtract(chargeAmount).max(BigDecimal.ZERO);
|
||||
account.setOnHoldFundsDerived(newHold);
|
||||
this.currentAccountRepository.saveAndFlush(account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-18
@@ -330,11 +330,11 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
|
||||
final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf()
|
||||
.multiply(BigDecimal.valueOf((long) noOfBooks * series.getLeavesPerBook()));
|
||||
this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount);
|
||||
// reserve the charge amount on the account for the pending duration — released (then charged) on
|
||||
// approval, or released (returned) on rejection; never left dangling
|
||||
final Long holdTransactionId = this.chequeBookIssuanceChargeService.placeHold(accountType, accountId, chargeAmount,
|
||||
DateUtils.getBusinessLocalDate());
|
||||
final Long pendingId = insertPendingRequest(ChequeBookPendingRequestType.REQUEST_BOOK, command.json(), holdTransactionId);
|
||||
// reserve the charge amount on the account for the pending duration (a direct on-hold-funds field
|
||||
// update — no transaction row, so it never shows in the passbook/transactions list) — released
|
||||
// (then charged) on approval, or released (returned) on rejection; never left dangling
|
||||
this.chequeBookIssuanceChargeService.placeHold(accountType, accountId, chargeAmount);
|
||||
final Long pendingId = submitPendingRequest(ChequeBookPendingRequestType.REQUEST_BOOK, command);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(pendingId).build();
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
|
||||
* out the book, so there is no separate "Issue" click left for anyone to do afterward. The book is
|
||||
* created and immediately drawn down from available stock in the same step.
|
||||
*/
|
||||
private Long finalizeRequestChequeBook(final JsonCommand command, final Long makerId, final Long holdTransactionId) {
|
||||
private Long finalizeRequestChequeBook(final JsonCommand command, final Long makerId) {
|
||||
final ChequeAccountType accountType = ChequeAccountType
|
||||
.fromString(command.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue()));
|
||||
final Long accountId = command.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue());
|
||||
@@ -368,7 +368,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
|
||||
final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf().multiply(BigDecimal.valueOf(totalLeaves));
|
||||
// release the hold placed at submit time before re-checking — otherwise the held amount would count
|
||||
// against the account twice (once as "on hold", once as the charge being re-validated)
|
||||
this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, holdTransactionId);
|
||||
this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, chargeAmount);
|
||||
// authoritative re-check: rate or balance may have moved since the maker submitted this request
|
||||
this.chequeBookIssuanceChargeService.assertBalanceCovers(accountType, accountId, chargeAmount);
|
||||
|
||||
@@ -606,16 +606,9 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
|
||||
}
|
||||
|
||||
private Long insertPendingRequest(final ChequeBookPendingRequestType type, final String payloadJson) {
|
||||
return insertPendingRequest(type, payloadJson, null);
|
||||
}
|
||||
|
||||
private Long insertPendingRequest(final ChequeBookPendingRequestType type, final String payloadJson, final Long holdTransactionId) {
|
||||
final AppUser maker = this.context.authenticatedUser();
|
||||
final String activityLog = appendActivityLog(null, "SUBMITTED", maker, null);
|
||||
final ChequeBookPendingRequest request = ChequeBookPendingRequest.submit(type, payloadJson, maker, activityLog);
|
||||
if (holdTransactionId != null) {
|
||||
request.recordHold(holdTransactionId);
|
||||
}
|
||||
final ChequeBookPendingRequest saved = this.chequeBookPendingRequestRepositoryWrapper.saveAndFlush(request);
|
||||
log.info("Cheque book pending request {} ({}) submitted by {}", saved.getId(), type, maker.getUsername());
|
||||
return saved.getId();
|
||||
@@ -642,7 +635,7 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
|
||||
final Long createdResourceId = switch (type) {
|
||||
case CREATE_SERIES -> finalizeCreateSeries(replay, request.getMakerId());
|
||||
case RECEIVE_STOCK -> finalizeReceiveStock(replay, request.getMakerId());
|
||||
case REQUEST_BOOK -> finalizeRequestChequeBook(replay, request.getMakerId(), request.getHoldTransactionId());
|
||||
case REQUEST_BOOK -> finalizeRequestChequeBook(replay, request.getMakerId());
|
||||
case DELETE_SERIES ->
|
||||
finalizeDeleteSeries(replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue()));
|
||||
};
|
||||
@@ -677,13 +670,17 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
|
||||
}
|
||||
|
||||
// a rejected book request returns any reserved issuance charge to the customer — never left on hold
|
||||
if (request.getHoldTransactionId() != null
|
||||
&& ChequeBookPendingRequestType.REQUEST_BOOK.name().equals(request.getRequestType())) {
|
||||
if (ChequeBookPendingRequestType.REQUEST_BOOK.name().equals(request.getRequestType())) {
|
||||
final JsonCommand replay = reconstructCommand(request.getPayloadJson());
|
||||
final ChequeAccountType accountType = ChequeAccountType
|
||||
.fromString(replay.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_TYPE.getValue()));
|
||||
final Long accountId = replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.ACCOUNT_ID.getValue());
|
||||
this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, request.getHoldTransactionId());
|
||||
final Long seriesId = replay.longValueOfParameterNamed(ChequeBookApiJsonInputParams.SERIES_ID.getValue());
|
||||
final Integer noOfBooks = replay.integerValueSansLocaleOfParameterNamed(ChequeBookApiJsonInputParams.NO_OF_BOOKS.getValue());
|
||||
final ChequeSeries series = this.chequeSeriesRepositoryWrapper.findOneWithNotFoundDetection(seriesId);
|
||||
final BigDecimal chargeAmount = this.chequeBookIssuanceChargeService.ratePerLeaf()
|
||||
.multiply(BigDecimal.valueOf((long) noOfBooks * series.getLeavesPerBook()));
|
||||
this.chequeBookIssuanceChargeService.releaseHold(accountType, accountId, chargeAmount);
|
||||
}
|
||||
|
||||
final String activityLog = appendActivityLog(request.getActivityLog(), "REJECTED", checker, reason);
|
||||
|
||||
+3
-1
@@ -697,7 +697,9 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
||||
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
|
||||
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
|
||||
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
|
||||
applyMapped(account, fc.isChequeBook(), "CHEQUE_BOOK");
|
||||
// Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
|
||||
// when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
|
||||
// the facility enabled.
|
||||
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
|
||||
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
|
||||
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
|
||||
|
||||
Reference in New Issue
Block a user