Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
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 E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Documentation build / build (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 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 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 E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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-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

This commit is contained in:
2026-10-12 10:25:19 +05:30
2 changed files with 137 additions and 12 deletions
@@ -18,20 +18,29 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.FileNotFoundException;
import java.nio.file.NoSuchFileException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService {
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
private static final int MAX_CAUSE_DEPTH = 10;
private final ContentStoreService storeService;
private final DocumentRepository documentRepository;
private final DocumentMapper documentMapper;
@@ -52,9 +61,39 @@ public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformServ
public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) {
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
final var is = storeService.download(doc.getLocation());
try {
final var is = storeService.download(doc.getLocation());
return documentMapper.map(doc, is);
} catch (final AbstractPlatformDomainRuleException e) {
throw e;
} catch (final Exception e) {
if (isMissingFromContentStore(e)) {
// The m_document row survives but the file it points at does not — the usual cause is
// a database copied between environments without its file store (see the identical
// guard in ImageReadPlatformServiceImpl). For the caller that is simply "this document
// has no content", not a server fault, so report it the same way as a missing document
// instead of letting a raw ContentStoreException surface as a 500.
log.warn("Document {} for {} {} is recorded but missing from the content store; treating it as not found", documentId,
entityType, entityId);
throw new DocumentNotFoundException(entityType, entityId, documentId);
}
throw new DocumentInvalidRequestException(e);
}
}
return documentMapper.map(doc, is);
/**
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
* way up (the file store rethrows as ContentStoreException).
*/
private boolean isMissingFromContentStore(final Throwable failure) {
Throwable cause = failure;
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
return true;
}
cause = cause.getCause();
}
return false;
}
@Override
@@ -390,6 +390,101 @@ public class ClerkShareWithdrawalRequestApiResource {
public String validate(@PathParam("id") final Long id) {
final AppUser currentUser = context.authenticatedUser();
final Map<?, ?> record = loadRecord(id);
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(record);
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
/**
* Lets the clerk see the same checklist the approver will see, before the request is even
* submitted — so a request that is going to fail a check (a missing GL mapping, an unverified
* KYC, an open AML alert) is caught while someone can still fix it, not after a manager has
* already spent time reviewing it. Takes the same fields {@link #submit(String)} does and
* applies the identical Sundry-downgrade rule, so what this previews is exactly what would be
* stored and exactly what {@link #validate(Long)} would later report on the saved request —
* never a second, independently-drifting copy of the rules.
*
* <p>
* The one check {@link #validate(Long)} runs that this cannot — "Maker and Checker are
* different users" — has no real answer yet: the clerk previewing this *is* the maker, and no
* checker has been chosen. It is reported here as a fixed reminder rather than silently
* dropped, so the clerk sees why the two lists differ by exactly that one row, and knows the
* rule is still enforced later, at {@code approve()}.
*/
@POST
@Path("/preview-validate")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String previewValidate(final String body) {
context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
if (shareAccountId == null) {
throw apiError(400, "Share account id is required");
}
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
: settlementModeRaw.trim().toUpperCase();
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
// Mirrors submit()'s own downgrade: naming the product's own Share Sundry GL as the
// destination means "stop here", not a real GL settlement — see submit() for the rationale.
if ("GL".equals(settlementMode) && settlementGlAccountId != null) {
final Long productId = jdbcTemplate.queryForList("SELECT product_id FROM m_share_account WHERE id = ?", shareAccountId)
.stream().findFirst().map(r -> ((Number) r.get("product_id")).longValue()).orElse(null);
if (productId != null) {
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
if (glSundry != null && glSundry.equals(settlementGlAccountId)) {
settlementMode = "NONE";
settlementGlAccountId = null;
}
}
}
final Map<String, Object> draftRecord = new java.util.HashMap<>();
draftRecord.put("resolution_number", resolutionNumber);
draftRecord.put("meeting_date", meetingDate);
draftRecord.put("share_account_id", shareAccountId);
draftRecord.put("requested_shares", requestedShares != null ? requestedShares : 0L);
draftRecord.put("settlement_mode", settlementMode);
draftRecord.put("settlement_gl_account_id", settlementGlAccountId);
draftRecord.put("settlement_payment_type_id", settlementPaymentTypeId);
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(draftRecord);
checks.add(checkResult("Maker and Checker are different users", true,
"Verified automatically when a different manager approves"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
/**
* The full checklist a checker sees before approving a share withdrawal — share balance and
* status, loan/tenure/guarantor exposure, settlement destination and payment type, and client
* KYC/risk/AML standing. Shared verbatim between {@link #validate(Long)} (an already-submitted
* request) and {@link #previewValidate(String)} (a draft that does not exist yet), so the two
* can never silently diverge. Reads {@code record} by the same string keys both callers
* populate: resolution_number, meeting_date, share_account_id, requested_shares,
* settlement_mode, settlement_gl_account_id, settlement_payment_type_id.
*/
private List<Map<String, Object>> buildShareWithdrawalChecks(final Map<?, ?> record) {
final List<Map<String, Object>> checks = new ArrayList<>();
final String resolutionNumber = (String) record.get("resolution_number");
@@ -592,16 +687,7 @@ public class ClerkShareWithdrawalRequestApiResource {
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
return checks;
}
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {