Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b |
+62
@@ -57,14 +57,23 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
"9752123456", "9752345678", "9752987654"
|
"9752123456", "9752345678", "9752987654"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Demo numbers that always return DISCONNECTED (found on the MNRL) without calling the external API. */
|
||||||
|
private static final Set<String> DEMO_DISCONNECTED_NUMBERS = Set.of("9000000001", "9000000002");
|
||||||
|
|
||||||
|
/** Demo numbers that always return SUSPECTED_AND_DISCONNECTED without calling the external API. */
|
||||||
|
private static final Set<String> DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS = Set.of("9000000003");
|
||||||
|
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
|
private final boolean enabled;
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final MobileLookupLogRepository logRepository;
|
private final MobileLookupLogRepository logRepository;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
|
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
|
||||||
|
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
|
||||||
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
|
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.enabled = enabled;
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
this.logRepository = logRepository;
|
this.logRepository = logRepository;
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
@@ -103,6 +112,39 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
return MobileLookupResult.clean(logId, trimmed);
|
return MobileLookupResult.clean(logId, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Demo numbers — return DISCONNECTED (on the MNRL) without calling the external API
|
||||||
|
if (isDemoDisconnectedNumber(trimmed)) {
|
||||||
|
LOG.info("Demo disconnected number detected: {}", trimmed);
|
||||||
|
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
|
||||||
|
"Number revoked and listed on MNRL (demo data)", "DEMO");
|
||||||
|
final Long logId = saveLog(trimmed, "DISCONNECTED", false, true,
|
||||||
|
"Number is DISCONNECTED — found in MNRL records", null, source, clientId);
|
||||||
|
return MobileLookupResult.disconnected(logId, trimmed, disconnectedDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo numbers — return SUSPECTED_AND_DISCONNECTED without calling the external API
|
||||||
|
if (isDemoSuspectedAndDisconnectedNumber(trimmed)) {
|
||||||
|
LOG.info("Demo suspected+disconnected number detected: {}", trimmed);
|
||||||
|
final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected(
|
||||||
|
"active", "Demo DIP suspected number", "DEMO");
|
||||||
|
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
|
||||||
|
"Number revoked and listed on MNRL (demo data)", "DEMO");
|
||||||
|
final Long logId = saveLog(trimmed, "SUSPECTED_AND_DISCONNECTED", true, true,
|
||||||
|
"Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", null, source, clientId);
|
||||||
|
return MobileLookupResult.suspectedAndDisconnected(logId, trimmed, suspectedDetail, disconnectedDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup switched off by config (fineract.mobile-lookup.enabled=false) -- skip the external
|
||||||
|
// call entirely rather than fail with a connection error. Logged distinctly from a genuine
|
||||||
|
// CLEAN result so it can never be mistaken for one in an audit trail.
|
||||||
|
if (!enabled) {
|
||||||
|
LOG.info("DIP/MNRL lookup is disabled by configuration — skipping check for {}", trimmed);
|
||||||
|
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_CLEAN, false, false,
|
||||||
|
"DIP/MNRL lookup is disabled by configuration — the number was not actually checked.",
|
||||||
|
"{\"provider\":\"LOOKUP_DISABLED_BY_CONFIG\"}", source, clientId);
|
||||||
|
return MobileLookupResult.clean(logId, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
|
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
|
||||||
|
|
||||||
LOG.info("Calling DIP/MNRL lookup API: {}", url);
|
LOG.info("Calling DIP/MNRL lookup API: {}", url);
|
||||||
@@ -402,6 +444,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isDemoDisconnectedNumber(final String mobileNumber) {
|
||||||
|
if (DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
|
||||||
|
return DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDemoSuspectedAndDisconnectedNumber(final String mobileNumber) {
|
||||||
|
if (DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
|
||||||
|
return DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private String getStringOrNull(JsonObject json, String key) {
|
private String getStringOrNull(JsonObject json, String key) {
|
||||||
JsonElement el = json.get(key);
|
JsonElement el = json.get(key);
|
||||||
return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
|
return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
|
||||||
|
|||||||
+69
@@ -18,6 +18,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.api;
|
package org.apache.fineract.portfolio.savings.api;
|
||||||
|
|
||||||
|
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION;
|
||||||
|
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
|
||||||
|
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME;
|
||||||
|
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
@@ -26,6 +31,7 @@ import jakarta.ws.rs.Consumes;
|
|||||||
import jakarta.ws.rs.DELETE;
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.HeaderParam;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
@@ -34,9 +40,11 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.Context;
|
import jakarta.ws.rs.core.Context;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
import jakarta.ws.rs.core.UriInfo;
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -50,6 +58,10 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiResource;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
|
||||||
|
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
|
||||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
import org.apache.fineract.infrastructure.security.service.SqlValidator;
|
||||||
@@ -63,6 +75,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
|
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
|
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
|
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
|
||||||
|
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
|
||||||
|
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
|
||||||
|
import org.glassfish.jersey.media.multipart.FormDataParam;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/currentaccounts")
|
@Path("/v1/currentaccounts")
|
||||||
@@ -84,6 +99,10 @@ public class CurrentAccountsApiResource {
|
|||||||
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
|
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
|
||||||
|
private final DocumentApiResource documentApiResource;
|
||||||
|
|
||||||
|
/** Matches the {@code entityType} current-account document requests are stored/looked up under. */
|
||||||
|
private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts";
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("template")
|
@Path("template")
|
||||||
@@ -337,4 +356,54 @@ public class CurrentAccountsApiResource {
|
|||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Documents ────────────────────────────────────────────────────────────
|
||||||
|
// The generic /v1/{entityType}/{entityId}/documents resource (DocumentApiResource) never gets a
|
||||||
|
// chance to handle current-account requests: this class's own literal /v1/currentaccounts path
|
||||||
|
// is matched first for anything under {accountId}, and it has no "documents" method, so the
|
||||||
|
// request dead-ends in a 404 instead of falling through. These delegate straight to the same
|
||||||
|
// DocumentApiResource so current accounts get identical document-management behaviour.
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{accountId}/documents")
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List documents attached to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK")
|
||||||
|
public List<DocumentData> retrieveDocuments(@PathParam("accountId") final Long accountId) {
|
||||||
|
return documentApiResource.retrieveAllDocuments(DOCUMENT_ENTITY_TYPE, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{accountId}/documents/{documentId}/attachment")
|
||||||
|
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
|
||||||
|
@Operation(summary = "Download a document attached to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
|
||||||
|
public Response downloadDocument(@PathParam("accountId") final Long accountId, @PathParam("documentId") final Long documentId) {
|
||||||
|
return documentApiResource.downloadFile(DOCUMENT_ENTITY_TYPE, accountId, documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{accountId}/documents")
|
||||||
|
@Consumes({ MediaType.MULTIPART_FORM_DATA })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Attach a document to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
|
||||||
|
public DocumentCreateResponse createDocument(@PathParam("accountId") final Long accountId,
|
||||||
|
@HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
|
||||||
|
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
|
||||||
|
return documentApiResource.createDocument(DOCUMENT_ENTITY_TYPE, accountId, fileSize, inputStream, fileDetails, filePart, name,
|
||||||
|
description);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("{accountId}/documents/{documentId}")
|
||||||
|
@Operation(summary = "Remove a document attached to a current account")
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK")
|
||||||
|
public DocumentDeleteResponse deleteDocument(@PathParam("accountId") final Long accountId,
|
||||||
|
@PathParam("documentId") final Long documentId) {
|
||||||
|
return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -435,7 +435,10 @@ public class FixedDepositAccount extends SavingsAccount {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Money activateWithBalance() {
|
public Money activateWithBalance() {
|
||||||
return Money.of(this.currency, this.accountTermAndPreClosure.depositAmount());
|
// Activation never deposits money -- funding happens strictly through a separate deposit
|
||||||
|
// transaction (posted by a clerk) after the account is already active, regardless of the
|
||||||
|
// deposit amount configured on the account's term/pre-closure details.
|
||||||
|
return Money.zero(this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
|
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
|
||||||
|
|||||||
+4
-1
@@ -552,7 +552,10 @@ public class RecurringDepositAccount extends SavingsAccount {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Money activateWithBalance() {
|
public Money activateWithBalance() {
|
||||||
return Money.of(this.currency, this.minRequiredOpeningBalance);
|
// Activation never deposits money -- funding happens strictly through a separate deposit
|
||||||
|
// transaction (posted by a clerk) after the account is already active, regardless of any
|
||||||
|
// minimum required opening balance the product has configured.
|
||||||
|
return Money.zero(this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
|
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
|
||||||
|
|||||||
+4
-13
@@ -19,7 +19,6 @@
|
|||||||
package org.apache.fineract.portfolio.savings.domain;
|
package org.apache.fineract.portfolio.savings.domain;
|
||||||
|
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountFacilityConfigParamName;
|
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
|
||||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
|
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
|
||||||
@@ -455,18 +454,10 @@ public class SavingsAccountAssembler {
|
|||||||
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
|
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
|
||||||
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
|
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
|
||||||
|
|
||||||
// Account Facility Config
|
// Every digital facility starts disabled at account opening, regardless of anything the
|
||||||
if (element.getAsJsonObject().has(accountFacilityConfigParamName)) {
|
// request sends or the product defaults to -- they're switched on deliberately, one at a
|
||||||
final JsonElement configEl = element.getAsJsonObject().get(accountFacilityConfigParamName);
|
// time, later, through the account's own Edit Nominee & Features screen.
|
||||||
if (configEl != null && !configEl.isJsonNull() && configEl.isJsonObject()) {
|
account.setFacilityConfig(new SavingsAccountFacilityConfig(account));
|
||||||
SavingsAccountFacilityConfig facilityConfig = account.getFacilityConfig();
|
|
||||||
if (facilityConfig == null) {
|
|
||||||
facilityConfig = new SavingsAccountFacilityConfig(account);
|
|
||||||
account.setFacilityConfig(facilityConfig);
|
|
||||||
}
|
|
||||||
facilityConfig.updateFrom(configEl.getAsJsonObject());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Joint holders
|
// Joint holders
|
||||||
if (element.getAsJsonObject().has(jointHoldersParamName)) {
|
if (element.getAsJsonObject().has(jointHoldersParamName)) {
|
||||||
|
|||||||
+3
-11
@@ -133,18 +133,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
|
|||||||
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
|
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum required balance validation
|
// Minimum required balance is not enforced -- a withdrawal must never be blocked for
|
||||||
|
// taking the account below its configured minimum, only for exceeding the available
|
||||||
|
// balance (checked above).
|
||||||
final BigDecimal newBalance = currentBalance.subtract(amount);
|
final BigDecimal newBalance = currentBalance.subtract(amount);
|
||||||
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
|
|
||||||
&& account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
|
|
||||||
throw new GeneralPlatformDomainRuleException(
|
|
||||||
"error.msg.current.account.min.required.balance.breached",
|
|
||||||
"Withdrawal of " + amount + " would breach the minimum required balance of "
|
|
||||||
+ account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance,
|
|
||||||
accountId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final String refNo = command.stringValueOfParameterNamed("accountNumber");
|
final String refNo = command.stringValueOfParameterNamed("accountNumber");
|
||||||
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
|
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
|
||||||
|
|||||||
+11
-151
@@ -227,12 +227,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
|
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facility config — UI sends "accountFacilityConfig", internal uses "facilityConfig"
|
// Every digital facility starts disabled at account opening, regardless of anything the
|
||||||
JsonObject fcJson = json.getAsJsonObject("accountFacilityConfig");
|
// request sends or the product defaults to -- they're switched on deliberately, one at a
|
||||||
if (fcJson == null) fcJson = json.getAsJsonObject("facilityConfig");
|
// time, later, through the account's own Edit Nominee & Features screen.
|
||||||
if (fcJson != null) {
|
account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account));
|
||||||
account.setFacilityConfig(assembleFacilityConfig(account, fcJson));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Joint holders
|
// Joint holders
|
||||||
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
|
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
|
||||||
@@ -294,61 +292,16 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
final JsonObject json = command.parsedJson().getAsJsonObject();
|
final JsonObject json = command.parsedJson().getAsJsonObject();
|
||||||
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
|
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
|
||||||
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
// Validate minimum opening balance before activation
|
|
||||||
java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO;
|
|
||||||
if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) {
|
|
||||||
try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {
|
|
||||||
// intentionally ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance();
|
|
||||||
if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0
|
|
||||||
&& openingBalanceValue.compareTo(minRequired) < 0) {
|
|
||||||
throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException(
|
|
||||||
java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError(
|
|
||||||
"openingBalance",
|
|
||||||
"Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")",
|
|
||||||
"validation.msg.currentaccount.openingBalance.less.than.min.required",
|
|
||||||
openingBalanceValue, minRequired)));
|
|
||||||
}
|
|
||||||
|
|
||||||
account.activate(activatedOnDate, user);
|
account.activate(activatedOnDate, user);
|
||||||
applyFacilityChargesOnActivation(account);
|
|
||||||
|
|
||||||
java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO;
|
// Activation posts no transaction of its own -- no opening-balance deposit, no
|
||||||
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns = new java.util.ArrayList<>();
|
// digital-facility charges applied or collected, regardless of what the account's facility
|
||||||
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
|
// config or product defaults say. Funding and any facility charges happen strictly
|
||||||
|
// afterwards, through separate transactions a clerk processes once the account is already
|
||||||
// Opening balance deposit transaction
|
// active.
|
||||||
if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) {
|
|
||||||
try {
|
|
||||||
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit =
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
|
|
||||||
.deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE");
|
|
||||||
deposit.setRunningBalanceDerived(openingBalanceValue);
|
|
||||||
account.setTotalDepositsDerived(openingBalanceValue);
|
|
||||||
currentAccountTransactionRepository.saveAndFlush(deposit);
|
|
||||||
currentBalance = openingBalanceValue;
|
|
||||||
depositTxns.add(deposit);
|
|
||||||
log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
|
|
||||||
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> activationCharges =
|
|
||||||
account.getCharges().stream()
|
|
||||||
.filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid())
|
|
||||||
.toList();
|
|
||||||
currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries);
|
|
||||||
|
|
||||||
account.setAccountBalanceDerived(currentBalance);
|
|
||||||
currentAccountRepository.saveAndFlush(account);
|
currentAccountRepository.saveAndFlush(account);
|
||||||
|
|
||||||
// Post GL journal entries — deposits in batch, each charge individually
|
|
||||||
postGlEntries(account, depositTxns, chargeEntries);
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,78 +590,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyFacilityChargesOnActivation(final CurrentAccount account) {
|
|
||||||
// Use account-level facility config — reflects what was selected at account opening time
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config =
|
|
||||||
account.getFacilityConfig();
|
|
||||||
|
|
||||||
log.info("[FacilityCharge] Activating account id={}, facilityConfig={}",
|
|
||||||
account.getId(), config != null ? "FOUND" : "NULL");
|
|
||||||
|
|
||||||
if (config == null) {
|
|
||||||
// Fallback: use product-level facility config
|
|
||||||
log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId());
|
|
||||||
config = currentProductRepository.findById(account.getProductId())
|
|
||||||
.map(p -> {
|
|
||||||
final var pc = p.getFacilityConfig();
|
|
||||||
if (pc == null) {
|
|
||||||
log.info("[FacilityCharge] Product has no facility config either");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Map product config to account config fields inline
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac =
|
|
||||||
new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig();
|
|
||||||
ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility()));
|
|
||||||
ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility()));
|
|
||||||
ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility()));
|
|
||||||
ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility()));
|
|
||||||
ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook()));
|
|
||||||
ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility()));
|
|
||||||
ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts()));
|
|
||||||
ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility()));
|
|
||||||
ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover()));
|
|
||||||
ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility()));
|
|
||||||
ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility()));
|
|
||||||
ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility()));
|
|
||||||
ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility()));
|
|
||||||
ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility()));
|
|
||||||
ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc()));
|
|
||||||
ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard()));
|
|
||||||
return ac;
|
|
||||||
}).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config == null) {
|
|
||||||
log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config;
|
|
||||||
log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}",
|
|
||||||
fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(),
|
|
||||||
fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(),
|
|
||||||
fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(),
|
|
||||||
fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility());
|
|
||||||
applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY");
|
|
||||||
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
|
|
||||||
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
|
|
||||||
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
|
|
||||||
// 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");
|
|
||||||
applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER");
|
|
||||||
applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY");
|
|
||||||
applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY");
|
|
||||||
applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY");
|
|
||||||
applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP");
|
|
||||||
applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY");
|
|
||||||
applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC");
|
|
||||||
applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper to carry a charge transaction together with its charge entity for GL posting
|
// Wrapper to carry a charge transaction together with its charge entity for GL posting
|
||||||
private record ChargeGlEntry(
|
private record ChargeGlEntry(
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
|
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
|
||||||
@@ -717,9 +598,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
/**
|
/**
|
||||||
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
|
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
|
||||||
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
|
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
|
||||||
* posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and
|
* posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) --
|
||||||
* {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the
|
* activation itself no longer collects any charges. Returns the resulting balance.
|
||||||
* charge-collection logic isn't duplicated. Returns the resulting balance.
|
|
||||||
*/
|
*/
|
||||||
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
|
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
|
||||||
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
|
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
|
||||||
@@ -875,24 +755,4 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
|
|
||||||
if (!enabled) {
|
|
||||||
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
|
|
||||||
if (mappingOpt.isEmpty()) {
|
|
||||||
log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
|
|
||||||
if (charge == null) {
|
|
||||||
log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType);
|
|
||||||
account.getCharges().add(
|
|
||||||
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge
|
|
||||||
.createFromFacility(account, charge));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-6
@@ -172,8 +172,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
|
|
||||||
if (portfolioAccountData == null) {
|
if (portfolioAccountData == null) {
|
||||||
final PaymentDetail paymentDetail = null;
|
final PaymentDetail paymentDetail = null;
|
||||||
this.depositAccountDomainService.handleFDDeposit(account, fmt, account.getActivationDate(),
|
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleFDDeposit(account, fmt,
|
||||||
amountForDeposit.getAmount(), paymentDetail);
|
account.getActivationDate(), amountForDeposit.getAmount(), paymentDetail);
|
||||||
|
// handleFDDeposit() already posts this transaction's own journal entries internally --
|
||||||
|
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
|
||||||
|
// activation-time changes, e.g. an activation charge payment) does not post it again.
|
||||||
|
if (depositTransaction != null && depositTransaction.getId() != null) {
|
||||||
|
existingTransactionIds.add(depositTransaction.getId());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final SavingsAccount fromSavingsAccount = null;
|
final SavingsAccount fromSavingsAccount = null;
|
||||||
boolean isRegularTransaction = false;
|
boolean isRegularTransaction = false;
|
||||||
@@ -202,8 +208,17 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
}
|
}
|
||||||
|
|
||||||
final boolean isPreMatureClosure = false;
|
final boolean isPreMatureClosure = false;
|
||||||
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
if (amountForDeposit.isGreaterThanZero()) {
|
||||||
financialYearBeginningMonth);
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
} else {
|
||||||
|
// No deposit has actually landed on the account yet -- project maturity using the
|
||||||
|
// configured deposit amount (the same approach used for the pre-activation preview and
|
||||||
|
// for reinvested FDs) instead of the account's real, still-empty transaction history,
|
||||||
|
// which would otherwise price in zero interest.
|
||||||
|
account.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
|
||||||
|
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||||
|
}
|
||||||
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
|
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
|
||||||
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
|
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
|
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
|
||||||
@@ -275,8 +290,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
|
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
|
||||||
.retriveSavingsLinkedAssociation(savingsId);
|
.retriveSavingsLinkedAssociation(savingsId);
|
||||||
if (portfolioAccountData == null) {
|
if (portfolioAccountData == null) {
|
||||||
this.depositAccountDomainService.handleRDDeposit(account, fmt, account.getActivationDate(),
|
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleRDDeposit(account, fmt,
|
||||||
amountForDeposit.getAmount(), null, isRegularTransaction);
|
account.getActivationDate(), amountForDeposit.getAmount(), null, isRegularTransaction);
|
||||||
|
// handleRDDeposit() already posts this transaction's own journal entries internally --
|
||||||
|
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
|
||||||
|
// activation-time changes) does not post it again.
|
||||||
|
if (depositTransaction != null && depositTransaction.getId() != null) {
|
||||||
|
existingTransactionIds.add(depositTransaction.getId());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final boolean isExceptionForBalanceCheck = false;
|
final boolean isExceptionForBalanceCheck = false;
|
||||||
final SavingsAccount fromSavingsAccount = null;
|
final SavingsAccount fromSavingsAccount = null;
|
||||||
@@ -368,6 +389,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
|||||||
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
|
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
|
||||||
transactionAmount, paymentDetail);
|
transactionAmount, paymentDetail);
|
||||||
|
|
||||||
|
// The account may have been activated with no funds yet (opening deposit is now a separate,
|
||||||
|
// later step) -- in that case its maturity amount was only ever projected off the configured
|
||||||
|
// deposit amount, with zero real transactions. Now that money has actually landed, recompute
|
||||||
|
// maturity date/amount off the account's real transaction history so it reflects reality.
|
||||||
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||||
|
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||||
|
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||||
|
final MathContext mc = MathContext.DECIMAL64;
|
||||||
|
final boolean isPreMatureClosure = false;
|
||||||
|
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||||
|
|
||||||
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
|
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
|
||||||
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
|
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
|
||||||
"DEPOSIT");
|
"DEPOSIT");
|
||||||
|
|||||||
+7
-1
@@ -220,10 +220,16 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
|
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Post the real charge(s) for newly-enabled facilities, today.
|
// 2) Post the real charge(s) for newly-enabled facilities, today -- and post the matching
|
||||||
|
// GL journal entries for those charge transactions (collectCharges() only books them
|
||||||
|
// against the account itself; it does not touch the general ledger).
|
||||||
if (!newlyEnabledFacilityTypes.isEmpty()) {
|
if (!newlyEnabledFacilityTypes.isEmpty()) {
|
||||||
|
final Set<Long> existingTransactionIds = new HashSet<>(account.findExistingTransactionIds());
|
||||||
|
final Set<Long> existingReversedTransactionIds = new HashSet<>(account.findExistingReversedTransactionIds());
|
||||||
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
|
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
|
||||||
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
|
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
|
||||||
|
this.savingsAccountWritePlatformService.postJournalEntriesForSavingsAccount(account, existingTransactionIds,
|
||||||
|
existingReversedTransactionIds, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Apply the facility-config toggles to the real row.
|
// 3) Apply the facility-config toggles to the real row.
|
||||||
|
|||||||
+13
-27
@@ -97,7 +97,6 @@ import org.apache.fineract.portfolio.note.domain.Note;
|
|||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
|
|
||||||
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||||
@@ -167,7 +166,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
private final GSIMRepositoy gsimRepository;
|
private final GSIMRepositoy gsimRepository;
|
||||||
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
|
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
|
||||||
private final ErrorHandler errorHandler;
|
private final ErrorHandler errorHandler;
|
||||||
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
|
|
||||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -203,23 +201,9 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
|
|
||||||
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
|
||||||
|
|
||||||
// Validate minimum opening balance
|
// Minimum required opening balance is not enforced -- activation must never be blocked
|
||||||
BigDecimal openingBalanceForValidation = BigDecimal.ZERO;
|
// for not meeting it; the account can be funded afterwards by whatever deposit the
|
||||||
if (command.parameterExists("openingBalance")) {
|
// customer brings (cash or a transfer), on their own timeline.
|
||||||
final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance");
|
|
||||||
if (ob != null) openingBalanceForValidation = ob;
|
|
||||||
}
|
|
||||||
final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance();
|
|
||||||
if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0
|
|
||||||
&& openingBalanceForValidation.compareTo(minRequiredOpening) < 0) {
|
|
||||||
final List<ApiParameterError> errors = new ArrayList<>();
|
|
||||||
errors.add(ApiParameterError.parameterError(
|
|
||||||
"openingBalance",
|
|
||||||
"Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")",
|
|
||||||
"validation.msg.savingsaccount.openingBalance.less.than.min.required",
|
|
||||||
openingBalanceForValidation, minRequiredOpening));
|
|
||||||
throw new PlatformApiDataValidationException(errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkClientOrGroupActive(account);
|
checkClientOrGroupActive(account);
|
||||||
final Set<Long> existingTransactionIds = new HashSet<>();
|
final Set<Long> existingTransactionIds = new HashSet<>();
|
||||||
@@ -236,14 +220,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
final Locale locale = command.extractLocale();
|
final Locale locale = command.extractLocale();
|
||||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||||
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
|
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
|
||||||
// Auto-apply facility charges based on product facility config
|
// Activation posts no transaction of its own -- digital-facility charges are no longer
|
||||||
facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account);
|
// auto-applied and auto-collected here. Whatever facilities are turned on later get
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
// charged (if at all) through the normal charge/collection flow at that time, not as a
|
||||||
// Collect any newly added facility activation charges (after flush so amountOutstanding is set)
|
// side effect of activation.
|
||||||
// payCharge() internally adds transactions to account.transactions via handleChargeTransactions
|
|
||||||
// Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting
|
|
||||||
facilityChargeAutoApplyService.collectActivationCharges(account);
|
|
||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
|
||||||
@@ -1521,6 +1501,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postJournalEntriesForSavingsAccount(final SavingsAccount account, final Set<Long> existingTransactionIds,
|
||||||
|
final Set<Long> existingReversedTransactionIds, final boolean backdatedTxnsAllowedTill) {
|
||||||
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
|
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -463,8 +463,7 @@ public class SavingsConfiguration {
|
|||||||
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
||||||
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
||||||
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
||||||
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
|
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
|
||||||
DenominationBreakdownWriter denominationBreakdownWriter) {
|
|
||||||
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
|
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
|
||||||
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
|
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
|
||||||
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
||||||
@@ -472,7 +471,7 @@ public class SavingsConfiguration {
|
|||||||
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
||||||
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
|
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
|
||||||
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
|
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
|
||||||
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
|
errorHandler, denominationBreakdownWriter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -688,6 +688,13 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
|
|||||||
# │ DIP/MNRL provider. Do not ship the mock. │
|
# │ DIP/MNRL provider. Do not ship the mock. │
|
||||||
# └──────────────────────────────────────────────────────────────────────────┘
|
# └──────────────────────────────────────────────────────────────────────────┘
|
||||||
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090}
|
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090}
|
||||||
|
# Set to false to skip the DIP/MNRL check entirely (no HTTP call at all, no need to run the mock
|
||||||
|
# service) -- every number is then logged as CLEAN but stamped "LOOKUP_DISABLED_BY_CONFIG" so it is
|
||||||
|
# never mistaken for a real pass.
|
||||||
|
# Currently OFF by default for local work so nobody needs the mock service running. Set
|
||||||
|
# FINERACT_MOBILE_LOOKUP_ENABLED=true (or flip the default below) before UAT/production, where the
|
||||||
|
# check must actually run.
|
||||||
|
fineract.mobile-lookup.enabled=${FINERACT_MOBILE_LOOKUP_ENABLED:false}
|
||||||
|
|
||||||
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
|
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
|
||||||
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
|
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
|
||||||
|
|||||||
+7
-4
@@ -3008,7 +3008,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Money activateWithBalance() {
|
public Money activateWithBalance() {
|
||||||
return Money.of(this.currency, this.minRequiredOpeningBalance);
|
// Activation never deposits money -- funding happens strictly through a separate deposit
|
||||||
|
// transaction (posted by a clerk) after the account is already active, regardless of any
|
||||||
|
// minimum required opening balance the product has configured.
|
||||||
|
return Money.zero(this.currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) {
|
public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) {
|
||||||
@@ -3700,10 +3703,10 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Money minRequiredBalanceDerived(final MonetaryCurrency currency) {
|
private Money minRequiredBalanceDerived(final MonetaryCurrency currency) {
|
||||||
|
// Minimum required balance is not enforced -- withdrawals (and every other balance check
|
||||||
|
// that goes through this) must never be blocked by a configured minimum balance. Overdraft
|
||||||
|
// headroom is a separate concept and still applies.
|
||||||
Money minReqBalance = Money.zero(currency);
|
Money minReqBalance = Money.zero(currency);
|
||||||
if (this.enforceMinRequiredBalance) {
|
|
||||||
minReqBalance = minReqBalance.plus(this.minRequiredBalance);
|
|
||||||
}
|
|
||||||
if (this.allowOverdraft) {
|
if (this.allowOverdraft) {
|
||||||
minReqBalance = minReqBalance.minus(this.overdraftLimit);
|
minReqBalance = minReqBalance.minus(this.overdraftLimit);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -160,7 +160,11 @@ public class FacilityChargeAutoApplyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!transactions.isEmpty()) {
|
if (!transactions.isEmpty()) {
|
||||||
savingsAccountTransactionRepository.saveAll(transactions);
|
// Flush immediately (not just save) so each transaction gets its DB-generated id right
|
||||||
|
// away -- the caller posts GL journal entries for these transactions straight after this
|
||||||
|
// call returns, and that requires a real id on each one, not one that only appears at
|
||||||
|
// end-of-transaction commit.
|
||||||
|
savingsAccountTransactionRepository.saveAllAndFlush(transactions);
|
||||||
account.recalculateBalancesAfterFacilityCharges(transactionDate);
|
account.recalculateBalancesAfterFacilityCharges(transactionDate);
|
||||||
}
|
}
|
||||||
return transactions;
|
return transactions;
|
||||||
|
|||||||
+3
@@ -79,6 +79,9 @@ public interface SavingsAccountWritePlatformService {
|
|||||||
void processPostActiveActions(SavingsAccount account, DateTimeFormatter fmt, Set<Long> existingTransactionIds,
|
void processPostActiveActions(SavingsAccount account, DateTimeFormatter fmt, Set<Long> existingTransactionIds,
|
||||||
Set<Long> existingReversedTransactionIds);
|
Set<Long> existingReversedTransactionIds);
|
||||||
|
|
||||||
|
void postJournalEntriesForSavingsAccount(SavingsAccount account, Set<Long> existingTransactionIds,
|
||||||
|
Set<Long> existingReversedTransactionIds, boolean backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
CommandProcessingResult modifyWithHoldTax(Long savingsAccountId, JsonCommand command);
|
CommandProcessingResult modifyWithHoldTax(Long savingsAccountId, JsonCommand command);
|
||||||
|
|
||||||
void setSubStatusInactive(Long savingsId);
|
void setSubStatusInactive(Long savingsId);
|
||||||
|
|||||||
Reference in New Issue
Block a user