feat(chequebook): add RETURNED leaf status for dishonoured/bounced cheques

Adds a maker-checker-gated "return" command on cheque books that flags
presented-and-dishonoured leaves as terminal RETURNED (distinct from
STOP_PAYMENT/drawer-initiated and LOST/custody-failure), following the
same reason/actor/timestamp pattern already used for lost/cancelled/used
leaves on m_cheque_leaf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-15 16:07:04 +05:30
co-authored by Claude Sonnet 5
parent 04d65d857f
commit 3bb7fc8050
11 changed files with 144 additions and 10 deletions
@@ -207,6 +207,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder returnChequeBook(final Long chequeBookId) {
this.actionName = "RETURN";
this.entityName = "CHEQUEBOOK";
this.entityId = chequeBookId;
this.href = "/chequebooks/" + chequeBookId;
return this;
}
public CommandWrapperBuilder approveChequeBookPendingRequest(final Long pendingRequestId) {
this.actionName = "APPROVE";
this.entityName = "CHEQUEBOOKPENDINGREQUEST";
@@ -99,10 +99,10 @@ public class ChequeBookApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a command on a cheque book", description = "Supported commands: issue, reject, withdraw, "
+ "stoppayment, revokestoppayment, reportlost, cancelleaf, markused.")
+ "stoppayment, revokestoppayment, reportlost, cancelleaf, markused, return.")
public CommandProcessingResult handleCommand(@PathParam("id") @Parameter(description = "id") final Long id,
@QueryParam("command") @Parameter(description = "issue | reject | withdraw | stoppayment | revokestoppayment | "
+ "reportlost | cancelleaf | markused") final String commandParam,
+ "reportlost | cancelleaf | markused | return") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson);
final CommandWrapper request;
@@ -122,6 +122,8 @@ public class ChequeBookApiResource {
request = builder.cancelLeafChequeBook(id).build();
} else if ("markused".equalsIgnoreCase(commandParam)) {
request = builder.markUsedChequeBook(id).build();
} else if ("return".equalsIgnoreCase(commandParam)) {
request = builder.returnChequeBook(id).build();
} else {
throw new UnrecognizedQueryParamException("command", commandParam);
}
@@ -52,6 +52,9 @@ public class ChequeLeafData {
private final String usedReason;
private final Long usedBy;
private final OffsetDateTime usedOn;
private final String returnReason;
private final Long returnedBy;
private final OffsetDateTime returnedOn;
private final String accountType;
private final Long accountId;
private final String accountNo;
@@ -40,10 +40,11 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
*
* <p>
* Legal transitions: (created)-&gt;ACTIVE (issued) or (created, no book)-&gt;LOST (unissued theft report);
* ACTIVE&lt;-&gt;STOP_PAYMENT (the one round-trip); ACTIVE-&gt;CANCELLED / LOST / WITHDRAWN / USED (all terminal).
* A recovered "lost" cheque conventionally stays hotlisted rather than silently reactivating, since the
* customer has typically already been issued a replacement book. USED is manually recorded by a branch,
* never automatic — see {@link #markUsed(String, Long)}.
* ACTIVE&lt;-&gt;STOP_PAYMENT (the one round-trip); ACTIVE-&gt;CANCELLED / LOST / WITHDRAWN / USED / RETURNED (all
* terminal). A recovered "lost" cheque conventionally stays hotlisted rather than silently reactivating,
* since the customer has typically already been issued a replacement book; a RETURNED (dishonoured) leaf
* likewise stays flagged rather than reverting to ACTIVE. USED is manually recorded by a branch, never
* automatic — see {@link #markUsed(String, Long)}.
*/
@Entity
@Table(name = "m_cheque_leaf")
@@ -105,6 +106,16 @@ public class ChequeLeaf extends AbstractPersistableCustom<Long> {
@Column(name = "used_on")
private OffsetDateTime usedOn;
/** Set when a presented leaf is dishonoured/bounced (insufficient funds, signature mismatch, etc.). */
@Column(name = "return_reason", length = 500)
private String returnReason;
@Column(name = "returned_by")
private Long returnedBy;
@Column(name = "returned_on")
private OffsetDateTime returnedOn;
@Column(name = "created_on")
private OffsetDateTime createdOn;
@@ -191,4 +202,13 @@ public class ChequeLeaf extends AbstractPersistableCustom<Long> {
this.usedBy = markedBy;
this.usedOn = DateUtils.getOffsetDateTimeOfTenant();
}
/** The leaf was presented for payment and dishonoured/bounced. Terminal — no revoke, matching LOST/CANCELLED. */
public void returned(final String reason, final Long actorId) {
assertActive();
this.status = ChequeLeafStatus.RETURNED.name();
this.returnReason = reason;
this.returnedBy = actorId;
this.returnedOn = DateUtils.getOffsetDateTimeOfTenant();
}
}
@@ -23,9 +23,11 @@ package org.apache.fineract.portfolio.chequebook.domain;
* stolen); ACTIVE&lt;-&gt;STOP_PAYMENT (the one round-trip, via stop-payment/revoke); ACTIVE-&gt;CANCELLED
* (spoiled leaf voided); ACTIVE-&gt;LOST (reported lost/stolen post-issuance); ACTIVE-&gt;WITHDRAWN (account
* closed); ACTIVE-&gt;USED (manually recorded once the branch sees the leaf clear on the bank/clearing
* statement — this CBS has no clearing/presentment integration, so USED can never be set automatically).
* LOST, CANCELLED, WITHDRAWN and USED are all terminal — a recovered "lost" cheque conventionally stays
* hotlisted since the customer has typically already been issued a replacement book.
* statement — this CBS has no clearing/presentment integration, so USED can never be set automatically);
* ACTIVE-&gt;RETURNED (presented and dishonoured/bounced — insufficient funds, signature mismatch, etc.).
* LOST, CANCELLED, WITHDRAWN, USED and RETURNED are all terminal — a recovered "lost" cheque conventionally
* stays hotlisted since the customer has typically already been issued a replacement book, and a returned
* leaf stays flagged rather than silently reverting to ACTIVE.
*/
public enum ChequeLeafStatus {
@@ -34,5 +36,6 @@ public enum ChequeLeafStatus {
CANCELLED, //
LOST, //
WITHDRAWN, //
USED; //
USED, //
RETURNED; //
}
@@ -0,0 +1,42 @@
/**
* 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.chequebook.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.chequebook.service.ChequeBookWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "CHEQUEBOOK", action = "RETURN")
@RequiredArgsConstructor
public class ReturnChequeBookCommandHandler implements NewCommandSourceHandler {
private final ChequeBookWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.returnChequeBook(command.entityId(), command);
}
}
@@ -296,6 +296,7 @@ public class ChequeBookReadPlatformServiceImpl implements ChequeBookReadPlatform
.lostReason(leaf.getLostReason()).lostReportedBy(leaf.getLostReportedBy()).lostReportedOn(leaf.getLostReportedOn())
.cancelledReason(leaf.getCancelledReason()).cancelledBy(leaf.getCancelledBy()).cancelledOn(leaf.getCancelledOn())
.usedReason(leaf.getUsedReason()).usedBy(leaf.getUsedBy()).usedOn(leaf.getUsedOn())
.returnReason(leaf.getReturnReason()).returnedBy(leaf.getReturnedBy()).returnedOn(leaf.getReturnedOn())
.accountType(leaf.getAccountType()).accountId(leaf.getAccountId()).accountNo(display.accountNo)
.clientName(display.clientName).officeName(leaf.getOfficeName()).build();
}
@@ -448,6 +449,7 @@ public class ChequeBookReadPlatformServiceImpl implements ChequeBookReadPlatform
+ "f.lost_reported_on as lost_reported_on, f.cancelled_reason as cancelled_reason, "
+ "f.cancelled_by as cancelled_by, f.cancelled_on as cancelled_on, "
+ "f.used_reason as used_reason, f.used_by as used_by, f.used_on as used_on, "
+ "f.return_reason as return_reason, f.returned_by as returned_by, f.returned_on as returned_on, "
+ "b.account_type as account_type, b.account_id as account_id, bo.name as book_office_name, "
+ "lo.name as lot_office_name "
+ "from m_cheque_leaf f left join m_cheque_book b on b.id = f.cheque_book_id left join m_office bo on bo.id = b.office_id "
@@ -469,6 +471,8 @@ public class ChequeBookReadPlatformServiceImpl implements ChequeBookReadPlatform
.cancelledReason(rs.getString("cancelled_reason")).cancelledBy((Long) rs.getObject("cancelled_by"))
.cancelledOn(rs.getObject("cancelled_on", OffsetDateTime.class)).usedReason(rs.getString("used_reason"))
.usedBy((Long) rs.getObject("used_by")).usedOn(rs.getObject("used_on", OffsetDateTime.class))
.returnReason(rs.getString("return_reason")).returnedBy((Long) rs.getObject("returned_by"))
.returnedOn(rs.getObject("returned_on", OffsetDateTime.class))
.accountType(rs.getString("account_type")).accountId((Long) rs.getObject("account_id")).accountNo(null)
.clientName(null).officeName(officeName).build();
}
@@ -61,6 +61,9 @@ public interface ChequeBookWritePlatformService {
CommandProcessingResult cancelLeaf(Long chequeBookId, JsonCommand command);
/** Flags leaves in range as RETURNED — presented and dishonoured/bounced. Terminal, like reportlost. */
CommandProcessingResult returnChequeBook(Long chequeBookId, JsonCommand command);
/**
* Manually records that leaves cleared, once a branch sees them on the bank/clearing statement — never
* automatic, since this CBS has no clearing/presentment integration to detect it on its own.
@@ -541,6 +541,21 @@ public class ChequeBookWritePlatformServiceImpl implements ChequeBookWritePlatfo
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(chequeBookId).build();
}
@Transactional
@Override
public CommandProcessingResult returnChequeBook(final Long chequeBookId, final JsonCommand command) {
this.validator.validateForBookLeafAction(command.json(), true);
final ChequeBook book = this.chequeBookRepositoryWrapper.findOneWithNotFoundDetection(chequeBookId);
final String reason = command.stringValueOfParameterNamed(ChequeBookApiJsonInputParams.REASON.getValue());
final Long actorId = this.context.authenticatedUser().getId();
final List<ChequeLeaf> leaves = resolveLeavesInRange(book, command);
for (final ChequeLeaf leaf : leaves) {
leaf.returned(reason, actorId);
}
this.chequeLeafRepository.saveAll(leaves);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(chequeBookId).build();
}
@Transactional
@Override
public CommandProcessingResult markLeafUsed(final Long chequeBookId, final JsonCommand command) {
@@ -408,4 +408,5 @@
<include file="parts/0385_chequebook_pending_requests.xml" relativeToChangelogFile="true"/>
<include file="parts/0386_enable_global_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0387_chequeleaf_serial_unique_per_lot.xml" relativeToChangelogFile="true"/>
<include file="parts/0388_chequeleaf_returned_status.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<!--
A RETURNED status for a cheque leaf — the leaf was presented for payment and dishonoured/bounced
(insufficient funds, signature mismatch, etc.), as distinct from STOP_PAYMENT (drawer-initiated) or
LOST (custody failure). Follows the same free-text reason pattern already used for lost_reason/
cancelled_reason/used_reason on this table, rather than introducing a one-off code-value list.
Terminal like LOST/CANCELLED/USED — no revoke, matching this table's existing conventions.
-->
<changeSet id="0388-cheque-leaf-returned-columns" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cheque_leaf" columnName="return_reason"/></not>
</preConditions>
<addColumn tableName="m_cheque_leaf">
<column name="return_reason" type="VARCHAR(500)"/>
<column name="returned_by" type="BIGINT"/>
<column name="returned_on" type="TIMESTAMP WITH TIME ZONE"/>
</addColumn>
</changeSet>
<changeSet id="0388-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'RETURN_CHEQUEBOOK', 'CHEQUEBOOK', 'RETURN', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'RETURN_CHEQUEBOOK');
</sql>
</changeSet>
</databaseChangeLog>