DDS: vouchers, maker-checker approvals, payout & close, Sundry GL, closure tab, fixes
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract 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 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
tarunmalviya
2026-10-01 12:47:48 +05:30
parent fa8e5f3f16
commit b0e3c0ac8b
58 changed files with 3716 additions and 172 deletions
@@ -164,12 +164,24 @@ public class CommandSourceService {
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())
|| isDdsEntity(commandSource.getEntityName())) {
return false;
}
return true;
}
/**
* The DDS module's maker-checker entities. A super user's DDS work goes to a checker like anyone
* else's: DDS moves customers' money, and every such move is to be approved by a second person.
*/
public static boolean isDdsEntity(final String entityName) {
return entityName != null && switch (entityName.toUpperCase()) {
case "PARTY", "AGENT", "PARTYTRANSACTION", "DDSACCOUNTTYPE", "COMMISSIONSLAB", "DDSBATCH", "DDSCOLLECTIONENTRY" -> true;
default -> false;
};
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -737,6 +737,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder prematureCloseOverrideParty(final Long partyId) {
this.actionName = "PREMATURECLOSEOVERRIDE";
this.entityName = "PARTY";
this.entityId = partyId;
this.href = "/dds/parties/" + partyId + "?command=prematurecloseoverride";
return this;
}
public CommandWrapperBuilder createDdsAccountType() {
this.actionName = "CREATE";
this.entityName = "DDSACCOUNTTYPE";
@@ -123,7 +123,9 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
AppUser appUser = this.context.authenticatedUser();
String permissionCode = commandSource.getPermissionCode();
appUser.validateHasCheckerPermissionTo(permissionCode);
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
// A DDS entry is never checked by the user who made it, super user or not - see CommandSourceService.isDdsEntity.
if (!configurationService.isSameMakerCheckerEnabled()
&& (!appUser.isCheckerSuperUser() || CommandSourceService.isDdsEntity(commandSource.getEntityName()))) {
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
@@ -53,6 +53,8 @@ public final class DdsAccountTypeData implements Serializable {
private final Integer prematureLockinMonths;
private final BigDecimal prematureInterestReduction;
private final BigDecimal prematureAgentCommissionPenalty;
/** The scheme's annual interest rate, which an account opened on it takes. */
private final BigDecimal interestRate;
private final Boolean active;
private final LocalDateTime createdDate;
private final LocalDateTime lastModifiedDate;
@@ -104,6 +104,13 @@ public class DdsAccountType extends AbstractAuditableCustom {
@Column(name = "premature_agent_commission_penalty", scale = 6, precision = 19, nullable = false)
private BigDecimal prematureAgentCommissionPenalty;
/**
* The scheme's annual interest rate. Mandatory: an account opened on the scheme takes this rate,
* rather than one typed at the counter for each customer.
*/
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal interestRate;
/**
* An account type is retired by deactivating it, never by deleting: Parties are opened against
* it, so removing the row would orphan every account already using it. Inactive types stay out
@@ -130,6 +137,7 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.prematureLockinMonths = valueOr(command, "prematureLockinMonths", DEFAULT_LOCKIN_MONTHS);
this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION);
this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY);
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
// A newly created account type is available for use straight away.
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
}
@@ -241,6 +249,13 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.prematureAgentCommissionPenalty = newValue == null ? BigDecimal.ZERO : newValue;
}
// The scheme's rate - every open account on it follows it (see the account type service).
if (command.parameterExists("interestRate") && command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
actualChanges.put("interestRate", newValue);
this.interestRate = newValue;
}
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
actualChanges.put("active", newValue);
@@ -60,4 +60,7 @@ public class CollectionEntryData {
private final String status;
private final LocalDate settledOn;
private final Long partyTransactionId;
/** Days paid ahead, or days of arrears cleared - empty on an ordinary day's collection. */
private final Integer advanceDays;
private final Integer arrearsDays;
}
@@ -106,6 +106,21 @@ public class CollectionEntry extends AbstractAuditableCustom {
@Column(name = "party_transaction_id")
private Long partyTransactionId;
/**
* The application of the cashier's DDS receipts that settled this entry, when it was settled that
* way - see 0517, which records which vouchers it used. A plain id for the same reason as the
* transaction.
*/
@Column(name = "receipt_application_id")
private Long receiptApplicationId;
/** Days paid ahead, or days of arrears cleared - see 0520. Both empty on an ordinary day's collection. */
@Column(name = "advance_days")
private Integer advanceDays;
@Column(name = "arrears_days")
private Integer arrearsDays;
protected CollectionEntry() {
//
}
@@ -123,6 +138,12 @@ public class CollectionEntry extends AbstractAuditableCustom {
this.status = PENDING;
}
/** Records what the collection covers beyond today, once its amount has been checked against it. */
public void covering(final Integer advanceDays, final Integer arrearsDays) {
this.advanceDays = advanceDays;
this.arrearsDays = arrearsDays;
}
public boolean isPending() {
return PENDING.equals(this.status);
}
@@ -140,6 +161,12 @@ public class CollectionEntry extends AbstractAuditableCustom {
this.partyTransactionId = partyTransactionId;
}
/** Settled against the cashier's receipts: the same as {@link #markSettled}, and which application did it. */
public void markSettledByReceipt(final LocalDate settledOn, final Long partyTransactionId, final Long receiptApplicationId) {
markSettled(settledOn, partyTransactionId);
this.receiptApplicationId = receiptApplicationId;
}
/** Withdraws a line that was never posted. Kept, not deleted - the agent wrote it, so it stands. */
public void markCancelled() {
this.status = CANCELLED;
@@ -76,6 +76,8 @@ public final class PartyData implements Serializable {
*/
private final String subTypeDescription;
private final String partyCode;
/** The DDS account's own number. */
private final String accountNo;
private final String lastAccount;
private final Boolean nominal;
private final String aadharNo;
@@ -100,6 +100,10 @@ public class Party extends AbstractAuditableCustom {
@Column(name = "party_code", length = 20, nullable = false)
private String partyCode;
/** The DDS account's own number - see 0514. Given once the party has an id, and never changed. */
@Column(name = "account_no", length = 20)
private String accountNo;
@Column(name = "last_account", length = 50)
private String lastAccount;
@@ -380,6 +384,16 @@ public class Party extends AbstractAuditableCustom {
recalculateTerm();
}
/**
* Takes the scheme's interest rate. The rate belongs to the scheme, not to the counter: it is set
* when the account is opened or moved to another scheme, and again on every open account whenever
* the bank changes the scheme's rate, with the maturity figure worked out again on it.
*/
public void takeSchemeRate(final BigDecimal schemeRate) {
this.interestRate = schemeRate == null ? BigDecimal.ZERO : schemeRate;
recalculateTerm();
}
/**
* Works out the maturity date and amount from the terms just set.
*
@@ -478,6 +492,13 @@ public class Party extends AbstractAuditableCustom {
this.savingsAccountId = savingsAccountId;
}
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
public void assignAccountNo() {
if (this.accountNo == null && getId() != null) {
this.accountNo = "DDS" + String.format("%09d", getId());
}
}
public BigDecimal credit(final BigDecimal amount) {
this.currentBalance = this.currentBalance.add(amount);
return this.currentBalance;
@@ -670,11 +691,8 @@ public class Party extends AbstractAuditableCustom {
this.days = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
actualChanges.put("interestRate", newValue);
this.interestRate = newValue;
}
// The rate is not edited here: it is the scheme's, taken when the account is opened or moved
// to another scheme - see takeSchemeRate.
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
@@ -49,4 +49,6 @@ public final class PartyTransactionData implements Serializable {
private final String tokenNo;
private final String description;
private final BigDecimal balanceAfterTransaction;
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
private final String voucherNo;
}
@@ -22,6 +22,7 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PostPersist;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -29,6 +30,7 @@ import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
/**
* A single posted entry on the DDS ledger: a Party's day's collection ("Party Tran", direction
@@ -107,4 +109,14 @@ public class PartyTransaction extends AbstractAuditableCustom {
this.description = description;
this.balanceAfterTransaction = balanceAfterTransaction;
}
/**
* Notes this line against the operation that wrote it, so the operation's voucher number can be
* stamped on it. The voucher number itself is not mapped here: it is written once, by SQL, after
* the voucher is raised, and a mapped field would be written back as null by any later save.
*/
@PostPersist
private void recordInVoucherScope() {
DdsVoucherScope.recordPartyTransaction(getId());
}
}
@@ -0,0 +1,105 @@
/**
* 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.dds.voucher;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* What one DDS operation moved, gathered while it runs so a single voucher can be raised for it at
* the end.
*
* A payout, an early closure, a posting run or a transfer each writes several ledger legs and
* passbook lines from several places - the interest brought up to date, the recovery, the payout
* itself. The voucher is the slip for the whole operation, not for each leg, so the legs are noted
* here as they are written and turned into one voucher when the operation finishes.
*
* Held per thread, since an operation runs on one thread from start to finish - including a posting
* run handed to a background thread, which opens its own scope there. Outside a scope nothing is
* recorded.
*/
public final class DdsVoucherScope {
/** A voucher side with no ledger leg behind it - a move between two accounts on the same GL. */
public record Line(String entryType, Long glAccountId, String partyName, String accountNo, BigDecimal amount, String description) {}
private static final ThreadLocal<DdsVoucherScope> CURRENT = new ThreadLocal<>();
private final Set<String> ledgerTransactionIds = new LinkedHashSet<>();
private final List<Long> partyTransactionIds = new ArrayList<>();
private final List<Line> extraLines = new ArrayList<>();
private DdsVoucherScope() {}
/** The scope now open on this thread, or null. */
public static DdsVoucherScope current() {
return CURRENT.get();
}
/** Opens a scope on this thread. The caller must {@link #close} it. */
public static DdsVoucherScope open() {
final DdsVoucherScope scope = new DdsVoucherScope();
CURRENT.set(scope);
return scope;
}
public static void close() {
CURRENT.remove();
}
/** Notes a ledger transaction written for the operation in progress. */
public static void recordLedgerTransaction(final String transactionId) {
final DdsVoucherScope scope = CURRENT.get();
if (scope != null && transactionId != null) {
scope.ledgerTransactionIds.add(transactionId);
}
}
/** Notes a passbook line written for the operation in progress, so it can carry the voucher number. */
public static void recordPartyTransaction(final Long partyTransactionId) {
final DdsVoucherScope scope = CURRENT.get();
if (scope != null && partyTransactionId != null) {
scope.partyTransactionIds.add(partyTransactionId);
}
}
/** Adds a voucher side the ledger does not show - see {@link Line}. */
public static void addLine(final Line line) {
final DdsVoucherScope scope = CURRENT.get();
if (scope != null && line != null) {
scope.extraLines.add(line);
}
}
public Set<String> getLedgerTransactionIds() {
return Collections.unmodifiableSet(this.ledgerTransactionIds);
}
public List<Long> getPartyTransactionIds() {
return Collections.unmodifiableList(this.partyTransactionIds);
}
public List<Line> getExtraLines() {
return Collections.unmodifiableList(this.extraLines);
}
}
@@ -55,4 +55,11 @@ public interface AgentRepository extends JpaRepository<Agent, Long> {
*/
@Query("select a from Agent a where a.glAccount.id = :glAccountId")
Agent findByGlAccountId(@Param("glAccountId") Long glAccountId);
/**
* The client's agents still on the books. One person is one agent: a second would split their
* collections and commission between two codes paying into the same account.
*/
@Query("select a from Agent a where a.client.id = :clientId and a.active = true")
List<Agent> findActiveByClientId(@Param("clientId") Long clientId);
}
@@ -64,11 +64,12 @@ public final class AgentCommandFromApiJsonDeserializer {
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
/**
* An agent handles the branch's cash and signs for it, so they have to be an adult. Kept as a
* named constant rather than written into the check, because it is a policy number the bank may
* one day want to read off, not an arithmetic detail.
* The ages the bank takes agents on at: at least 16, and not yet 60. Kept as named constants
* rather than written into the check, because they are policy numbers the bank may one day want
* to read off, not an arithmetic detail.
*/
private static final int MINIMUM_AGENT_AGE_YEARS = 18;
private static final int MINIMUM_AGENT_AGE_YEARS = 16;
private static final int MAXIMUM_AGENT_AGE_YEARS = 60;
/**
* Which branch the agent works out of, as an office id. Optional: it defaults to the client's
@@ -223,7 +224,7 @@ public final class AgentCommandFromApiJsonDeserializer {
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
validateAgentBirthDate(agentBirthDate, baseDataValidator);
validateAgentBirthDate(agentBirthDate, baseDataValidator, true);
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
@@ -310,7 +311,7 @@ public final class AgentCommandFromApiJsonDeserializer {
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
validateAgentBirthDate(agentBirthDate, baseDataValidator);
validateAgentBirthDate(agentBirthDate, baseDataValidator, false);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
@@ -382,10 +383,11 @@ public final class AgentCommandFromApiJsonDeserializer {
* ago has only grown older since, so today is the reading that cannot wrongly reject a record
* that was legitimate when it was made.
*
* Only the shape of the date is judged here. Whether the person should be an agent at all is the
* branch's decision, not the validator's.
* The age range is applied when an agent is taken on. An agent already on the books is not
* refused an edit for having turned 60 since - their age was right when they were appointed.
*/
private void validateAgentBirthDate(final LocalDate agentBirthDate, final DataValidatorBuilder baseDataValidator) {
private void validateAgentBirthDate(final LocalDate agentBirthDate, final DataValidatorBuilder baseDataValidator,
final boolean appointing) {
if (agentBirthDate == null) {
return;
}
@@ -398,9 +400,17 @@ public final class AgentCommandFromApiJsonDeserializer {
return;
}
if (!appointing) {
return;
}
if (agentBirthDate.isAfter(today.minusYears(MINIMUM_AGENT_AGE_YEARS))) {
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
.failWithCode("agent.must.be.at.least." + MINIMUM_AGENT_AGE_YEARS + ".years.old");
.failWithCodeAndMessage("agent.must.be.at.least." + MINIMUM_AGENT_AGE_YEARS + ".years.old",
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " years old.");
} else if (!agentBirthDate.isAfter(today.minusYears(MAXIMUM_AGENT_AGE_YEARS))) {
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate)
.failWithCodeAndMessage("agent.must.be.under." + MAXIMUM_AGENT_AGE_YEARS + ".years.old",
"An agent must be " + MINIMUM_AGENT_AGE_YEARS + " to " + (MAXIMUM_AGENT_AGE_YEARS - 1) + " years old.");
}
}
@@ -76,6 +76,7 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
final Long clientId = command.longValueOfParameterNamed("clientId");
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
refuseIfAlreadyAnAgent(clientId, null);
validateSavingsAccount(clientId, command);
final String agentCode = generateNextAgentCode();
@@ -120,6 +121,7 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
if (changesOnly.containsKey("clientId")) {
final Long newClientId = (Long) changesOnly.get("clientId");
refuseIfAlreadyAnAgent(newClientId, agentId);
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
agentForUpdate.changeClient(newClient);
// The branch follows the client unless the caller named one - see officeOf.
@@ -259,6 +261,22 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
return glAccount;
}
/**
* One client is one agent. A second code for the same person splits their collections, their
* commission and their cash-in-hand across two records paying into the same account, and every
* agent-wise report then shows them twice. A closed agent does not count - the person can be taken
* on again.
*/
private void refuseIfAlreadyAnAgent(final Long clientId, final Long forAgentId) {
for (final Agent existing : this.agentRepository.findActiveByClientId(clientId)) {
if (!existing.getId().equals(forAgentId)) {
throw new PlatformDataIntegrityException("error.msg.agent.client.already.agent",
"This client is already agent " + existing.getAgentCode() + ". One client can be only one agent.",
"clientId", existing.getAgentCode());
}
}
}
private Office officeOf(final Client client, final JsonCommand command) {
if (command.parameterExists("officeId")) {
final Long officeId = command.longValueOfParameterNamed("officeId");
@@ -21,16 +21,23 @@ package org.apache.fineract.portfolio.dds.accounting;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryType;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
@@ -85,19 +92,48 @@ public class DdsLedger {
/** The savings product DDS opens customer accounts under. Set by 0492. */
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
/** Where a matured deposit waits while it is on its way to another bank. Set by 0494. */
public static final String MATURED_PAYABLE = "dds-matured-payable-account";
/** What DDS customers are owed on accounts that are DDS's own rather than savings accounts. Set by 0516. */
public static final String DEPOSIT_LIABILITY = "dds-deposit-liability-account";
/** What interest on DDS's own accounts costs the bank. Set by 0518. */
public static final String INTEREST_EXPENSE = "dds-interest-expense-account";
private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1";
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
/**
* The accounts money can be parked in for a customer with no account here. Every bank names its
* Sundry account differently, so none is assumed: every open posting liability account is offered
* and the clerk searches it by code or name and picks their bank's own. Liability, because the bank
* now owes the money - an income or expense account would book it as the bank's own. Posting,
* because a header cannot take an entry. And never an account whose balance is meant to equal the
* sum of customer or agent accounts - DDS Customer Deposits, a savings or deposit product's control
* account, a current-account control, an agent's own cash account - since parked money in any of
* those would leave it no longer adding up.
*/
private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
+ "from acc_gl_account g "
+ "where g.classification_enum = 2 and g.account_usage = 1 and g.disabled = false "
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 "
+ "and m.financial_account_type = 2 and m.gl_account_id is not null) "
+ "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) "
+ "and g.id not in (select c.value from c_configuration c where c.name in ('dds-deposit-liability-account', "
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
+ "order by g.gl_code";
/**
* product_type 2 is savings. financial_account_type 2 is the control account - what the customer
* is owed - and 1 is the fund source, the till or vault cash moves through.
*/
// Oldest first. A current product that shares its id with a savings product writes its mapping
// under the same product_type, and the two sets of rows cannot be told apart; the savings product's
// were there first, so the first row is the savings product's rather than whichever the database
// happens to return. See 0519 for the current-account side of the same collision.
private static final String PRODUCT_ACCOUNT_SQL = "select gl_account_id from acc_product_mapping "
+ "where product_id = ? and product_type = 2 and financial_account_type = ? and payment_type is null";
+ "where product_id = ? and product_type = 2 and financial_account_type = ? and payment_type is null order by id";
private static final int CONTROL_ACCOUNT = 2;
private static final int FUND_SOURCE = 1;
@@ -110,6 +146,27 @@ public class DdsLedger {
private final JournalEntryRepository journalEntryRepository;
private final GLAccountRepository glAccountRepository;
private final JdbcTemplate jdbcTemplate;
/** Who a current-account deposit row is recorded as made by - see payOutToCurrentAccount. */
private final PlatformSecurityContext context;
private final DdsVoucherService ddsVoucherService;
/**
* Runs a DDS operation and raises one voucher, in the DDS series, for everything it moved.
* Every service that moves money reaches the ledger through here, so the voucher comes with it.
*/
public <T> T withVoucher(final String voucherType, final String referenceType, final Long referenceId,
final java.util.function.Supplier<T> operation) {
return this.ddsVoucherService.raise(voucherType, referenceType, referenceId, operation);
}
/**
* A voucher side for a move the ledger does not show: two DDS accounts both sit on DDS Customer
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both.
*/
public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) {
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, configured(DEPOSIT_LIABILITY).getId(),
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
}
/**
* Credits a customer's own deposit account with what an agent collected from them.
@@ -137,6 +194,255 @@ public class DdsLedger {
description, null, null);
}
/**
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
*
* The cash is already in the bank: the cashier took it from the agent and credited the agent's GL
* account. What moves here is who it belongs to - off the agent's account and onto what the bank
* owes its DDS customers - so the debit is the very account the receipts credited, never looked up
* afresh. One pair for the whole application rather than one per customer: each customer's share is
* on their own DDS account, and a thousand identical pairs would say nothing the total does not.
*
* <pre>
* DR the account the receipts credited the agent's account clears
* CR DDS Customer Deposits what the bank now owes the customers
* </pre>
*/
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
final String transactionId, final String description) {
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description,
null, null);
}
/**
* The same hand-on for a party still on a savings account from before DDS kept its own: credited on
* that account, as a collection always was, with the pair on the account's own control.
*/
public void applyAgentReceiptToSavings(final Party party, final LocalDate date, final BigDecimal amount,
final GLAccount receiptAccount, final String description) {
final SavingsAccount account = load(party.getSavingsAccountId());
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, receiptAccount, controlAccountOf(account), description);
}
// ---------------------------------------------------------------------------------------------
// A party's account, wherever it is kept.
//
// A DDS account is DDS's own now (0514): the balance is on the party, and what the bank owes for it
// sits on DDS Customer Deposits. A party opened before that still has a savings account behind it.
// Each movement below asks which of the two this party has and books it there - the same entry in
// the same direction either way, only the account that stands for "what the customer is owed"
// differs. Callers no longer choose; they name the party.
// ---------------------------------------------------------------------------------------------
/** A collection straight onto the party's account: Dr the agent's cash, Cr what the customer is owed. */
public void collect(final Party party, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount agentCashAccount) {
if (party.getSavingsAccountId() != null) {
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
return;
}
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description);
}
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
public void postInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
if (party.getSavingsAccountId() != null) {
postInterest(party.getSavingsAccountId(), date, amount, description);
return;
}
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description);
}
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
public void reverseInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
if (party.getSavingsAccountId() != null) {
reverseInterest(party.getSavingsAccountId(), date, amount, description);
return;
}
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description);
}
/**
* The party's whole balance paid into another account of the customer's at this bank. Nothing leaves
* the bank: what it owed on the DDS account it now owes on theirs.
*/
public void payOutToOwnAccount(final Party party, final Long toSavingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
if (party.getSavingsAccountId() != null) {
payOutToOwnAccount(party.getSavingsAccountId(), toSavingsAccountId, date, amount, description);
return;
}
final SavingsAccount to = load(toSavingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description);
}
/**
* The party's whole balance paid into the customer's current account at this bank.
*
* Written onto the current account the way the bank's other book transfers into one are (NACH,
* clerk disbursement): a deposit row and the balances, directly, so the current-account module
* posts nothing of its own - and the pair here is the whole of the entry. Nothing leaves the bank:
* what it owed on the DDS account it now owes on the current account.
*
* <pre>
* DR what the customer was owed DDS Customer Deposits, or the party's savings control
* CR the current account's control
* </pre>
*/
public void payOutToCurrentAccount(final Party party, final Long currentAccountId, final LocalDate date, final BigDecimal amount,
final String description) {
final GLAccount owed;
final Office office;
if (party.getSavingsAccountId() != null) {
final SavingsAccount account = load(party.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
owed = controlAccountOf(account);
office = account.office();
} else {
owed = configured(DEPOSIT_LIABILITY);
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
}
creditCurrentAccount(currentAccountId, date, amount, description);
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
null, null);
}
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
private static final String CURRENT_DEPOSIT_SQL = "insert into m_current_account_transaction "
+ " (current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, is_manual, "
+ " is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
+ " values (?, ?, 1, ?, ?, false, false, false, false, ?, ?, ?, ?, current_timestamp, ?)";
private static final String CURRENT_BALANCE_SQL = "update m_current_account set account_balance_derived = account_balance_derived + ?, "
+ " total_deposits_derived = coalesce(total_deposits_derived, 0) + ? where id = ?";
private static final String CURRENT_CONTROL_SQL = "select apm.gl_account_id from m_current_account ca "
+ " join acc_product_mapping apm on apm.product_id = ca.product_id "
+ " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null";
private static final String CURRENT_CONTROL_OVERRIDE_SQL = "select value from c_configuration "
+ " where name = 'dds-current-account-control-account' and enabled = true";
/** A deposit row and the balances on the current account, refused unless it is active. */
private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) {
final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
if (rows.isEmpty() || ((Number) rows.get(0).get("status_enum")).intValue() != 300) {
throw new DdsLedgerNotConfiguredException("an active current account " + currentAccountId + " to pay into");
}
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("account_balance_derived");
final java.sql.Date day = java.sql.Date.valueOf(date);
final String remarks = description.length() > 490 ? description.substring(0, 490) : description;
this.jdbcTemplate.update(CURRENT_DEPOSIT_SQL, currentAccountId, rows.get(0).get("office_id"), day, amount, // NOSONAR
UUID.randomUUID().toString().replace("-", "").substring(0, 20), day, balance.add(amount), remarks,
this.context.authenticatedUser().getId());
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
}
/**
* The current account's control account: its product's mapping when that is the only one, and the
* setting when the product shares an id - and so its mapping rows - with a savings product.
*/
private GLAccount currentAccountControl(final Long currentAccountId) {
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
if (ids.size() == 1) {
return this.glAccountRepository.findById(ids.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId));
}
final List<Long> override = this.jdbcTemplate.queryForList(CURRENT_CONTROL_OVERRIDE_SQL, Long.class); // NOSONAR
if (!override.isEmpty() && override.get(0) != null) {
return this.glAccountRepository.findById(override.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException("dds-current-account-control-account"));
}
throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId
: "dds-current-account-control-account (the current product's GL mapping is shared with a savings product of the same id)");
}
/** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
public void payOutToPayable(final Party party, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount parkIn) {
if (party.getSavingsAccountId() != null) {
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
return;
}
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description);
}
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
public List<Map<String, Object>> sundryAccounts() {
return this.jdbcTemplate.queryForList(SUNDRY_ACCOUNTS_SQL); // NOSONAR
}
/**
* The account to park in: the one the clerk picked, when it is on the Sundry list. There is no
* fallback - every bank's Sundry account is its own, so none is guessed. Anything off the list is
* refused - an income or asset account, or a customer's own deposits, would put the money
* somewhere nobody would think to look for it.
*/
public GLAccount sundryAccount(final Long glAccountId) {
if (glAccountId == null) {
final String message = "Pick the Sundry GL account the balance is to be parked in.";
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.required", message,
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.required", message, "sundryGlAccountId")));
}
final boolean allowed = sundryAccounts().stream().anyMatch(row -> glAccountId.equals(((Number) row.get("id")).longValue()));
if (!allowed) {
final String message = "GL account " + glAccountId + " cannot hold a parked DDS balance - pick an open liability account that is not a customer or agent control account.";
throw new PlatformApiDataValidationException("error.msg.dds.sundry.account.invalid", message,
List.of(ApiParameterError.parameterError("error.msg.dds.sundry.account.invalid", message, "sundryGlAccountId")));
}
return this.glAccountRepository.findById(glAccountId).orElseThrow();
}
/**
* Money moved from one party's account to another's.
*
* Two DDS accounts are both owed on DDS Customer Deposits, so the bank owes the same on the same
* account before and after and there is nothing to book - the two DDS balances are the whole
* movement. Only when one side is still a savings account does the money change ledger account.
*/
public void transfer(final Party from, final Party to, final LocalDate date, final BigDecimal amount, final String description) {
final boolean fromSavings = from.getSavingsAccountId() != null;
final boolean toSavings = to.getSavingsAccountId() != null;
if (fromSavings && toSavings) {
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
} else if (fromSavings) {
final SavingsAccount account = load(from.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
configured(DEPOSIT_LIABILITY), description, null, account.getId());
} else if (toSavings) {
final SavingsAccount account = load(to.getSavingsAccountId());
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description);
} else {
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom.
voucherSideOnDeposits("DR", from, amount, description);
voucherSideOnDeposits("CR", to, amount, description);
}
}
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
final String description) {
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
}
/**
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one
* when they have none.
@@ -246,7 +552,8 @@ public class DdsLedger {
* CR Matured Deposits Payable and the bank now owes it to them
* </pre>
*/
public void payOutToPayable(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
private void payOutToPayable(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount parkIn) {
final SavingsAccount account = load(savingsAccountId);
// DDS's own movement, so the debit block comes off for it and goes straight back on.
@@ -256,7 +563,7 @@ public class DdsLedger {
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), configured(MATURED_PAYABLE),
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
description, null, account.getId());
}
@@ -361,7 +668,14 @@ public class DdsLedger {
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
final Long savingsAccountId) {
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId;
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
}
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
final Long savingsTransactionId, final Long savingsAccountId) {
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService.
DdsVoucherScope.recordLedgerTransaction(transactionId);
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
@@ -394,6 +708,15 @@ public class DdsLedger {
return ids.get(0);
}
/** The bank's own currency, for an entry that belongs to no savings product. */
private String organisationCurrency() {
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
if (codes.isEmpty()) {
throw new DdsLedgerNotConfiguredException("organisation currency");
}
return codes.get(0);
}
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
private String currencyOf(final Long productId) {
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
@@ -0,0 +1,450 @@
/**
* 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.dds.accounting;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Raises the voucher for a DDS operation in the bank's own voucher register.
*
* Nothing about vouchers is kept by DDS itself: the number, the register and its lines are the ones
* every other module writes through {@link VoucherService}, in the same PREFIX/DDMMYYYY/NNNN form -
* only the prefix is DDS's own, so its slips read DDS/29092026/0001 and run as a series of their own.
*
* An operation is wrapped in {@link #raise}. Its ledger legs and passbook lines are noted as they are
* written (see {@link DdsVoucherScope}); when it finishes, one voucher is raised from the legs it
* actually wrote, and its number stamped on the passbook lines and on any savings entry it made. A
* wrap inside another adds to the outer one, so an early closure that also brings interest up to date
* is one slip, not two.
*
* The voucher is raised in the operation's own transaction, so an operation that rolls back - one held
* for a checker's approval, say - leaves no voucher behind, and the number is taken only when the money
* actually moves.
*/
@Component
@RequiredArgsConstructor
public class DdsVoucherService {
/** The series DDS vouchers are numbered in. */
public static final String PREFIX = "DDS";
public static final String TYPE_APPLY_RECEIPT = "DDS_APPLY_RECEIPT";
public static final String TYPE_INTEREST = "DDS_INTEREST";
public static final String TYPE_COMMISSION = "DDS_COMMISSION";
public static final String TYPE_MATURITY_PAYOUT = "DDS_MATURITY_PAYOUT";
public static final String TYPE_PREMATURE = "DDS_PREMATURE";
public static final String TYPE_TRANSFER = "DDS_TRANSFER";
public static final String TYPE_TRANSACTION = "DDS_TRANSACTION";
public static final String REF_PARTY = "DDS_PARTY";
public static final String REF_BATCH_RUN = "DDS_BATCH_RUN";
public static final String REF_AGENT = "DDS_AGENT";
/** The ledger id DdsLedger gives a leg posted for one party: DDS-PT0003-0fdd3261. */
private static final Pattern PARTY_LEG = Pattern.compile("^DDS-(.+)-[0-9a-f]{8}$");
private static final String LEGS_SQL = "select je.type_enum as \"typeEnum\", je.amount, je.account_id as \"glId\", "
+ "gl.name as \"glName\", gl.gl_code as \"glCode\", je.description, je.transaction_id as \"transactionId\", "
+ "je.savings_transaction_id as \"savingsTxnId\" from acc_gl_journal_entry je "
+ "join acc_gl_account gl on gl.id = je.account_id "
+ "where je.transaction_id in (%s) and (je.reversed is null or je.reversed = false) order by je.id";
private static final String PARTY_SQL = "select party_name as \"name\", account_no as \"accountNo\" from m_dds_party "
+ "where party_code = ?";
private static final String SAVINGS_OWNER_SQL = "select sa.id as \"savingsAccountId\", sa.account_no as \"accountNo\", "
+ "c.display_name as \"name\", apm.gl_account_id as \"controlGlId\" from m_savings_account_transaction t "
+ "join m_savings_account sa on sa.id = t.savings_account_id left join m_client c on c.id = sa.client_id "
+ "left join acc_product_mapping apm on apm.product_id = sa.product_id and apm.product_type = 2 "
+ "and apm.financial_account_type = 2 where t.id = ? order by apm.id limit 1";
private static final String SYSTEM_USER_SQL = "select id from m_appuser where username = 'system' and is_deleted = false limit 1";
/** How a voucher held for an entry awaiting approval is tied to that entry: DDS_COMMAND:{commandId}. */
public static final String HELD_FOR_COMMAND = "DDS_COMMAND:";
/** The entry being approved on this thread, so the voucher held for it is the one used. */
private static final ThreadLocal<Long> APPROVING_COMMAND = new ThreadLocal<>();
/**
* A voucher held for an entry that is waiting on a checker and has not been used yet. Held while
* the entry is pending (2), or just approved (1) for a posting run that finishes in the background;
* never one whose entry was rejected (3).
*/
private static final String HELD_VOUCHER_SQL = "select v.voucher_no from m_voucher v "
+ "join m_portfolio_command_source c on v.reference_key = 'DDS_COMMAND:' || c.id and c.status in (1, 2) "
+ "where v.voucher_no like 'DDS/%' and not exists (select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) "
+ "and v.voucher_type = ? and v.reference_type = ? and v.reference_id = ? "
+ "order by (v.amount = ?) desc, v.id limit 1";
/** The last voucher raised on this thread - read by the approvals screen to show what an approval produced. */
private static final ThreadLocal<String> LAST_ISSUED = new ThreadLocal<>();
private final VoucherService voucherService;
/** Flushed before the voucher is raised, so every passbook line the operation wrote has its id. */
@PersistenceContext
private EntityManager entityManager;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final AppUserRepository appUserRepository;
/**
* Runs one DDS operation and raises its voucher.
*
* @return whatever the operation returns
*/
public <T> T raise(final String voucherType, final String referenceType, final Long referenceId, final Supplier<T> operation) {
if (DdsVoucherScope.current() != null) {
// Part of a larger operation, whose voucher this becomes a part of.
return operation.get();
}
LAST_ISSUED.remove();
final DdsVoucherScope scope = DdsVoucherScope.open();
try {
// Any savings posting inside is DDS's own movement and part of this voucher, not a slip of its own.
final T result = this.voucherService.withOwnVoucher(operation);
issue(scope, voucherType, referenceType, referenceId);
return result;
} finally {
DdsVoucherScope.close();
}
}
/**
* Sends an entry for approval, numbering its voucher there and then.
*
* An entry held for a checker has moved nothing yet, but the checker approves it against a voucher
* number - so the number is taken now, with the amount the entry is for, and the entry is tied to
* it. When the entry is approved the money moves under that same number; when it is rejected the
* number is marked cancelled and never used. An entry that needs no checker is carried out at
* once and numbered as it moves, as before.
*/
public CommandProcessingResult submitHoldingVoucher(final String voucherType, final String referenceType, final Long referenceId,
final Supplier<BigDecimal> amount, final Supplier<CommandProcessingResult> submit) {
try {
return submit.get();
} catch (final RollbackTransactionNotApprovedException held) {
final Long commandId = held.getResult().getCommandId();
final AppUser user = currentUser();
if (voucherType != null && commandId != null && user != null) {
final BigDecimal value = amount.get();
this.voucherService.createVoucher(voucherType, referenceType, referenceId, HELD_FOR_COMMAND + commandId,
value == null ? BigDecimal.ZERO : value, user, PREFIX);
}
throw held;
}
}
/** Runs an approval so the voucher held for that entry is the one its money moves under. */
public <T> T approving(final Long commandId, final Supplier<T> approval) {
APPROVING_COMMAND.set(commandId);
try {
return approval.get();
} finally {
APPROVING_COMMAND.remove();
}
}
/** Marks the voucher held for a rejected entry cancelled, so it is never used. */
public void cancelHeld(final Long commandId) {
this.jdbcTemplate.update("update m_voucher set reference_key = reference_key || ':REJECTED' where reference_key = ?", // NOSONAR
HELD_FOR_COMMAND + commandId);
}
private String heldVoucher(final String voucherType, final String referenceType, final Long referenceId, final BigDecimal total) {
final Long approving = APPROVING_COMMAND.get();
if (approving != null) {
final List<String> exact = this.jdbcTemplate.queryForList( // NOSONAR
"select voucher_no from m_voucher v where reference_key = ? and not exists "
+ "(select 1 from m_voucher_line l where l.voucher_no = v.voucher_no) order by id limit 1",
String.class, HELD_FOR_COMMAND + approving);
if (!exact.isEmpty()) {
return exact.get(0);
}
}
if (referenceType == null || referenceId == null) {
return null;
}
// Approved from elsewhere - the Checker Inbox - or a posting run finishing in the background.
final List<String> found = this.jdbcTemplate.queryForList(HELD_VOUCHER_SQL, String.class, voucherType, referenceType, // NOSONAR
referenceId, total);
return found.isEmpty() ? null : found.get(0);
}
/** What a payout or early closure of this account will move: its balance, with interest worked out but not posted. */
public BigDecimal amountHeldBy(final Long partyId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select current_balance + coalesce(interest_calc, 0) from m_dds_party where id = ?", BigDecimal.class, partyId);
return found.isEmpty() ? null : found.get(0);
}
/** The voucher a posting run raises, by its job type - the same types the run itself uses. */
public String voucherTypeOfRun(final Long batchRunId) {
final List<String> found = this.jdbcTemplate.queryForList("select job_type from m_dds_batch_run where id = ?", String.class, // NOSONAR
batchRunId);
final String jobType = found.isEmpty() ? "" : found.get(0);
return switch (jobType) {
case "COMMISSION_POSTING" -> TYPE_COMMISSION;
case "MATURITY_AUTO_CLOSE" -> TYPE_MATURITY_PAYOUT;
case "INTEREST_POSTING", "OD_INTEREST_POSTING" -> TYPE_INTEREST;
// A calculation run moves no money, so no voucher is held for it.
default -> null;
};
}
/** What a posting run will move. */
public BigDecimal amountOfRun(final Long batchRunId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select total_amount from m_dds_batch_run where id = ?", // NOSONAR
BigDecimal.class, batchRunId);
return found.isEmpty() ? null : found.get(0);
}
/** What an agent's day will apply: every entry of theirs still waiting to be settled. */
public BigDecimal amountPendingFor(final Long agentId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select coalesce(sum(amount), 0) from m_dds_collection_entry where agent_id = ? and status = 'PENDING'", BigDecimal.class,
agentId);
return found.isEmpty() ? null : found.get(0);
}
/** A number read from a request body, or null. */
public static BigDecimal numberIn(final String json, final String name) {
final JsonObject body = bodyOf(json);
try {
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsBigDecimal() : null;
} catch (RuntimeException e) {
return null;
}
}
/** A whole number read from a request body, or null. */
public static Long idIn(final String json, final String name) {
final JsonObject body = bodyOf(json);
try {
return body.has(name) && !body.get(name).isJsonNull() ? body.get(name).getAsLong() : null;
} catch (RuntimeException e) {
return null;
}
}
private static JsonObject bodyOf(final String json) {
try {
final JsonElement element = json == null ? null : JsonParser.parseString(json);
return element != null && element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
} catch (RuntimeException e) {
return new JsonObject();
}
}
/** The voucher the last operation on this thread raised, or null, clearing it. */
public String takeLastIssued() {
final String voucherNo = LAST_ISSUED.get();
LAST_ISSUED.remove();
return voucherNo;
}
/** As {@link #raise}, for an operation that returns nothing. */
public void raise(final String voucherType, final String referenceType, final Long referenceId, final Runnable operation) {
raise(voucherType, referenceType, referenceId, () -> {
operation.run();
return null;
});
}
private void issue(final DdsVoucherScope scope, final String voucherType, final String referenceType, final Long referenceId) {
// A passbook line saved after the operation's last ledger write would otherwise still be waiting
// to be inserted - noted in the scope only once it is - and would miss its voucher number.
try {
this.entityManager.flush();
} catch (RuntimeException notInTransaction) {
// Nothing pending to write when there is no transaction to flush.
}
final Set<Long> savingsTransactions = new LinkedHashSet<>();
final List<VoucherLine> lines = linesFromLedger(scope.getLedgerTransactionIds(), savingsTransactions);
for (final DdsVoucherScope.Line extra : scope.getExtraLines()) {
final VoucherLine line = new VoucherLine(extra.entryType(), extra.glAccountId(), null, null, extra.partyName(),
extra.accountNo(), extra.amount(), null);
lines.add(extra.description() == null ? line : line.withDescription(extra.description()));
}
if (lines.isEmpty()) {
// Nothing moved - a calculation run, or an operation that found nothing to do.
return;
}
final BigDecimal total = lines.stream().filter(VoucherLine::isDebit).map(VoucherLine::amount).reduce(BigDecimal.ZERO,
BigDecimal::add);
final String held = heldVoucher(voucherType, referenceType, referenceId, total);
final String voucherNo;
if (held != null) {
// Numbered when the entry was sent for approval, so the checker saw it; its sides are the
// legs written now that the money has moved, and its amount what actually moved.
this.voucherService.addLines(held, lines);
this.jdbcTemplate.update("update m_voucher set amount = ? where voucher_no = ?", total, held); // NOSONAR
voucherNo = held;
} else {
final AppUser user = currentUser();
if (user == null) {
throw new IllegalStateException("No user to raise the DDS voucher as - the system user is missing.");
}
voucherNo = this.voucherService.createVoucherWithLines(voucherType, referenceType, referenceId, user, PREFIX, lines);
}
LAST_ISSUED.set(voucherNo);
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
savingsTransactions);
}
/**
* The voucher's sides, read from the legs the operation wrote.
*
* A leg on a customer's own account - a DDS account, or a savings account the money was paid into -
* is named after the customer and their account, one line each, since the slip has to show who was
* paid what. Every other leg - interest expense, commission expense, a Sundry account - is an
* account of the bank's, and its legs are added up into one line per side, so a posting run over
* four hundred accounts shows one expense line against four hundred credits.
*/
private List<VoucherLine> linesFromLedger(final Set<String> transactionIds, final Set<Long> savingsTransactions) {
final List<VoucherLine> customerLines = new ArrayList<>();
final Map<String, BigDecimal> bankTotals = new LinkedHashMap<>();
final Map<String, Map<String, Object>> bankAccounts = new LinkedHashMap<>();
if (transactionIds.isEmpty()) {
return new ArrayList<>();
}
final Long depositGlId = depositLiabilityGlId();
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>();
for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
transactionIds.toArray())) {
final boolean debit = ((Number) leg.get("typeEnum")).intValue() == 2;
final String side = debit ? VoucherService.ENTRY_DEBIT : VoucherService.ENTRY_CREDIT;
final BigDecimal amount = (BigDecimal) leg.get("amount");
final Long glId = ((Number) leg.get("glId")).longValue();
final String description = (String) leg.get("description");
final Long savingsTxnId = leg.get("savingsTxnId") == null ? null : ((Number) leg.get("savingsTxnId")).longValue();
if (savingsTxnId != null) {
savingsTransactions.add(savingsTxnId);
final Map<String, Object> owner = this.jdbcTemplate.queryForList(SAVINGS_OWNER_SQL, savingsTxnId).stream().findFirst() // NOSONAR
.orElse(Map.of());
final Object controlGl = owner.get("controlGlId");
if (controlGl != null && glId.equals(((Number) controlGl).longValue())) {
final Long savingsAccountId = ((Number) owner.get("savingsAccountId")).longValue();
final VoucherLine line = debit
? VoucherLine.debitSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount)
: VoucherLine.creditSavings(savingsAccountId, (String) owner.get("name"), (String) owner.get("accountNo"), amount);
customerLines.add(description == null ? line : line.withDescription(description));
continue;
}
}
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
if (partyLeg.matches() && glId.equals(depositGlId)) {
final String partyCode = partyLeg.group(1);
final Map<String, Object> party = parties.computeIfAbsent(partyCode,
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
final String name = party.get("name") == null ? partyCode : partyCode + " · " + party.get("name");
final VoucherLine line = new VoucherLine(side, glId, null, null, name, (String) party.get("accountNo"), amount, null);
customerLines.add(description == null ? line : line.withDescription(description));
continue;
}
final String key = side + ":" + glId;
bankTotals.merge(key, amount, BigDecimal::add);
bankAccounts.putIfAbsent(key, leg);
}
final List<VoucherLine> lines = new ArrayList<>();
// Debits first, as a slip reads: where the money came from, then where it went.
for (final String side : List.of(VoucherService.ENTRY_DEBIT, VoucherService.ENTRY_CREDIT)) {
for (final Map.Entry<String, BigDecimal> total : bankTotals.entrySet()) {
if (total.getKey().startsWith(side + ":")) {
final Map<String, Object> account = bankAccounts.get(total.getKey());
lines.add(new VoucherLine(side, ((Number) account.get("glId")).longValue(), null, null, (String) account.get("glName"),
(String) account.get("glCode"), total.getValue(), null));
}
}
for (final VoucherLine line : customerLines) {
if (side.equals(line.entryType())) {
lines.add(line);
}
}
}
return lines;
}
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */
private Long depositLiabilityGlId() {
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class);
return ids.isEmpty() ? null : ids.get(0);
}
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
if (ids.isEmpty()) {
return;
}
final List<Object> params = new ArrayList<>();
params.add(voucherNo);
params.addAll(ids);
this.jdbcTemplate.update(String.format(sql, placeholders(ids)), params.toArray()); // NOSONAR
}
/** Who raised it: the user at the screen, or the system user for the nightly job. */
private AppUser currentUser() {
final AppUser user = this.context.getAuthenticatedUserIfPresent();
if (user != null) {
return user;
}
final List<Long> ids = this.jdbcTemplate.queryForList(SYSTEM_USER_SQL, Long.class); // NOSONAR
return ids.isEmpty() ? null : this.appUserRepository.findById(ids.get(0)).orElse(null);
}
private static String placeholders(final Collection<?> values) {
return String.join(",", Collections.nCopies(values.size(), "?"));
}
}
@@ -41,7 +41,7 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty",
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
"dateFormat", "locale"));
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
@@ -66,6 +66,9 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
v.reset().parameter("accountTypeDescription")
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
.notExceedingLengthOf(200);
// Mandatory: every account opened on the scheme takes this rate.
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
validateOptionalFields(element, v);
@@ -91,6 +94,10 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
.notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists("interestRate", element)) {
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
}
validateOptionalFields(element, v);
@@ -48,6 +48,7 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
+ " t.premature_lockin_months as prematureLockinMonths, "
+ " t.premature_interest_reduction as prematureInterestReduction, "
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
+ " t.interest_rate as interestRate, "
+ " t.is_active as active, "
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
+ " from m_dds_account_type t";
@@ -67,6 +68,7 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
.interestRate(rs.getBigDecimal("interestRate")) //
.active(rs.getBoolean("active")) //
.createdDate(localDateTime(rs, "createdDate")) //
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.dds.accounttype.service;
import jakarta.persistence.PersistenceException;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -32,6 +33,8 @@ import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountType;
import org.apache.fineract.portfolio.dds.accounttype.domain.DdsAccountTypeRepository;
import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNotFoundException;
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@@ -42,6 +45,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final DdsAccountTypeRepository ddsAccountTypeRepository;
private final PartyRepository partyRepository;
@Transactional
@Override
@@ -76,12 +80,26 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
// The accounts on the scheme carry the code as it was before this update.
final String codeBeforeUpdate = accountType.getAccountTypeCode();
final Map<String, Object> changesOnly = accountType.update(command);
if (!changesOnly.isEmpty()) {
this.ddsAccountTypeRepository.saveAndFlush(accountType);
}
// The rate on the product is the rate: every open account on it moves to the new one, and
// its maturity figure is worked out again. Interest already calculated stays as it was;
// from here on it is worked out at the new rate.
if (changesOnly.containsKey("interestRate")) {
final List<Party> openAccounts = this.partyRepository.findActiveOnSubType(codeBeforeUpdate);
for (final Party party : openAccounts) {
party.takeSchemeRate(accountType.getInterestRate());
}
this.partyRepository.saveAll(openAccounts);
changesOnly.put("accountsRepriced", openAccounts.size());
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(accountTypeId) //
@@ -24,6 +24,7 @@ import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadP
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformService;
import org.apache.fineract.portfolio.dds.accounttype.service.DdsAccountTypeWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -41,7 +42,8 @@ public class DdsAccountTypeConfiguration {
@Bean
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository) {
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository);
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository,
PartyRepository partyRepository) {
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository);
}
}
@@ -0,0 +1,130 @@
/**
* 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.dds.approval.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.approval.service.DdsApprovalReadService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* The DDS approvals queue: every DDS entry waiting on a checker, in one place, with what it will do
* and - once approved - the voucher it moved under.
*
* The entries are the platform's own maker-checker entries (m_portfolio_command_source); nothing is
* queued a second way. Approving one replays it through the platform's own approval, so the money
* moves then, and only then, and the DDS voucher is raised at that moment.
*
* One rule is held here more strictly than elsewhere: whoever made an entry cannot be the one to
* approve or reject it - not even a super user, who the platform otherwise lets check their own work.
*/
@Path("/v1/dds/approvals")
@Component
@Tag(name = "DDS Approvals", description = "DDS entries waiting on a checker - transfers, payouts, early closures, receipts applied, "
+ "postings, and changes to parties, agents and schemes.")
@RequiredArgsConstructor
public class DdsApprovalsApiResource {
private final PlatformSecurityContext context;
private final DdsApprovalReadService readService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "DDS entries by approval status", description = "status is PENDING (default), APPROVED or REJECTED; "
+ "fromDate and toDate (yyyy-MM-dd) narrow it to the days the entries were made.")
public List<Map<String, Object>> list(@QueryParam("status") @Parameter(description = "status") final String status,
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate) {
this.context.authenticatedUser();
return this.readService.list(status, fromDate, toDate);
}
@GET
@Path("{commandId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "One DDS entry, with what it will do")
public Map<String, Object> retrieveOne(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId) {
this.context.authenticatedUser();
return this.readService.retrieveOne(commandId);
}
@POST
@Path("{commandId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve or reject a DDS entry", description = "command=approve carries the entry out and returns the "
+ "voucher it moved under; command=reject leaves nothing done. Neither is open to the user who made the entry.")
public Map<String, Object> action(@PathParam("commandId") @Parameter(description = "commandId") final Long commandId,
@QueryParam("command") @Parameter(description = "command") final String commandParam) {
final AppUser user = this.context.authenticatedUser();
final Map<String, Object> entry = this.readService.pendingEntry(commandId);
final Number makerId = (Number) entry.get("makerId");
if (makerId != null && makerId.longValue() == user.getId()) {
throw new NoAuthorizationException("You made this entry, so you cannot " + ("reject".equalsIgnoreCase(commandParam) ? "reject" : "approve")
+ " it. Another user with checker rights has to.");
}
if ("approve".equalsIgnoreCase(commandParam)) {
this.ddsVoucherService.takeLastIssued();
// The money moves under the voucher numbered when the entry was sent for approval.
final CommandProcessingResult result = this.ddsVoucherService.approving(commandId,
() -> this.commandsSourceWritePlatformService.approveEntry(commandId));
String voucherNo = this.ddsVoucherService.takeLastIssued();
if (voucherNo == null) {
// A posting run finishes in the background; the voucher it will use is the one held for it.
voucherNo = this.readService.heldVoucher(commandId);
}
this.readService.linkVoucher(commandId, voucherNo);
return Map.of("commandId", commandId, "status", "APPROVED", "resourceId",
result.getResourceId() == null ? "" : result.getResourceId(), "voucherNo", voucherNo == null ? "" : voucherNo);
}
if ("reject".equalsIgnoreCase(commandParam)) {
this.commandsSourceWritePlatformService.rejectEntry(commandId);
// The number held for it is cancelled, never used for anything else.
final String voucherNo = this.readService.heldVoucher(commandId);
this.ddsVoucherService.cancelHeld(commandId);
return Map.of("commandId", commandId, "status", "REJECTED", "voucherNo", voucherNo == null ? "" : voucherNo);
}
throw new UnrecognizedQueryParamException("command", commandParam);
}
}
@@ -0,0 +1,503 @@
/**
* 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.dds.approval.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
import org.apache.fineract.commands.exception.CommandNotFoundException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Reads the DDS entries in the platform's maker-checker register and says, in the clerk's terms, what
* each one does: which accounts, how much, where the money goes, and - once it has moved - under which
* voucher.
*
* Only the DDS actions that are actually held for a checker are listed (their permission is marked for
* maker-checker), so the list is the queue of work, not every DDS command ever logged.
*/
@Component
@RequiredArgsConstructor
public class DdsApprovalReadService {
private static final int STATUS_PROCESSED = 1;
private static final int STATUS_AWAITING = 2;
private static final int STATUS_REJECTED = 3;
private static final String ENTRIES_SQL = "select c.id as \"id\", c.action_name as \"action\", c.entity_name as \"entity\", "
+ "c.resource_id as \"resourceId\", c.status as \"status\", c.command_as_json as \"json\", "
+ "to_char(c.made_on_date_utc, 'YYYY-MM-DD HH24:MI') as \"madeOn\", to_char(c.checked_on_date_utc, 'YYYY-MM-DD HH24:MI') as \"checkedOn\", "
+ "mk.id as \"makerId\", mk.username as \"maker\", ck.username as \"checker\", "
+ "trim(coalesce(mk.firstname, '') || ' ' || coalesce(mk.lastname, '')) as \"makerName\", "
+ "trim(coalesce(ck.firstname, '') || ' ' || coalesce(ck.lastname, '')) as \"checkerName\" "
+ "from m_portfolio_command_source c "
+ "join m_permission p on p.code = c.action_name || '_' || c.entity_name and p.can_maker_checker = true "
+ "left join m_appuser mk on mk.id = c.maker_id left join m_appuser ck on ck.id = c.checker_id "
+ "where c.entity_name in ('PARTY', 'AGENT', 'PARTYTRANSACTION', 'DDSACCOUNTTYPE', 'COMMISSIONSLAB', 'DDSBATCH', "
+ "'DDSCOLLECTIONENTRY') ";
private static final String REF_KEY_PREFIX = "DDS_COMMAND:";
private final JdbcTemplate jdbcTemplate;
public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) {
final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? ");
final List<Object> params = new ArrayList<>();
params.add(statusCode(status));
if (fromDate != null && !fromDate.isBlank()) {
sql.append(" and cast(c.made_on_date_utc as date) >= cast(? as date) ");
params.add(fromDate);
}
if (toDate != null && !toDate.isBlank()) {
sql.append(" and cast(c.made_on_date_utc as date) <= cast(? as date) ");
params.add(toDate);
}
sql.append(" order by c.id desc limit 500");
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> entry : this.jdbcTemplate.queryForList(sql.toString(), params.toArray())) { // NOSONAR
final Map<String, Object> row = describe(entry);
// How many accounts it touches, as the Disbursement & Transfers list counts its entries.
row.put("entries", linesFor(row, entry).size());
rows.add(row);
}
return rows;
}
public Map<String, Object> retrieveOne(final Long commandId) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList(ENTRIES_SQL + " and c.id = ?", commandId); // NOSONAR
if (found.isEmpty()) {
throw new CommandNotFoundException(commandId);
}
final Map<String, Object> row = describe(found.get(0));
row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription"));
row.put("lines", linesFor(row, found.get(0)));
return row;
}
/**
* The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they
* are the voucher's own lines; while it waits they are what approving it will move, worked out from
* the request and the accounts as they stand now.
*/
private List<Map<String, Object>> linesFor(final Map<String, Object> row, final Map<String, Object> entry) {
final String voucherNo = (String) row.get("voucherNo");
if (voucherNo != null) {
final List<Map<String, Object>> posted = this.jdbcTemplate.queryForList( // NOSONAR
"select entry_type as \"side\", account_no as \"accountNo\", party_name as \"accountName\", "
+ "case when savings_account_id is not null then 'SAVINGS' when gl_account_id is not null then 'GL' else '' end as \"type\", "
+ "amount from m_voucher_line where voucher_no = ? order by line_no",
voucherNo);
if (!posted.isEmpty()) {
return posted;
}
}
final List<Map<String, Object>> lines = new ArrayList<>();
final String entity = (String) row.get("entity");
final String action = (String) row.get("action");
final Long resourceId = (Long) row.get("resourceId");
final JsonObject json = jsonOf(entry.get("json"));
final BigDecimal amount = (BigDecimal) row.get("amount");
switch (entity + ":" + action) {
case "PARTYTRANSACTION:CREATE" -> {
lines.add(partyLine("DR", text(json, "debitPartyCode"), amount));
lines.add(partyLine("CR", text(json, "creditorPartyCode"), amount));
}
case "PARTY:PAYOUT", "PARTY:PREMATURECLOSE", "PARTY:PREMATURECLOSEOVERRIDE" -> {
final String code = first("select party_code from m_dds_party where id = ?", resourceId);
lines.add(partyLine("DR", code, amount));
final String toAccount = text(json, "toSavingsAccountNo");
if (toAccount != null && !toAccount.isBlank()) {
final String owner = first("select c.display_name from m_savings_account s left join m_client c on c.id = s.client_id "
+ "where s.account_no = ?", toAccount);
lines.add(line("CR", toAccount, owner, "SAVINGS", amount));
} else {
final Long glId = longOf(json, "sundryGlAccountId");
final Map<String, Object> gl = glId == null ? Map.of()
: this.jdbcTemplate.queryForList("select gl_code as \"code\", name from acc_gl_account where id = ?", glId) // NOSONAR
.stream().findFirst().orElse(Map.of());
lines.add(line("CR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount));
}
}
case "DDSCOLLECTIONENTRY:SETTLE" -> {
final Long agentId = longOf(json, "agentId");
final Map<String, Object> agentGl = agentId == null ? Map.of()
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from m_dds_agent a " // NOSONAR
+ "join acc_gl_account g on g.id = a.gl_account_id where a.id = ?", agentId).stream().findFirst().orElse(Map.of());
lines.add(line("DR", (String) agentGl.get("code"), (String) agentGl.get("name"), "GL", amount));
if (agentId != null) {
for (final Map<String, Object> pending : this.jdbcTemplate.queryForList( // NOSONAR
"select p.account_no as \"accountNo\", p.party_code || ' · ' || p.party_name as \"accountName\", e.amount "
+ "from m_dds_collection_entry e join m_dds_party p on p.id = e.party_id "
+ "where e.agent_id = ? and e.status = 'PENDING' order by e.id",
agentId)) {
lines.add(line("CR", (String) pending.get("accountNo"), (String) pending.get("accountName"), "DDS",
(BigDecimal) pending.get("amount")));
}
}
}
case "DDSBATCH:POST" -> {
final String expense = first("select c.name from m_dds_batch_run r join c_configuration c on c.name = "
+ "case when r.job_type like 'COMMISSION%' then 'dds-agent-commission-expense-account' "
+ "else 'dds-interest-expense-account' end where r.id = ?", resourceId);
final Map<String, Object> gl = expense == null ? Map.of()
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from c_configuration c " // NOSONAR
+ "join acc_gl_account g on g.id = c.value where c.name = ?", expense).stream().findFirst().orElse(Map.of());
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount));
for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR
"select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", "
+ "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", "
+ "case when p.id is null then 'SAVINGS' else 'DDS' end as \"type\", r.amount "
+ "from m_dds_batch_run_row r left join m_dds_party p on p.id = r.party_id "
+ "left join m_dds_agent a on a.id = r.agent_id where r.batch_run_id = ? and r.amount > 0 order by r.id",
resourceId)) {
lines.add(line("CR", (String) credited.get("accountNo"), (String) credited.get("accountName"),
(String) credited.get("type"), (BigDecimal) credited.get("amount")));
}
}
default -> {
// A change to a party, agent or scheme moves no money and has no sides.
}
}
return lines;
}
private Map<String, Object> partyLine(final String side, final String partyCode, final BigDecimal amount) {
final Map<String, Object> party = partyCode == null ? Map.of()
: this.jdbcTemplate.queryForList("select account_no as \"accountNo\", party_name as \"name\" from m_dds_party " // NOSONAR
+ "where party_code = ?", partyCode).stream().findFirst().orElse(Map.of());
return line(side, (String) party.get("accountNo"), partyCode == null ? null : partyCode + " · " + party.get("name"), "DDS", amount);
}
private static Map<String, Object> line(final String side, final String accountNo, final String accountName, final String type,
final BigDecimal amount) {
final Map<String, Object> line = new LinkedHashMap<>();
line.put("side", side);
line.put("accountNo", accountNo);
line.put("accountName", accountName);
line.put("type", type);
line.put("amount", amount);
return line;
}
/** The entry, if it is a DDS entry still waiting on a checker. */
public Map<String, Object> pendingEntry(final Long commandId) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList(ENTRIES_SQL + " and c.id = ?", commandId); // NOSONAR
if (found.isEmpty()) {
throw new CommandNotFoundException(commandId);
}
if (((Number) found.get(0).get("status")).intValue() != STATUS_AWAITING) {
throw new CommandNotAwaitingApprovalException(commandId);
}
return found.get(0);
}
/** The voucher number held for an entry since it was sent for approval, if any. */
public String heldVoucher(final Long commandId) {
return first("select voucher_no from m_voucher where reference_key = ? order by id desc limit 1", REF_KEY_PREFIX + commandId);
}
/** Ties the voucher an approval raised to the entry, so the approved list can show it. */
public void linkVoucher(final Long commandId, final String voucherNo) {
if (voucherNo != null) {
this.jdbcTemplate.update("update m_voucher set reference_key = ? where voucher_no = ? and reference_key is null", // NOSONAR
REF_KEY_PREFIX + commandId, voucherNo);
}
}
private static int statusCode(final String status) {
if ("APPROVED".equalsIgnoreCase(status)) {
return STATUS_PROCESSED;
}
return "REJECTED".equalsIgnoreCase(status) ? STATUS_REJECTED : STATUS_AWAITING;
}
private static String statusName(final int code) {
return switch (code) {
case STATUS_PROCESSED -> "APPROVED";
case STATUS_REJECTED -> "REJECTED";
case STATUS_AWAITING -> "PENDING";
default -> "OTHER";
};
}
/** One entry in the clerk's terms: what kind of work, on which accounts, for how much, and where to. */
private Map<String, Object> describe(final Map<String, Object> entry) {
final String entity = (String) entry.get("entity");
final String action = (String) entry.get("action");
final Long resourceId = entry.get("resourceId") == null ? null : ((Number) entry.get("resourceId")).longValue();
final JsonObject json = jsonOf(entry.get("json"));
final int status = ((Number) entry.get("status")).intValue();
final Map<String, Object> row = new LinkedHashMap<>();
row.put("id", entry.get("id"));
row.put("entity", entity);
row.put("action", action);
row.put("resourceId", resourceId);
row.put("status", statusName(status));
row.put("maker", entry.get("maker"));
row.put("makerName", entry.get("makerName"));
row.put("checkerName", entry.get("checkerName"));
row.put("makerId", entry.get("makerId"));
row.put("madeOn", entry.get("madeOn"));
row.put("checker", entry.get("checker"));
row.put("checkedOn", entry.get("checkedOn"));
String type = entity + " " + action;
String details = "";
BigDecimal amount = null;
switch (entity + ":" + action) {
case "PARTYTRANSACTION:CREATE" -> {
final String txnType = text(json, "transactionType");
if ("WITHDRAW_DEPOSITE".equals(txnType)) {
type = "Transfer";
details = party(text(json, "debitPartyCode")) + " → " + party(text(json, "creditorPartyCode"));
amount = number(json, "withdrawAmount");
} else {
type = txnType == null ? "DDS Transaction" : txnType.replace('_', ' ');
details = agent(longOf(json, "agentId"));
}
}
case "PARTY:PAYOUT" -> {
type = "Maturity Payout & Close";
details = partyById(resourceId) + " → " + destination(json);
amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "MATURITY_PAYOUT");
}
case "PARTY:PREMATURECLOSE", "PARTY:PREMATURECLOSEOVERRIDE" -> {
type = "PREMATURECLOSEOVERRIDE".equals(action) ? "Premature Close (within lock-in)" : "Premature Close";
details = partyById(resourceId) + " → " + destination(json);
final String reason = text(json, "lockinOverrideReason");
if (reason != null) {
details = details + " · Reason: " + reason;
}
amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "PREMATURE_PAYOUT");
}
case "PARTY:CLOSE" -> {
type = "Close DDS Account";
details = partyById(resourceId);
}
case "PARTY:CREATE" -> {
type = "New DDS Account";
details = resourceId == null ? text(json, "partyName") : partyById(resourceId);
amount = number(json, "dailyDepositAmount");
}
case "PARTY:UPDATE" -> {
type = "Edit DDS Account";
details = partyById(resourceId);
}
case "AGENT:CREATE", "AGENT:UPDATE", "AGENT:CLOSE" -> {
type = switch (action) {
case "CREATE" -> "New Agent";
case "CLOSE" -> "Close Agent";
default -> "Edit Agent";
};
details = resourceId != null ? agent(resourceId) : String.valueOf(text(json, "agentName"));
}
case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> {
type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product";
details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId);
}
case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> {
type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab";
details = resourceId == null ? "" : "Slab #" + resourceId;
}
case "DDSBATCH:POST" -> {
final Map<String, Object> run = batchRun(resourceId);
type = "Post " + String.valueOf(run.getOrDefault("jobType", "Batch")).replace('_', ' ').toLowerCase();
type = Character.toUpperCase(type.charAt(0)) + type.substring(1);
details = "Run #" + resourceId + " · " + run.getOrDefault("totalRows", 0) + " account(s) · as on " + run.getOrDefault("jobDate", "");
amount = (BigDecimal) run.get("totalAmount");
}
case "DDSCOLLECTIONENTRY:SETTLE" -> {
type = "Apply Receipt";
details = agent(longOf(json, "agentId")) + (text(json, "entryDate") == null ? "" : " · " + text(json, "entryDate"));
// Waiting: what the agent has outstanding. Approved: replaced by the voucher's own figure below.
amount = status == STATUS_AWAITING ? collectionTotal(longOf(json, "agentId"), status) : null;
}
default -> details = "";
}
row.put("type", type);
row.put("details", details);
row.put("amount", amount);
final Long commandId = ((Number) entry.get("id")).longValue();
final String voucherNo = switch (status) {
case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn"));
// Numbered when it was sent for approval; a rejected one keeps its number, marked cancelled.
case STATUS_AWAITING -> heldVoucher(commandId);
case STATUS_REJECTED -> first("select voucher_no from m_voucher where reference_key = ? order by id desc limit 1",
REF_KEY_PREFIX + commandId + ":REJECTED");
default -> null;
};
row.put("voucherNo", voucherNo);
row.put("voucherCancelled", status == STATUS_REJECTED && voucherNo != null);
if (voucherNo != null && "DDSCOLLECTIONENTRY".equals(entity)) {
// What was actually applied, as the voucher records it.
final List<BigDecimal> applied = this.jdbcTemplate.queryForList("select amount from m_voucher where voucher_no = ?", // NOSONAR
BigDecimal.class, voucherNo);
row.put("amount", applied.isEmpty() ? row.get("amount") : applied.get(0));
}
return row;
}
private String voucherFor(final Long commandId, final String entity, final String action, final Long resourceId, final JsonObject json,
final String madeOn) {
final List<String> linked = this.jdbcTemplate.queryForList( // NOSONAR
"select voucher_no from m_voucher where reference_key = ? order by id desc limit 1", String.class, REF_KEY_PREFIX + commandId);
if (!linked.isEmpty()) {
return linked.get(0);
}
// Approved without passing through this screen - a user whose work needs no checker, say.
if ("DDSBATCH".equals(entity) && resourceId != null) {
return first("select voucher_no from m_voucher where reference_type = 'DDS_BATCH_RUN' and reference_id = ? order by id desc limit 1",
resourceId);
}
if ("PARTYTRANSACTION".equals(entity) && resourceId != null) {
return first("select voucher_no from m_dds_party_transaction where id = ?", resourceId);
}
if ("DDSCOLLECTIONENTRY".equals(entity) && longOf(json, "agentId") != null && madeOn != null) {
final List<String> found = this.jdbcTemplate.queryForList( // NOSONAR
"select voucher_no from m_voucher where voucher_type = 'DDS_APPLY_RECEIPT' and reference_id = ? "
+ "and created_at >= cast(? as timestamp) - interval '1 minute' order by id limit 1",
String.class, longOf(json, "agentId"), madeOn);
return found.isEmpty() ? null : found.get(0);
}
if ("PARTY".equals(entity) && resourceId != null && ("PAYOUT".equals(action) || action.startsWith("PREMATURECLOSE"))) {
return first("select voucher_no from m_dds_party_transaction where party_id = ? and voucher_no is not null "
+ "and transaction_type in ('MATURITY_PAYOUT', 'PREMATURE_PAYOUT') order by id desc limit 1", resourceId);
}
return null;
}
private String destination(final JsonObject json) {
final String toAccount = text(json, "toSavingsAccountNo");
if (toAccount != null && !toAccount.isBlank()) {
return "Savings " + toAccount;
}
final Long glId = longOf(json, "sundryGlAccountId");
if (glId != null) {
final String gl = first("select gl_code || ' ' || name from acc_gl_account where id = ?", glId);
return "Sundry " + (gl == null ? "#" + glId : gl);
}
return "Sundry";
}
private String party(final String partyCode) {
if (partyCode == null) {
return "";
}
final String name = first("select party_name from m_dds_party where party_code = ?", partyCode);
return name == null ? partyCode : partyCode + " " + name;
}
private String partyById(final Long partyId) {
if (partyId == null) {
return "";
}
final String found = first("select party_code || ' ' || party_name from m_dds_party where id = ?", partyId);
return found == null ? "Party #" + partyId : found;
}
private String agent(final Long agentId) {
if (agentId == null) {
return "";
}
final String found = first("select agent_code || ' ' || coalesce(agent_name, '') from m_dds_agent where id = ?", agentId);
return found == null ? "Agent #" + agentId : found.trim();
}
private String scheme(final Long accountTypeId) {
return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId);
}
private BigDecimal balanceOf(final Long partyId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select current_balance from m_dds_party where id = ?", // NOSONAR
BigDecimal.class, partyId);
return found.isEmpty() ? null : found.get(0);
}
private BigDecimal paidOut(final Long partyId, final String transactionType) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select amount from m_dds_party_transaction where party_id = ? and transaction_type = ? order by id desc limit 1",
BigDecimal.class, partyId, transactionType);
return found.isEmpty() ? null : found.get(0);
}
private BigDecimal collectionTotal(final Long agentId, final int status) {
if (agentId == null) {
return null;
}
final String state = status == STATUS_AWAITING ? "PENDING" : "SETTLED";
final List<BigDecimal> found = this.jdbcTemplate.queryForList( // NOSONAR
"select sum(amount) from m_dds_collection_entry where agent_id = ? and status = ?", BigDecimal.class, agentId, state);
return found.isEmpty() ? null : found.get(0);
}
private Map<String, Object> batchRun(final Long batchRunId) {
if (batchRunId == null) {
return Map.of();
}
return this.jdbcTemplate.queryForList("select job_type as \"jobType\", total_rows as \"totalRows\", total_amount as \"totalAmount\", " // NOSONAR
+ "to_char(job_date, 'DD/MM/YYYY') as \"jobDate\" from m_dds_batch_run where id = ?", batchRunId).stream().findFirst()
.orElse(Map.of());
}
private String first(final String sql, final Object param) {
final List<String> found = this.jdbcTemplate.queryForList(sql, String.class, param); // NOSONAR
return found.isEmpty() ? null : found.get(0);
}
private static JsonObject jsonOf(final Object text) {
if (!(text instanceof String json) || json.isBlank()) {
return new JsonObject();
}
try {
final JsonElement element = JsonParser.parseString(json);
return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject();
} catch (RuntimeException e) {
return new JsonObject();
}
}
private static String text(final JsonObject json, final String name) {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null;
}
private static BigDecimal number(final JsonObject json, final String name) {
try {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsBigDecimal() : null;
} catch (RuntimeException e) {
return null;
}
}
private static Long longOf(final JsonObject json, final String name) {
try {
return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsLong() : null;
} catch (RuntimeException e) {
return null;
}
}
}
@@ -30,6 +30,7 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -50,6 +51,7 @@ public class BatchRunsApiResource {
private final PlatformSecurityContext context;
private final BatchRunReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -101,6 +103,8 @@ public class BatchRunsApiResource {
.postDdsBatchRun(batchRunId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
return ddsVoucherService.submitHoldingVoucher(ddsVoucherService.voucherTypeOfRun(batchRunId), DdsVoucherService.REF_BATCH_RUN,
batchRunId, () -> ddsVoucherService.amountOfRun(batchRunId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
}
@@ -59,6 +59,13 @@ public interface BatchRunWritePlatformService {
*/
BigDecimal postInterestThrough(Party party, LocalDate through);
/**
* The interest an account is owed up to a day and has not yet had credited - calculated and left
* unposted, or not calculated at all. Nothing is changed; this only says what a calculation and
* posting run would still credit.
*/
BigDecimal interestNotYetPosted(Party party, LocalDate through);
/**
* Turns over the minors' accounts whose holders have come of age and had their own KYC verified.
*
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
@@ -40,6 +41,7 @@ import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.calculation.DdsCommissionCalculation;
import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRun;
@@ -62,6 +64,8 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* The DDS Interest Part: the four Calculate/Post job pairs from the legacy menu.
@@ -120,6 +124,16 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
*/
private final ObjectProvider<PartyWritePlatformService> partyWritePlatformService;
/** An agent's collections no commission run has counted yet, up to a date, and the earliest of them. */
private static final String UNCOMMISSIONED_COLLECTIONS_SQL = "select coalesce(sum(amount), 0) as volume, "
+ "min(transaction_date) as from_date from m_dds_party_transaction where agent_id = ? and transaction_type = 'PARTY_TRAN' "
+ "and direction = 'CREDIT' and commission_batch_run_id is null and transaction_date <= ?";
/** Marks those collections as counted by a run. */
private static final String MARK_COLLECTIONS_COMMISSIONED_SQL = "update m_dds_party_transaction set commission_batch_run_id = ? "
+ "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is null "
+ "and transaction_date <= ?";
/**
* Everything needed to say whether an agent's commission can be paid into an account, and if not,
* why. Read in one go so each reason can be named rather than the deposit simply being attempted.
@@ -413,16 +427,15 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
continue;
}
final LocalDate fromDate = agent.getLastCommissionDate() != null ? agent.getLastCommissionDate().plusDays(1)
: agent.getAgentOpeningDate();
if (fromDate.isAfter(jobDate)) {
continue;
}
final BigDecimal collectionVolume = this.partyTransactionRepository.sumPartyTranCredits(agent.getId(), fromDate, jobDate);
// Every collection no run has counted yet, whatever day it was collected on. A collection is
// credited only when the agent's cash receipt is applied - often a day or more later - so a
// window of collection dates starting after the last run would miss the ones applied late.
final Map<String, Object> pending = this.jdbcTemplate.queryForMap(UNCOMMISSIONED_COLLECTIONS_SQL, agent.getId(), jobDate); // NOSONAR
final BigDecimal collectionVolume = (BigDecimal) pending.get("volume");
if (collectionVolume == null || collectionVolume.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final LocalDate fromDate = ((java.sql.Date) pending.get("from_date")).toLocalDate();
// A volume that falls in a gap in the chart is a fault in the chart, not a reason to pay
// this agent nothing: an agent who worked all month would read exactly like one who
@@ -499,8 +512,27 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
// tell a slow run from a hung one. The context a background thread does not inherit is handed
// over with it: the tenant, or there is no database; the business date, or the postings are
// dated by the wall clock; the signed-in user, or every row fails its audit column.
this.postingRunner.post(batchRunId, ThreadLocalContextUtil.getTenant(), ThreadLocalContextUtil.getBusinessDates(),
ThreadLocalContextUtil.getDataSourceContext(), SecurityContextHolder.getContext());
//
// Only once this request has committed. A run sent for a checker's approval is rolled back
// after this method returns, and a background thread started here would already be crediting
// accounts by then - the approval bypassed. After commit it starts only for a request that
// stands: the checker's approval, or a user who needs none.
final var tenant = ThreadLocalContextUtil.getTenant();
final var businessDates = ThreadLocalContextUtil.getBusinessDates();
final var dataSourceContext = ThreadLocalContextUtil.getDataSourceContext();
final var securityContext = SecurityContextHolder.getContext();
final Runnable start = () -> this.postingRunner.post(batchRunId, tenant, businessDates, dataSourceContext, securityContext);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
start.run();
}
});
} else {
start.run();
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
@@ -545,6 +577,22 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
* than a second implementation that could drift from it.
*/
private void applyRows(final BatchRun batchRun) {
// One DDS voucher for the whole run - the expense on one side, every account credited on the
// other. A calculation run moves no money and raises none.
this.ddsLedger.withVoucher(voucherTypeFor(batchRun.getJobType()), DdsVoucherService.REF_BATCH_RUN, batchRun.getId(), () -> {
applyEachRow(batchRun);
return null;
});
}
private static String voucherTypeFor(final String jobType) {
if (COMMISSION_POSTING.equals(jobType)) {
return DdsVoucherService.TYPE_COMMISSION;
}
return MATURITY_AUTO_CLOSE.equals(jobType) ? DdsVoucherService.TYPE_MATURITY_PAYOUT : DdsVoucherService.TYPE_INTEREST;
}
private void applyEachRow(final BatchRun batchRun) {
final String jobType = batchRun.getJobType();
final LocalDate jobDate = batchRun.getJobDate();
@@ -555,7 +603,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true);
case INTEREST_POSTING -> postInterest(row, batchRun, false);
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
case COMMISSION_CALCULATION -> accrueCommission(row, jobDate);
case COMMISSION_CALCULATION -> accrueCommission(row, batchRun);
case COMMISSION_POSTING -> payCommission(row, batchRun);
case MATURITY_AUTO_CLOSE -> closeAtMaturity(row);
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
@@ -622,13 +670,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
// The same interest on the bank's own books. A party opened before DDS began opening real
// accounts has none, and keeps its DDS balance alone rather than the run refusing to pay
// everybody because of one old record.
if (party.getSavingsAccountId() != null) {
this.ddsLedger.postInterest(party.getSavingsAccountId(), batchRun.getJobDate(), row.getAmount(),
(odInterest ? "DDS OD interest" : "DDS interest") + ", batch run #" + batchRun.getId());
}
// The same interest on the bank's own books - on the party's savings account where it still
// has one, otherwise on DDS Customer Deposits against DDS Interest Expense.
this.ddsLedger.postInterest(party, batchRun.getJobDate(), row.getAmount(),
(odInterest ? "DDS OD interest" : "DDS interest") + ", batch run #" + batchRun.getId());
final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST";
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType,
@@ -637,15 +682,28 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
/**
* Adds what the agent earned to what they are owed, and withholds the TDS that went with it.
* Guarded the same way as interest accrual: a period already calculated through is not paid again.
* Guarded by the collections themselves: each is marked with the run that counted it, so none is
* paid twice however many runs are generated over it.
*/
private void accrueCommission(final BatchRunRow row, final LocalDate jobDate) {
private void accrueCommission(final BatchRunRow row, final BatchRun batchRun) {
final Agent agent = agentOf(row.getAgentId());
final LocalDate jobDate = batchRun.getJobDate();
final LocalDate calculatedThrough = agent.getLastCommissionDate();
if (calculatedThrough != null && !calculatedThrough.isBefore(row.getToDate())) {
// The collections this row was worked out on are marked as counted by this run, so no later run
// counts them again. What is still unmarked has to be exactly what was previewed: nothing means
// another run got there first; anything else means receipts were applied since Generate, and
// the figure on screen is no longer the figure that would be paid.
final BigDecimal stillUncounted = (BigDecimal) this.jdbcTemplate
.queryForMap(UNCOMMISSIONED_COLLECTIONS_SQL, agent.getId(), row.getToDate()).get("volume"); // NOSONAR
if (stillUncounted == null || stillUncounted.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
if (row.getBaseAmount() == null || stillUncounted.compareTo(row.getBaseAmount()) != 0) {
throw new PlatformDataIntegrityException("error.msg.dds.commission.run.out.of.date",
"Collections for " + agent.getAgentCode() + " have changed since this run was generated (" + row.getBaseAmount()
+ " then, " + stillUncounted + " now). Discard it and generate it again.");
}
this.jdbcTemplate.update(MARK_COLLECTIONS_COMMISSIONED_SQL, batchRun.getId(), agent.getId(), row.getToDate()); // NOSONAR
final BigDecimal tds = row.getTdsAmount() == null ? BigDecimal.ZERO : row.getTdsAmount();
agent.accrueCommission(row.getAmount(), tds, jobDate);
@@ -835,7 +893,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
public int postMaturityInterest(final LocalDate businessDate) {
int posted = 0;
for (final Party party : this.partyRepository.findMaturedWithInterestOutstanding(businessDate)) {
if (postInterestThrough(party, party.getMaturityDate()).compareTo(BigDecimal.ZERO) > 0) {
// Each account's last stretch of interest is its own slip.
final BigDecimal paid = this.ddsLedger.withVoucher(DdsVoucherService.TYPE_INTEREST, DdsVoucherService.REF_PARTY,
party.getId(), () -> postInterestThrough(party, party.getMaturityDate()));
if (paid.compareTo(BigDecimal.ZERO) > 0) {
posted++;
}
}
@@ -890,15 +951,24 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
@Transactional
@Override
public BigDecimal postInterestThrough(final Party party, final LocalDate through) {
final BigDecimal rate = party.getInterestRate();
if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
if (through == null) {
return BigDecimal.ZERO;
}
// Interest already calculated but never posted is the customer's too. Without this, an account
// closed between a calculation run and its posting run would shut with that interest still
// parked against it - calculated through the day, so not counted again below, and never paid.
final BigDecimal alreadyCalculated = postCalculatedInterest(party, through);
final BigDecimal rate = party.getInterestRate();
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
return alreadyCalculated;
}
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
: party.getOpeningDate();
if (!accrueFrom.isBefore(through)) {
return BigDecimal.ZERO;
return alreadyCalculated;
}
final List<PartyTransaction> movements = this.partyTransactionRepository.findForPartiesUpTo(List.of(party.getId()), through);
@@ -906,23 +976,56 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
through);
final BigDecimal interest = DdsInterestCalculation.interestOn(dailyProduct, rate);
if (interest.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
return alreadyCalculated;
}
party.accrueInterest(interest, through, false);
final BigDecimal newBalance = party.postInterest(interest, false);
this.partyRepository.saveAndFlush(party);
// The same interest on the bank's own books. A party opened before DDS began opening real
// accounts has none, and keeps its DDS balance alone rather than the posting being refused.
if (party.getSavingsAccountId() != null) {
this.ddsLedger.postInterest(party.getSavingsAccountId(), through, interest, "DDS interest to maturity");
}
// The same interest on the bank's own books, wherever the party's account is kept.
this.ddsLedger.postInterest(party, through, interest, "DDS interest to maturity");
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), through, "INTEREST_POST", DIRECTION_CREDIT, null,
null, null, interest, null, null, "Interest to maturity", newBalance));
return interest;
return alreadyCalculated.add(interest);
}
@Override
public BigDecimal interestNotYetPosted(final Party party, final LocalDate through) {
final BigDecimal calculated = party.accruedInterest(false) == null ? BigDecimal.ZERO : party.accruedInterest(false);
final BigDecimal rate = party.getInterestRate();
if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
return calculated;
}
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
: party.getOpeningDate();
if (accrueFrom == null || !accrueFrom.isBefore(through)) {
return calculated;
}
final List<PartyTransaction> movements = this.partyTransactionRepository.findForPartiesUpTo(List.of(party.getId()), through);
final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(), movementsOf(movements), accrueFrom,
through);
return calculated.add(DdsInterestCalculation.interestOn(dailyProduct, rate));
}
/**
* Credits interest a calculation run has already worked out but no posting run has credited yet,
* the same way a posting run would: onto the balance, onto the bank's books, and into the account's
* history.
*/
private BigDecimal postCalculatedInterest(final Party party, final LocalDate on) {
final BigDecimal pending = party.accruedInterest(false);
if (pending == null || pending.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
final BigDecimal newBalance = party.postInterest(pending, false);
this.partyRepository.saveAndFlush(party);
this.ddsLedger.postInterest(party, on, pending, "DDS interest calculated earlier, posted on closure");
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), on, "INTEREST_POST", DIRECTION_CREDIT, null,
null, null, pending, null, null, "Interest calculated earlier, posted on closure", newBalance));
return pending;
}
private void calculateAndPost(final String jobType, final LocalDate jobDate, final String subType) {
@@ -31,7 +31,10 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -55,6 +58,7 @@ public class CollectionEntriesApiResource {
private final PlatformSecurityContext context;
private final CollectionEntryReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -69,6 +73,42 @@ public class CollectionEntriesApiResource {
return readPlatformService.retrieveAgentDay(agentId, date);
}
@GET
@Path("pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Everything an agent still has outstanding", description = "Every pending entry for the agent, whatever day "
+ "it was written, with what they add up to - what a cashier's DDS receipt is applied against.")
public AgentCollectionSummaryData retrieveAgentPending(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAgentPending(agentId);
}
@GET
@Path("cashreceipts")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Cashier receipts into an agent's GL account", description = "Without voucherNo: the DDS receipts the manager "
+ "has approved and nobody has applied. With voucherNo: that voucher on the agent's account, in whatever state, so the "
+ "screen can say why it cannot be used.")
public List<Map<String, Object>> retrieveAgentCashReceipts(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId,
@QueryParam("voucherNo") @Parameter(description = "voucherNo") final String voucherNo,
@QueryParam("includeWaiting") @Parameter(description = "also list receipts still awaiting the manager") final Boolean includeWaiting) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAgentCashReceipts(agentId, voucherNo, Boolean.TRUE.equals(includeWaiting));
}
@GET
@Path("cashreceipts/{receiptId}/glentries")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "A cashier receipt's own GL entries", description = "The receipt's Dr/Cr lines, read under its own transaction id.")
public List<Map<String, Object>> retrieveCashReceiptGlEntries(
@PathParam("receiptId") @Parameter(description = "receiptId") final Long receiptId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveCashReceiptGlEntries(receiptId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@@ -87,14 +127,19 @@ public class CollectionEntriesApiResource {
@Path("settle")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Settle an agent's day", description = "Mandatory: agentId, entryDate. Posts every outstanding entry for that "
+ "agent and date as a real collection - all of them or none. No amount is accepted: what is settled is what the entries say.")
@Operation(summary = "Settle an agent's day", description = "Mandatory: agentId, and either entryDate or cashierDepositRequestIds "
+ "with entryDates. With entryDate, posts every outstanding entry for that agent and date as a real collection. With "
+ "cashierDepositRequestIds (approved DDS receipts on the agent's GL account, each used once) and entryDates (yyyy-MM-dd), "
+ "applies the receipts to every outstanding entry on those days - only if they cover them exactly. All or none either way. "
+ "No amount is accepted: what is settled is what the entries say.")
public CommandProcessingResult settle(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.settleDdsCollectionEntries() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
final Long agentId = DdsVoucherService.idIn(apiRequestBodyAsJson, "agentId");
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_APPLY_RECEIPT, DdsVoucherService.REF_AGENT, agentId,
() -> ddsVoucherService.amountPendingFor(agentId), () -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
@DELETE
@@ -37,6 +37,16 @@ public interface CollectionEntryRepository extends JpaRepository<CollectionEntry
+ "and e.status = 'PENDING' order by e.id")
List<CollectionEntry> findPendingFor(@Param("agentId") Long agentId, @Param("entryDate") LocalDate entryDate);
/**
* Everything this agent still has outstanding, whatever day it was written on.
*
* The day's cash can reach the counter the day after the round - written on Friday, brought in on
* Saturday - so a receipt is applied against every outstanding line, not one date's. Oldest first,
* the order the money was collected in.
*/
@Query("select e from CollectionEntry e where e.agent.id = :agentId and e.status = 'PENDING' order by e.entryDate, e.id")
List<CollectionEntry> findAllPendingFor(@Param("agentId") Long agentId);
/**
* Everything written for this agent on this date, settled or not.
*
@@ -58,4 +68,12 @@ public interface CollectionEntryRepository extends JpaRepository<CollectionEntry
@Query("select count(e) from CollectionEntry e where e.party.id = :partyId and e.entryDate = :entryDate "
+ "and e.status <> 'CANCELLED'")
long countLiveEntriesFor(@Param("partyId") Long partyId, @Param("entryDate") LocalDate entryDate);
/**
* What is written down for this account and not yet settled. Counted with what has been settled
* when a new line is checked against the scheme, so arrears cleared on an entry still waiting at
* the counter cannot be cleared a second time on the next one.
*/
@Query("select coalesce(sum(e.amount), 0) from CollectionEntry e where e.party.id = :partyId and e.status = 'PENDING'")
java.math.BigDecimal sumPendingFor(@Param("partyId") Long partyId);
}
@@ -35,6 +35,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -58,10 +59,10 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
Arrays.asList("agentId", "entryDate", ENTRIES, "dateFormat", "locale"));
private static final Set<String> SUPPORTED_ENTRY_PARAMETERS = new HashSet<>(
Arrays.asList("partyCode", "amount", "transactionMode", "chequeNo", "tokenNo", "description"));
Arrays.asList("partyCode", "amount", "advanceDays", "arrearsDays", "transactionMode", "chequeNo", "tokenNo", "description"));
private static final Set<String> SUPPORTED_SETTLE_PARAMETERS = new HashSet<>(
Arrays.asList("agentId", "entryDate", "dateFormat", "locale"));
Arrays.asList("agentId", "entryDate", "cashierDepositRequestIds", "entryDates", "dateFormat", "locale"));
private final FromJsonHelper fromApiJsonHelper;
@@ -97,7 +98,24 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
v.reset().parameter(ENTRIES).parameterAtIndexArray("partyCode", i).value(stringOf(row, "partyCode")).notBlank();
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
// Days ahead or days of arrears, never both - one line is one kind of collection. The
// amount follows from the days, so it is not required when they are given; where it is
// sent as well it has to agree, which the service checks against the scheme.
final Integer advanceDays = integerOf(row, "advanceDays");
final Integer arrearsDays = integerOf(row, "arrearsDays");
if (advanceDays != null && arrearsDays != null) {
v.reset().parameter(ENTRIES).failWithCodeAndMessage("advance.and.arrears",
"Row " + (i + 1) + ": a line is either days in advance or days of arrears, not both");
}
if (advanceDays != null) {
v.reset().parameter(ENTRIES).parameterAtIndexArray("advanceDays", i).value(advanceDays)
.inMinMaxRange(1, DdsSchedule.MAX_ADVANCE_DAYS);
} else if (arrearsDays != null) {
v.reset().parameter(ENTRIES).parameterAtIndexArray("arrearsDays", i).value(arrearsDays)
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
} else {
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
}
final String mode = stringOf(row, "transactionMode");
v.reset().parameter(ENTRIES).parameterAtIndexArray("transactionMode", i).value(mode).notBlank();
@@ -131,7 +149,33 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull();
// The cashier's receipts settle the days picked; without them, one day is named.
if (this.fromApiJsonHelper.parameterExists("cashierDepositRequestIds", element)) {
final var receipts = element.getAsJsonObject().get("cashierDepositRequestIds");
v.reset().parameter("cashierDepositRequestIds").value(receipts != null && receipts.isJsonArray() ? receipts.getAsJsonArray() : null)
.notNull().jsonArrayNotEmpty();
final var days = element.getAsJsonObject().get("entryDates");
v.reset().parameter("entryDates").value(days != null && days.isJsonArray() ? days.getAsJsonArray() : null).notNull()
.jsonArrayNotEmpty();
if (receipts != null && receipts.isJsonArray()) {
for (int i = 0; i < receipts.getAsJsonArray().size(); i++) {
final var id = receipts.getAsJsonArray().get(i);
v.reset().parameter("cashierDepositRequestIds").parameterAtIndexArray("id", i)
.value(id.isJsonPrimitive() && id.getAsJsonPrimitive().isNumber() ? id.getAsLong() : null).notNull()
.integerGreaterThanZero();
}
}
if (days != null && days.isJsonArray()) {
for (int i = 0; i < days.getAsJsonArray().size(); i++) {
final var day = days.getAsJsonArray().get(i);
v.reset().parameter("entryDates").parameterAtIndexArray("date", i)
.value(day.isJsonPrimitive() ? day.getAsString() : null).notBlank()
.matchesRegularExpression("\\d{4}-\\d{2}-\\d{2}", "Dates must be yyyy-MM-dd");
}
}
} else {
v.reset().parameter("entryDate").value(this.fromApiJsonHelper.extractLocalDateNamed("entryDate", element)).notNull();
}
// No amount is asked for. What is settled is what the entries say, to the rupee - a figure
// typed here would be a second opinion about a total the entries already give, and the two
@@ -143,6 +187,11 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Integer integerOf(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() && !json.get(field).getAsString().isBlank() ? json.get(field).getAsInt()
: null;
}
private BigDecimal decimalOf(final JsonObject json) {
return json.has("amount") && !json.get("amount").isJsonNull() ? json.get("amount").getAsBigDecimal() : null;
}
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.dds.collection.service;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import org.apache.fineract.portfolio.dds.collection.data.AgentCollectionSummaryData;
public interface CollectionEntryReadPlatformService {
@@ -31,4 +33,13 @@ public interface CollectionEntryReadPlatformService {
* Two calls would be two chances for the two screens to disagree about the same day.
*/
AgentCollectionSummaryData retrieveAgentDay(Long agentId, LocalDate entryDate);
/** Everything the agent still has outstanding, whatever day it was written. */
AgentCollectionSummaryData retrieveAgentPending(Long agentId);
/** The cashier's receipts into the agent's GL account - the applicable ones, or one voucher by number. */
List<Map<String, Object>> retrieveAgentCashReceipts(Long agentId, String voucherNo, boolean includeWaiting);
/** A cashier's receipt's own Dr/Cr lines. */
List<Map<String, Object>> retrieveCashReceiptGlEntries(Long receiptId);
}
@@ -23,6 +23,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.organisation.agent.domain.Agent;
@@ -52,7 +53,8 @@ public class CollectionEntryReadPlatformServiceImpl implements CollectionEntryRe
+ " p.current_balance as partyBalance, p.daily_deposit_amount as dailyDepositAmount, "
+ " e.entry_date as entryDate, e.amount as amount, e.transaction_mode as transactionMode, "
+ " e.cheque_no as chequeNo, e.token_no as tokenNo, e.description as description, "
+ " e.status as status, e.settled_on as settledOn, e.party_transaction_id as partyTransactionId "
+ " e.status as status, e.settled_on as settledOn, e.party_transaction_id as partyTransactionId, "
+ " e.advance_days as advanceDays, e.arrears_days as arrearsDays "
+ " from m_dds_collection_entry e "
+ " join m_dds_agent a on a.id = e.agent_id "
+ " join m_dds_party p on p.id = e.party_id";
@@ -79,10 +81,88 @@ public class CollectionEntryReadPlatformServiceImpl implements CollectionEntryRe
.status(rs.getString("status")) //
.settledOn(JdbcSupport.getLocalDate(rs, "settledOn")) //
.partyTransactionId(JdbcSupport.getLong(rs, "partyTransactionId")) //
.advanceDays(JdbcSupport.getInteger(rs, "advanceDays")) //
.arrearsDays(JdbcSupport.getInteger(rs, "arrearsDays")) //
.build();
}
}
/**
* Everything the agent still has outstanding, whatever day it was written - what a cashier's
* receipt is applied against. Same shape as a day, with no date on it.
*/
@Override
public AgentCollectionSummaryData retrieveAgentPending(final Long agentId) {
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
final EntryMapper mapper = new EntryMapper();
final String sql = "select " + mapper.schema() + " where e.agent_id = ? and e.status = '" + CollectionEntry.PENDING + "' "
+ " order by e.entry_date, e.id";
final List<CollectionEntryData> entries = this.jdbcTemplate.query(sql, mapper, agentId);
final BigDecimal expected = entries.stream().map(CollectionEntryData::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
return AgentCollectionSummaryData.builder() //
.agentId(agent.getId()) //
.agentCode(agent.getAgentCode()) //
.agentName(agent.getAgentName()) //
.pendingCount(entries.size()) //
.expectedAmount(expected) //
.settledCount(0) //
.settledAmount(BigDecimal.ZERO) //
.entries(entries) //
.build();
}
/**
* The cashier's receipts into this agent's GL account.
*
* Without a voucher number: only what can be applied - DDS receipts the manager has approved and
* nobody has applied yet - or, asked to include waiting ones, those still with the manager as well,
* so the counter can see cash that has come in but cannot be applied yet. With one: that voucher on
* this account whatever state it is in, so the screen can say why it cannot be used rather than
* simply not finding it. Read straight off the cashier's own table and never written.
*/
@Override
public List<Map<String, Object>> retrieveAgentCashReceipts(final Long agentId, final String voucherNo, final boolean includeWaiting) {
final Agent agent = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
if (agent.getGlAccount() == null) {
return List.of();
}
final StringBuilder sql = new StringBuilder("select d.id as \"id\", d.voucher_no as \"voucherNo\", "
+ " to_char(d.txn_date, 'YYYY-MM-DD') as \"txnDate\", d.amount as \"amount\", d.status as \"status\", "
+ " cv.code_value as \"entryType\", d.account_no as \"glCode\", g.name as \"glName\", "
+ " d.cashier_name as \"cashierName\", d.manager_name as \"managerName\", d.description as \"description\", "
+ " d.txn_id as \"txnId\", "
+ " exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = d.id) as \"applied\" "
+ " from m_cashier_deposit_request d "
+ " left join m_code_value cv on cv.id = d.entry_type_cv_id "
+ " left join acc_gl_account g on g.id = d.gl_account_id "
+ " where trim(d.account_no) = ? ");
final Object[] params;
if (voucherNo != null && !voucherNo.isBlank()) {
sql.append(" and d.voucher_no = ? ");
params = new Object[] { agent.getGlAccount().getGlCode(), voucherNo.trim() };
} else {
sql.append(" and cv.code_value = 'DDS' ")
.append(includeWaiting ? " and d.status not in ('REJECTED', 'CANCELLED') " : " and d.status = 'APPROVED' ")
.append(" and not exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = d.id) ");
params = new Object[] { agent.getGlAccount().getGlCode() };
}
sql.append(" order by d.txn_date, d.id");
return this.jdbcTemplate.queryForList(sql.toString(), params); // NOSONAR
}
/** A receipt's own Dr/Cr lines, read under its own transaction id. */
@Override
public List<Map<String, Object>> retrieveCashReceiptGlEntries(final Long receiptId) {
return this.jdbcTemplate.queryForList("select je.id as \"id\", g.gl_code as \"glCode\", g.name as \"glName\", " // NOSONAR
+ " case je.type_enum when 1 then 'CR' else 'DR' end as \"drCr\", je.amount as \"amount\", "
+ " to_char(je.entry_date, 'YYYY-MM-DD') as \"entryDate\", je.reversed as \"reversed\" "
+ " from acc_gl_journal_entry je join acc_gl_account g on g.id = je.account_id "
+ " where je.transaction_id = (select d.txn_id from m_cashier_deposit_request d where d.id = ?) order by je.id", receiptId);
}
@Override
public AgentCollectionSummaryData retrieveAgentDay(final Long agentId, final LocalDate entryDate) {
// Named first, so an agent id that matches nothing is refused here rather than quietly
@@ -23,14 +23,29 @@ import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyArrearsDaysExceededException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
@@ -66,6 +81,11 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
private final AgentRepository agentRepository;
private final PartyRepository partyRepository;
private final PartyTransactionWritePlatformService partyTransactionWritePlatformService;
private final PartyTransactionRepository partyTransactionRepository;
private final DdsLedger ddsLedger;
private final GLAccountRepository glAccountRepository;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Transactional
@Override
@@ -75,18 +95,37 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
// A round is written on the day, or caught up on afterwards - never ahead of it.
if (entryDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw refused("entry.future.date", "A collection cannot be written down for " + entryDate + ", which has not happened yet.");
}
final JsonArray rows = command.arrayOfParameterNamed("entries");
final List<Long> written = new ArrayList<>(rows.size());
final Set<Long> partiesInRound = new HashSet<>();
BigDecimal total = BigDecimal.ZERO;
for (int i = 0; i < rows.size(); i++) {
final JsonObject row = rows.get(i).getAsJsonObject();
final Party party = collectableParty(stringOf(row, "partyCode"), agent);
final BigDecimal amount = row.get("amount").getAsBigDecimal();
// One line per account per day. A daily scheme takes one instalment a day, so a second line
// is nearly always the same collection entered twice; more than a day's money is one line
// with days in advance or days of arrears on it. Refused here, not only on the agent's
// screen, because the screen is not the only way in.
if (!partiesInRound.add(party.getId()) || this.collectionEntryRepository.countLiveEntriesFor(party.getId(), entryDate) > 0) {
throw refused("entry.duplicate", party.getPartyCode() + " already has a collection written down for " + entryDate
+ ". One line per account per day - use Advance or Previous days to cover more than one day.");
}
final Integer advanceDays = integerOf(row, "advanceDays");
final Integer arrearsDays = integerOf(row, "arrearsDays");
final BigDecimal amount = checkedAmount(party, entryDate, row, advanceDays, arrearsDays);
final CollectionEntry entry = new CollectionEntry(agent, party, entryDate, amount,
DdsTransactionMode.canonicalOrOriginal(stringOf(row, "transactionMode")), stringOf(row, "chequeNo"),
stringOf(row, "tokenNo"), stringOf(row, "description"));
entry.covering(advanceDays, arrearsDays);
written.add(this.collectionEntryRepository.saveAndFlush(entry).getId());
total = total.add(amount);
@@ -121,6 +160,19 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
public CommandProcessingResult settleAgentDay(final JsonCommand command) {
this.fromApiJsonDeserializer.validateForSettle(command.json());
// One DDS voucher for the day applied: the agent's account on one side, each customer credited on the other.
return this.ddsLedger.withVoucher(DdsVoucherService.TYPE_APPLY_RECEIPT, DdsVoucherService.REF_AGENT,
command.longValueOfParameterNamed("agentId"), () -> settleWithinVoucher(command));
}
private CommandProcessingResult settleWithinVoucher(final JsonCommand command) {
// Named by the cashier's receipts: the cash has already reached the bank and sits on the agent's
// GL account, and settling hands it on from there. Without them, the day is settled the way it
// always was.
if (command.parameterExists(RECEIPT_IDS)) {
return settleAgainstReceipts(command);
}
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
final LocalDate entryDate = command.localDateValueOfParameterNamed("entryDate");
@@ -158,6 +210,242 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build();
}
private static final String RECEIPT_IDS = "cashierDepositRequestIds";
private static final String ENTRY_DATES = "entryDates";
/**
* A receipt as the cashier left it, locked for the length of this settlement so the same voucher
* cannot be applied twice by two clerks at once. Read only - the cashier's row is never written here.
*/
private static final String RECEIPT_SQL = "select d.id, d.voucher_no, d.account_no, d.amount, d.status, d.gl_account_id, "
+ " (select cv.code_value from m_code_value cv where cv.id = d.entry_type_cv_id) as entry_type "
+ " from m_cashier_deposit_request d where d.id = ? for update";
private static final String RECEIPT_USED_SQL = "select count(*) from m_dds_receipt_application_voucher where cashier_deposit_request_id = ?";
private static final String INSERT_APPLICATION_SQL = "insert into m_dds_receipt_application "
+ " (agent_id, applied_on, total_amount, entry_count, gl_account_id, journal_transaction_id, createdby_id, created_date) "
+ " values (?, ?, ?, ?, ?, null, ?, current_timestamp) returning id";
private static final String INSERT_APPLICATION_VOUCHER_SQL = "insert into m_dds_receipt_application_voucher "
+ " (application_id, cashier_deposit_request_id, voucher_no, amount) values (?, ?, ?, ?)";
private static final String SET_JOURNAL_SQL = "update m_dds_receipt_application set journal_transaction_id = ? where id = ?";
/**
* Applies the cashier's DDS receipts to the agent's entries for the days picked.
*
* The same as Apply on a Share Collection receipt, for any number of vouchers and days: a day's cash
* can arrive in two visits, and two days' in one. Every voucher has to be the agent's - a DDS
* receipt, approved, into this agent's own GL account - and applied once. Together they have to
* cover the picked days' entries exactly: what the agent wrote down is what the customers are owed,
* so vouchers short of it leave somebody uncredited and vouchers over it leave money on the agent's
* account nobody can place. All of it or none.
*/
private CommandProcessingResult settleAgainstReceipts(final JsonCommand command) {
final Agent agent = agentOf(command.longValueOfParameterNamed("agentId"));
final GLAccount agentAccount = agent.getGlAccount();
if (agentAccount == null) {
throw refused("agent.no.gl", agent.getAgentCode() + " has no GL account, so no cashier receipt can be theirs.");
}
// The vouchers.
final JsonArray receiptIds = command.arrayOfParameterNamed(RECEIPT_IDS);
final Set<Long> seen = new LinkedHashSet<>();
final List<Map<String, Object>> receipts = new ArrayList<>();
BigDecimal received = BigDecimal.ZERO;
Long receiptAccountId = null;
for (int i = 0; i < receiptIds.size(); i++) {
final Long receiptId = receiptIds.get(i).getAsLong();
if (!seen.add(receiptId)) {
continue;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(RECEIPT_SQL, receiptId); // NOSONAR
if (rows.isEmpty()) {
throw refused("receipt.not.found", "No cashier receipt with id " + receiptId + ".");
}
final Map<String, Object> receipt = rows.get(0);
final String voucherNo = (String) receipt.get("voucher_no");
if (!"DDS".equals(receipt.get("entry_type"))) {
throw refused("receipt.wrong.purpose", "Voucher " + voucherNo + " was not taken as a DDS receipt. Ask the cashier to "
+ "correct the entry type at Cashier > Transaction Entry.");
}
if (!"APPROVED".equals(receipt.get("status"))) {
throw refused("receipt.not.approved", "Voucher " + voucherNo + " is " + receipt.get("status")
+ ". It can be applied once the manager has approved it.");
}
if (!agentAccount.getGlCode().equals(String.valueOf(receipt.get("account_no")).trim())) {
throw refused("receipt.other.account", "Voucher " + voucherNo + " was paid into " + receipt.get("account_no") + ", not "
+ agent.getAgentCode() + "'s account " + agentAccount.getGlCode() + ".");
}
final Long used = this.jdbcTemplate.queryForObject(RECEIPT_USED_SQL, Long.class, receiptId); // NOSONAR
if (used != null && used > 0) {
throw refused("receipt.already.applied", "Voucher " + voucherNo + " has already been applied.");
}
final Long accountId = receipt.get("gl_account_id") == null ? agentAccount.getId()
: ((Number) receipt.get("gl_account_id")).longValue();
if (receiptAccountId != null && !receiptAccountId.equals(accountId)) {
throw refused("receipt.mixed.accounts", "The vouchers picked were paid into different GL accounts. Apply them separately.");
}
receiptAccountId = accountId;
received = received.add((BigDecimal) receipt.get("amount"));
receipts.add(receipt);
}
// The entries: everything outstanding on the days picked.
final JsonArray dateArray = command.arrayOfParameterNamed(ENTRY_DATES);
final Set<LocalDate> dates = new LinkedHashSet<>();
for (int i = 0; i < dateArray.size(); i++) {
dates.add(LocalDate.parse(dateArray.get(i).getAsString()));
}
final List<CollectionEntry> pending = new ArrayList<>();
for (final CollectionEntry entry : this.collectionEntryRepository.findAllPendingFor(agent.getId())) {
if (dates.contains(entry.getEntryDate())) {
pending.add(entry);
}
}
if (pending.isEmpty()) {
throw new NothingToSettleException(agent.getAgentCode(), dates.toString());
}
final BigDecimal expected = pending.stream().map(CollectionEntry::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (expected.compareTo(received) != 0) {
throw refused("receipt.amount.mismatch", "The vouchers come to " + received.stripTrailingZeros().toPlainString() + " but "
+ agent.getAgentCode() + "'s entries for the days picked come to " + expected.stripTrailingZeros().toPlainString()
+ ". They have to match exactly.");
}
// Debited from the very account the vouchers credited, not whatever the agent names today.
final GLAccount receiptAccount = this.glAccountRepository.findById(receiptAccountId).orElse(agentAccount);
// The application, then the money.
final LocalDate settledOn = DateUtils.getBusinessLocalDate();
final Long applicationId = this.jdbcTemplate.queryForObject(INSERT_APPLICATION_SQL, Long.class, agent.getId(), // NOSONAR
java.sql.Date.valueOf(settledOn), expected, pending.size(), receiptAccount.getId(),
this.context.authenticatedUser().getId());
final List<String> voucherNos = new ArrayList<>();
for (final Map<String, Object> receipt : receipts) {
// The unique constraint on the voucher is the last word: a second clerk racing this one
// fails here rather than applying the same cash twice.
this.jdbcTemplate.update(INSERT_APPLICATION_VOUCHER_SQL, applicationId, receipt.get("id"), receipt.get("voucher_no"), // NOSONAR
receipt.get("amount"));
voucherNos.add((String) receipt.get("voucher_no"));
}
final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos);
BigDecimal pooled = BigDecimal.ZERO;
for (final CollectionEntry entry : pending) {
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
if (party.getSavingsAccountId() != null) {
this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description);
} else {
pooled = pooled.add(entry.getAmount());
}
// The customer's own statement records the day the money was collected, as it always has.
// The agent's cash in hand is not raised: this cash was counted in at the counter, not
// carried.
final BigDecimal balanceAfter = party.credit(entry.getAmount());
final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent,
entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(),
entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter));
entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId);
this.collectionEntryRepository.save(entry);
}
// Everyone on DDS's own accounts, in one pair.
final String journalId = "DDSR-A" + applicationId;
if (pooled.signum() > 0) {
this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, pooled, receiptAccount, journalId, description);
this.jdbcTemplate.update(SET_JOURNAL_SQL, journalId, applicationId); // NOSONAR
}
final Map<String, Object> changes = new LinkedHashMap<>();
changes.put("applicationId", applicationId);
changes.put("entriesSettled", pending.size());
changes.put("totalAmount", expected);
changes.put("settledOn", settledOn);
changes.put("vouchers", voucherNos);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agent.getId()).with(changes).build();
}
/**
* What one line collects, checked against where the account stands on the day it is written for.
*
* The bank's rules, the same ones the counter's own collection applies (DdsSchedule):
* <ul>
* <li>an ordinary line is today's instalment - less if the customer paid less, never more;</li>
* <li>days in advance, one to five, and the account may not end up more than five days ahead;</li>
* <li>days of arrears, one to three, and only as many as are still inside the three-day catch-up
* window - older missed days are skipped, and skipped means gone.</li>
* </ul>
* With days, the amount is the daily instalment times them, worked out here rather than trusted;
* one sent as well has to agree. What is already written down and still waiting at the counter is
* counted with what has been settled, so arrears cleared on one pending line cannot be cleared again
* on the next.
*/
private BigDecimal checkedAmount(final Party party, final LocalDate entryDate, final JsonObject row, final Integer advanceDays,
final Integer arrearsDays) {
final BigDecimal daily = party.getDailyDepositAmount();
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
throw new PartyHasNoDailyAmountException(party.getPartyCode());
}
final int due = DdsSchedule.instalmentsDue(party.getOpeningDate(), party.getDays(), entryDate);
final BigDecimal collected = this.partyTransactionRepository.sumCollectedFor(party.getId())
.add(this.collectionEntryRepository.sumPendingFor(party.getId()));
final DdsSchedule before = DdsSchedule.of(daily, due, collected);
final BigDecimal amount;
if (advanceDays != null) {
amount = daily.multiply(BigDecimal.valueOf(advanceDays));
} else if (arrearsDays != null) {
final int allowed = before.recoverableDays(daily);
if (arrearsDays > allowed) {
throw new PartyArrearsDaysExceededException(party.getPartyCode(), arrearsDays, allowed);
}
amount = daily.multiply(BigDecimal.valueOf(arrearsDays));
} else {
amount = row.get("amount").getAsBigDecimal();
if (amount.compareTo(daily) > 0) {
throw refused("entry.more.than.a.day", party.getPartyCode() + ": " + amount.stripTrailingZeros().toPlainString()
+ " is more than one day's instalment of " + daily.stripTrailingZeros().toPlainString()
+ ". Use Advance for days ahead or Previous days for days missed.");
}
}
final Integer days = advanceDays != null ? advanceDays : arrearsDays;
if (days != null && row.has("amount") && !row.get("amount").isJsonNull() && !row.get("amount").getAsString().isBlank()) {
final BigDecimal quoted = row.get("amount").getAsBigDecimal();
if (quoted.compareTo(amount) != 0) {
throw new PartyAdvanceAmountMismatchException(party.getPartyCode(), days, amount, quoted, advanceDays != null);
}
}
// Whatever kind of line it is, the account may not end up more than five days ahead.
final DdsSchedule after = DdsSchedule.of(daily, due, collected.add(amount));
if (after.advanceDays() > DdsSchedule.MAX_ADVANCE_DAYS) {
throw refused("entry.advance.exceeded", party.getPartyCode() + " would be " + after.advanceDays()
+ " days ahead after this; an account may be at most " + DdsSchedule.MAX_ADVANCE_DAYS + " days ahead.");
}
return amount;
}
private Integer integerOf(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() && !json.get(field).getAsString().isBlank() ? json.get(field).getAsInt()
: null;
}
private PlatformDataIntegrityException refused(final String code, final String message) {
return new PlatformDataIntegrityException("error.msg.dds.collection." + code, message);
}
/**
* The account this line names, if this agent may collect from it at all.
*
@@ -18,7 +18,11 @@
*/
package org.apache.fineract.portfolio.dds.collection.starter;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
import org.apache.fineract.portfolio.dds.collection.serialization.CollectionEntryCommandFromApiJsonDeserializer;
@@ -48,8 +52,10 @@ public class DdsCollectionConfiguration {
public CollectionEntryWritePlatformService collectionEntryWritePlatformService(
CollectionEntryCommandFromApiJsonDeserializer fromApiJsonDeserializer, CollectionEntryRepository collectionEntryRepository,
AgentRepository agentRepository, PartyRepository partyRepository,
PartyTransactionWritePlatformService partyTransactionWritePlatformService) {
PartyTransactionWritePlatformService partyTransactionWritePlatformService, PartyTransactionRepository partyTransactionRepository,
DdsLedger ddsLedger, GLAccountRepository glAccountRepository, JdbcTemplate jdbcTemplate, PlatformSecurityContext context) {
return new CollectionEntryWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, collectionEntryRepository,
agentRepository, partyRepository, partyTransactionWritePlatformService);
agentRepository, partyRepository, partyTransactionWritePlatformService, partyTransactionRepository, ddsLedger,
glAccountRepository, jdbcTemplate, context);
}
}
@@ -31,14 +31,17 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
@@ -54,6 +57,8 @@ public class PartiesApiResource {
private final PlatformSecurityContext context;
private final PartyReadPlatformService readPlatformService;
private final DdsLedger ddsLedger;
private final DdsVoucherService ddsVoucherService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
/**
@@ -105,6 +110,34 @@ public class PartiesApiResource {
return readPlatformService.retrieveCreditableAccounts(clientId);
}
@GET
@Path("sundryaccounts")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Sundry accounts a DDS balance may be parked in",
description = "For a customer with no account here: every open posting liability account that is not a customer "
+ "or agent control account, by GL code, for the clerk to search and pick their bank's Sundry. The same "
+ "list command=payout and command=closeEarly accept as sundryGlAccountId (required when parking). "
+ "Returns id, glCode and name.")
public List<Map<String, Object>> retrieveSundryAccounts() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return ddsLedger.sundryAccounts();
}
@GET
@Path("{partyId}/closure")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "How a closed DDS account was closed",
description = "For a closed account: the kind of closure (PREMATURE, MATURITY_PAYOUT_AND_CLOSE, MATURITY_AUTO_CLOSE, "
+ "MATURITY_PAYOUT_THEN_CLOSE, CLOSED_NIL_BALANCE), who closed and approved it, the lock-in reason, the balance, "
+ "interest recovered or topped up, the agent's penalty, what was paid out and where it went - the customer's "
+ "account or the Sundry GL - with both ledger legs. For an open account returns closed=false.")
public Map<String, Object> retrieveClosure(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveClosure(partyId);
}
@GET
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -173,7 +206,10 @@ public class PartiesApiResource {
+ "their commission either way. Optional field: toSavingsAccountNo, as for payout. The lock-in period, the "
+ "rate reduction and the agent's share are the scheme's own and are read as the account is closed, so terms "
+ "the bank changes take effect on accounts already open. Refused inside the lock-in period, and refused on an "
+ "account that has already matured - that one is paid out and closed in the ordinary way.")
+ "account that has already matured - that one is paid out and closed in the ordinary way.\n" + "\n"
+ "command=prematurecloseoverride is the same early closure for one account still inside its lock-in. It takes "
+ "the same fields plus mandatory lockinOverrideReason, runs under its own permission so it goes to a manager "
+ "for approval, and leaves the scheme's lock-in unchanged for every other account.")
public CommandProcessingResult handleCommand(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@QueryParam("command") @Parameter(description = "command") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
@@ -185,14 +221,27 @@ public class PartiesApiResource {
.payOutParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_MATURITY_PAYOUT, DdsVoucherService.REF_PARTY,
partyId, () -> ddsVoucherService.amountHeldBy(partyId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
if ("prematureclose".equals(commandParam)) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.prematureCloseParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY,
partyId, () -> ddsVoucherService.amountHeldBy(partyId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
if ("prematurecloseoverride".equals(commandParam)) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.prematureCloseOverrideParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY,
partyId, () -> ddsVoucherService.amountHeldBy(partyId),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
if (!"close".equals(commandParam)) {
throw new UnrecognizedQueryParamException("command", commandParam);
@@ -44,6 +44,10 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
@Query("select count(p) from Party p where p.agent.id = :agentId and p.status = 'ACTIVE'")
Long countActivePartiesOfAgent(@Param("agentId") Long agentId);
/** Open accounts on a scheme, which take its rate when the rate is changed. */
@Query("select p from Party p where p.subType = :subType and p.status = 'ACTIVE'")
List<Party> findActiveOnSubType(@Param("subType") String subType);
/**
* Accounts an interest run covers. Closed accounts are excluded: a closed account has been paid
* out and settled to zero, so accruing further interest on it would credit an account nobody can
@@ -0,0 +1,53 @@
/**
* 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.dds.party.handler;
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.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Closes one DDS account early inside its scheme's lock-in.
*
* Its own command rather than a flag on PREMATURECLOSE, so waiving a lock-in has its own permission and
* its own maker-checker queue: the clerk asks, with a reason, and a manager decides. The scheme's
* lock-in is never touched to let one account through.
*/
@Service
@CommandType(entity = "PARTY", action = "PREMATURECLOSEOVERRIDE")
public class PrematureCloseOverridePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public PrematureCloseOverridePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.prematureCloseWithinLockin(command.entityId(), command);
}
}
@@ -268,15 +268,20 @@ public final class PartyCommandFromApiJsonDeserializer {
* two answers take money from different people. Everything else about the terms comes from the
* scheme, so this is the only choice the caller gets to make.
*/
public void validateForPrematureClose(final String json) {
/**
* An early closure. One waiving the lock-in carries the reason for it as well - the one thing the
* approving manager has to go on, and what the passbook line records.
*/
public void validateForPrematureClose(final String json, final boolean withinLockin) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json,
Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale"));
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, withinLockin
? Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale", "lockinOverrideReason", "sundryGlAccountId")
: Set.of("shortfallBorneBy", "toSavingsAccountNo", "closeDate", "dateFormat", "locale", "sundryGlAccountId"));
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
@@ -294,6 +299,11 @@ public final class PartyCommandFromApiJsonDeserializer {
.ignoreIfNull().notExceedingLengthOf(50);
}
if (withinLockin) {
v.reset().parameter("lockinOverrideReason").value(this.fromApiJsonHelper.extractStringNamed("lockinOverrideReason", element))
.notBlank().notExceedingLengthOf(250);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
@@ -304,7 +314,7 @@ public final class PartyCommandFromApiJsonDeserializer {
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("toSavingsAccountNo", "dateFormat", "locale"));
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("toSavingsAccountNo", "dateFormat", "locale", "sundryGlAccountId"));
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
@@ -35,6 +35,12 @@ public interface PartyReadPlatformService {
*/
Collection<Map<String, Object>> retrieveCreditableAccounts(Long clientId);
/**
* How a closed account was closed - early or at maturity, by whom, what the balance came to and
* where it went - gathered from its transactions, the ledger and the command that closed it.
*/
Map<String, Object> retrieveClosure(Long partyId);
/**
* @param agentId
* when given, only the parties this agent collects for - the daily transaction screen
@@ -18,6 +18,9 @@
*/
package org.apache.fineract.portfolio.dds.party.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.ResultSet;
@@ -29,6 +32,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
import org.apache.fineract.portfolio.dds.party.data.PartyNomineeData;
@@ -69,7 +73,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
+ " case sa.status_enum when 100 then 'Submitted' when 200 then 'Approved' when 300 then 'Active' "
+ " when 600 then 'Closed' when 700 then 'Rejected' end as savingsAccountStatus, "
+ " p.sub_type as subType, t.account_type_description as subTypeDescription, "
+ " p.party_code as partyCode, p.last_account as lastAccount, p.nominal as nominal, "
+ " p.party_code as partyCode, p.account_no as accountNo, p.last_account as lastAccount, p.nominal as nominal, "
+ " p.aadhar_no as aadharNo, p.party_name as partyName, p.party_name1 as partyName1, p.address1 as address1, "
+ " p.address2 as address2, p.address3 as address3, p.city as city, p.pincode as pincode, p.gender as gender, "
+ " p.pan_card_no as panCardNo, p.telephone as telephone, p.mobile_no_sms as mobileNoSms, p.operation as operation, "
@@ -148,6 +152,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
.subType(rs.getString("subType")) //
.subTypeDescription(rs.getString("subTypeDescription")) //
.partyCode(rs.getString("partyCode")) //
.accountNo(rs.getString("accountNo")) //
.lastAccount(rs.getString("lastAccount")) //
.nominal(rs.getBoolean("nominal")) //
.aadharNo(rs.getString("aadharNo")) //
@@ -259,10 +264,12 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
*/
@Override
public Collection<PartyData> retrieveDueForClosure() {
// Matured as of the bank's business date - the date the payout itself checks - not the
// server's clock, so the list never offers an account the payout would call not yet matured.
final String sql = "select " + PARTY_MAPPER.schema()
+ " where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date <= current_date"
+ " where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date <= ?"
+ " order by p.maturity_date, p.party_code";
return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER)); // NOSONAR
return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER, DateUtils.getBusinessLocalDate())); // NOSONAR
}
@Override
@@ -310,16 +317,192 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
*/
// Aliases are quoted: unquoted ones are folded to lower case by the database, and the keys would
// reach the screen as accountno and productname, which read back as blank.
private static final String CREDITABLE_ACCOUNTS_SQL = "select a.id as id, a.account_no as \"accountNo\", "
+ "p.name as \"productName\", a.account_balance_derived as balance from m_savings_account a "
//
// Current accounts are offered as well: they keep their own table, and a maturity can be paid into
// one as surely as into a savings account - the payout checks the same things of it, open and the
// customer's own.
private static final String CREDITABLE_ACCOUNTS_SQL = "select * from ("
+ "select a.id as id, a.account_no as \"accountNo\", "
+ "p.name as \"productName\", a.account_balance_derived as balance, 'SAVINGS' as \"accountKind\" from m_savings_account a "
+ "join m_savings_product p on p.id = a.product_id "
+ "where a.client_id = ? and a.status_enum = 300 and p.deposit_type_enum = 100 "
+ "and a.product_id is distinct from (select cast(value as bigint) from c_configuration "
+ "where name = 'dds-deposit-product' and enabled = true) "
+ "order by a.account_no";
+ "union all "
+ "select c.id as id, c.account_no as \"accountNo\", cp.name as \"productName\", c.account_balance_derived as balance, "
+ "'CURRENT' as \"accountKind\" from m_current_account c join m_current_product cp on cp.id = c.product_id "
+ "where c.client_id = ? and c.status_enum = 300"
+ ") accounts order by \"accountNo\"";
@Override
public Collection<Map<String, Object>> retrieveCreditableAccounts(final Long clientId) {
return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId); // NOSONAR
return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId, clientId); // NOSONAR
}
private static final String CLOSURE_PARTY_SQL = "select p.party_code as \"partyCode\", p.status, p.close_date as \"closeDate\", "
+ "a.agent_code as \"agentCode\" from m_dds_party p left join m_dds_agent a on a.id = p.agent_id where p.id = ?";
private static final String CLOSURE_PAYOUT_SQL = "select transaction_type as \"type\", amount, transaction_date as \"date\", "
+ "account_no as \"accountNo\", description, voucher_no as \"voucherNo\" from m_dds_party_transaction where party_id = ? "
+ "and transaction_type in ('MATURITY_PAYOUT', 'PREMATURE_PAYOUT') order by id desc limit 1";
private static final String CLOSURE_ADJUSTMENTS_SQL = "select "
+ "coalesce(sum(case when transaction_type = 'PREMATURE_INT_RECOVERY' then amount end), 0) as \"recovered\", "
+ "coalesce(sum(case when transaction_type = 'PREMATURE_AGENT_TOP_UP' then amount end), 0) as \"toppedUp\" "
+ "from m_dds_party_transaction where party_id = ?";
/**
* The commands that closed the account. Party ids can come round again after test data is cleared,
* so only commands made since this party was opened are read - an older one belongs to someone else.
*/
private static final String CLOSURE_COMMANDS_SQL = "select c.action_name as \"action\", c.command_as_json as \"json\", "
+ "c.result, mk.username as \"maker\", ck.username as \"checker\" "
+ "from m_portfolio_command_source c left join m_appuser mk on mk.id = c.maker_id "
+ "left join m_appuser ck on ck.id = c.checker_id where c.entity_name = 'PARTY' and c.resource_id = ? and c.status = 1 "
+ "and c.action_name in ('PAYOUT', 'CLOSE', 'CLOSEEARLY', 'PREMATURECLOSEOVERRIDE') "
+ "and c.made_on_date_utc >= (select created_date from m_dds_party where id = ?) order by c.id";
/** Both sides of the payout on the ledger - where it left and where it landed. */
private static final String CLOSURE_LEDGER_SQL = "select j.transaction_id as \"transactionId\", g.gl_code as \"glCode\", "
+ "g.name as \"glName\", case j.type_enum when 1 then 'CR' else 'DR' end as \"side\", j.amount "
+ "from acc_gl_journal_entry j join acc_gl_account g on g.id = j.account_id "
+ "where j.description = ? and j.entry_date = ? and j.reversed = false order by j.id";
private static final String CREDITED_ACCOUNT_SQL = "select s.account_no as \"accountNo\", sp.name as \"productName\", "
+ "c.display_name as \"owner\" from m_savings_account s join m_savings_product sp on sp.id = s.product_id "
+ "left join m_client c on c.id = s.client_id where s.account_no = ? "
+ "union all select ca.account_no, cp.name, c.display_name from m_current_account ca "
+ "join m_current_product cp on cp.id = ca.product_id left join m_client c on c.id = ca.client_id where ca.account_no = ?";
private static final String ACTION_CLOSE = "CLOSE";
private static final String ACTION_PAYOUT = "PAYOUT";
@Override
public Map<String, Object> retrieveClosure(final Long partyId) {
final List<Map<String, Object>> parties = this.jdbcTemplate.queryForList(CLOSURE_PARTY_SQL, partyId); // NOSONAR
if (parties.isEmpty()) {
throw new PartyNotFoundException(partyId);
}
final Map<String, Object> party = parties.get(0);
final Map<String, Object> closure = new LinkedHashMap<>();
final boolean closed = "CLOSED".equals(party.get("status"));
closure.put("closed", closed);
if (!closed) {
return closure;
}
final String partyCode = (String) party.get("partyCode");
closure.put("closeDate", isoDate(party.get("closeDate")));
closure.put("agentCode", party.get("agentCode"));
final List<Map<String, Object>> payouts = this.jdbcTemplate.queryForList(CLOSURE_PAYOUT_SQL, partyId); // NOSONAR
final Map<String, Object> payout = payouts.isEmpty() ? null : payouts.get(0);
final Map<String, Object> adjustments = this.jdbcTemplate.queryForMap(CLOSURE_ADJUSTMENTS_SQL, partyId); // NOSONAR
final BigDecimal recovered = (BigDecimal) adjustments.get("recovered");
final BigDecimal toppedUp = (BigDecimal) adjustments.get("toppedUp");
final BigDecimal paidOut = payout == null ? BigDecimal.ZERO : (BigDecimal) payout.get("amount");
// The command that did the closing, and a plain Close that followed a payout made the old way.
Map<String, Object> closingCommand = null;
Map<String, Object> laterClose = null;
for (final Map<String, Object> command : this.jdbcTemplate.queryForList(CLOSURE_COMMANDS_SQL, partyId, partyId)) { // NOSONAR
if (ACTION_CLOSE.equals(command.get("action")) && closingCommand != null) {
laterClose = command;
} else {
closingCommand = command;
laterClose = null;
}
}
final String closingAction = closingCommand == null ? null : (String) closingCommand.get("action");
final boolean premature = payout != null ? "PREMATURE_PAYOUT".equals(payout.get("type"))
: closingAction != null && !ACTION_CLOSE.equals(closingAction) && !ACTION_PAYOUT.equals(closingAction);
final String closureType;
if (premature) {
closureType = "PREMATURE";
} else if (payout == null) {
closureType = "CLOSED_NIL_BALANCE";
} else if (!ACTION_PAYOUT.equals(closingAction)) {
// Paid out with no clerk's command behind it: the nightly Maturity Auto Close.
closureType = "MATURITY_AUTO_CLOSE";
} else {
closureType = laterClose != null ? "MATURITY_PAYOUT_THEN_CLOSE" : "MATURITY_PAYOUT_AND_CLOSE";
}
closure.put("closureType", closureType);
closure.put("withinLockin", "PREMATURECLOSEOVERRIDE".equals(closingAction));
final Map<String, Object> actedBy = laterClose != null ? laterClose : closingCommand;
closure.put("closedBy", "MATURITY_AUTO_CLOSE".equals(closureType) ? "System" : actedBy == null ? null : actedBy.get("maker"));
closure.put("approvedBy", "MATURITY_AUTO_CLOSE".equals(closureType) || actedBy == null ? null : actedBy.get("checker"));
if (laterClose != null) {
closure.put("paidOutBy", closingCommand.get("maker"));
}
final JsonObject request = jsonOf(closingCommand == null ? null : closingCommand.get("json"));
final JsonObject result = jsonOf(closingCommand == null ? null : closingCommand.get("result"));
final JsonObject changes = result != null && result.has("changes") && result.get("changes").isJsonObject()
? result.getAsJsonObject("changes")
: null;
closure.put("lockinOverrideReason", stringOf(request, "lockinOverrideReason"));
closure.put("shortfallBorneBy", premature ? stringOf(request, "shortfallBorneBy") : null);
closure.put("agentCommissionPenalty",
changes != null && changes.has("agentCommissionPenalty") && !changes.get("agentCommissionPenalty").isJsonNull()
? changes.get("agentCommissionPenalty").getAsBigDecimal()
: BigDecimal.ZERO);
// What the account held when closing began: what went out, plus what was taken back, less what the agent put in.
closure.put("balanceAtClose", paidOut.add(recovered).subtract(toppedUp));
closure.put("interestRecovered", recovered);
closure.put("interestToppedUpByAgent", toppedUp);
closure.put("paidOut", paidOut);
if (payout != null) {
final String accountNo = (String) payout.get("accountNo");
closure.put("payoutDate", isoDate(payout.get("date")));
closure.put("passbookNote", payout.get("description"));
closure.put("voucherNo", payout.get("voucherNo"));
final String ledgerDescription = (premature ? "DDS premature closure payout for " : "DDS maturity payout for ") + partyCode;
final List<Map<String, Object>> ledger = this.jdbcTemplate.queryForList(CLOSURE_LEDGER_SQL, ledgerDescription, // NOSONAR
payout.get("date"));
// Only the last posting, should the same account ever have been paid out twice.
final Object lastTransaction = ledger.isEmpty() ? null : ledger.get(ledger.size() - 1).get("transactionId");
final List<Map<String, Object>> legs = ledger.stream().filter(row -> row.get("transactionId").equals(lastTransaction)).toList();
closure.put("ledgerTransactionId", lastTransaction);
closure.put("ledgerEntries", legs);
if (accountNo != null && !accountNo.isBlank()) {
closure.put("destination", "OWN_ACCOUNT");
final List<Map<String, Object>> accounts = this.jdbcTemplate.queryForList(CREDITED_ACCOUNT_SQL, accountNo, accountNo); // NOSONAR
closure.put("creditedAccount", accounts.isEmpty() ? Map.of("accountNo", accountNo) : accounts.get(0));
} else {
closure.put("destination", "SUNDRY");
legs.stream().filter(row -> "CR".equals(row.get("side"))).findFirst()
.ifPresent(row -> closure.put("sundryGl", Map.of("glCode", row.get("glCode"), "glName", row.get("glName"))));
}
}
return closure;
}
/** A date from the database as yyyy-MM-dd, which the screen reads - a java.sql.Date will not serialise. */
private static String isoDate(final Object value) {
if (value instanceof java.sql.Date date) {
return date.toLocalDate().toString();
}
return value == null ? null : value.toString();
}
private static JsonObject jsonOf(final Object text) {
if (!(text instanceof String json) || json.isBlank()) {
return null;
}
try {
final JsonElement element = JsonParser.parseString(json);
return element.isJsonObject() ? element.getAsJsonObject() : null;
} catch (RuntimeException e) {
return null;
}
}
private static String stringOf(final JsonObject json, final String name) {
return json != null && json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null;
}
}
@@ -90,4 +90,10 @@ public interface PartyWritePlatformService {
* could then be changed underneath it.
*/
CommandProcessingResult prematureClose(Long partyId, JsonCommand command);
/**
* Closes one account early inside its scheme's lock-in, for a reason a manager approves. The
* scheme's lock-in is not changed; only this account is let through it.
*/
CommandProcessingResult prematureCloseWithinLockin(Long partyId, JsonCommand command);
}
@@ -22,10 +22,13 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -36,6 +39,8 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.organisation.office.domain.Office;
@@ -63,11 +68,11 @@ import org.apache.fineract.portfolio.dds.party.exception.PartyHeldUnderLienExcep
import org.apache.fineract.portfolio.dds.party.exception.PartyMajorWithoutKycException;
import org.apache.fineract.portfolio.dds.party.exception.PartyNothingToPayOutException;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotMaturedException;
import org.apache.fineract.portfolio.dds.party.exception.PartyHasNoDepositAccountException;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.apache.fineract.portfolio.dds.party.exception.PartySavingsAccountClientMismatchException;
import org.apache.fineract.portfolio.dds.party.exception.PartySavingsAccountNotFoundException;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.party.exception.DdsDepositProductNotConfiguredException;
import org.apache.fineract.portfolio.dds.party.exception.PartyAccountAlreadyOpenException;
import org.apache.fineract.portfolio.dds.party.exception.PartySubTypeNotActiveException;
@@ -129,11 +134,15 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
final String partyCode = generateNextPartyCode();
final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode,
command);
party.takeSchemeRate(schemeOf(party).getInterestRate());
linkSavingsAccountIfProvided(party, clientId, command);
openDepositAccountIfUnlinked(party, client);
addNomineesFromCommand(party, command);
// The party is the DDS account: nothing is opened in the savings tables for it. Its
// number comes from its id, so it is given once the first save has made one.
this.partyRepository.saveAndFlush(party);
party.assignAccountNo();
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder() //
@@ -172,6 +181,9 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// must stay editable for everything else.
if (changesOnly.containsKey("subType")) {
validateSubType((String) changesOnly.get("subType"));
// Moved to another scheme: it takes that scheme's rate.
partyForUpdate.takeSchemeRate(schemeOf(partyForUpdate).getInterestRate());
changesOnly.put("interestRate", partyForUpdate.getInterestRate());
}
if (changesOnly.containsKey("clientId")) {
@@ -324,7 +336,19 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
refuseIfNotReadyForMaturity(party);
settleMaturity(party, command.stringValueOfParameterNamedAllowingNull("toSavingsAccountNo"));
// One DDS voucher for the payout, the interest brought up to date first included.
this.ddsLedger.withVoucher(DdsVoucherService.TYPE_MATURITY_PAYOUT, DdsVoucherService.REF_PARTY, partyId, () -> {
settleMaturity(party, command.stringValueOfParameterNamedAllowingNull("toSavingsAccountNo"),
command.longValueOfParameterNamed("sundryGlAccountId"));
return null;
});
// Paid out is closed. The balance is nil and a matured account takes nothing more, so leaving it
// open only leaves a second step to forget - an account that reads as active in every report and
// that an agent could still collect on. The same as Maturity Auto Close does, on the day the
// money moved.
party.close(DateUtils.getBusinessLocalDate());
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
}
@@ -343,9 +367,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
if (party.getMaturityDate() == null || party.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) {
throw new PartyNotMaturedException(party.getPartyCode(), party.getMaturityDate());
}
if (party.getSavingsAccountId() == null) {
throw new PartyHasNoDepositAccountException(party.getPartyCode());
}
// No savings account is not a reason to refuse any more: a DDS account is the party itself
// (0514), and DdsLedger books it on DDS Customer Deposits.
refuseIfMajorWithoutKyc(party);
refuseIfHeldUnderLien(party);
}
@@ -357,7 +380,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
* the customer's own account to pay into, or null to park the money in the matured-
* deposits payable account for the branch to send on.
*/
private void settleMaturity(final Party party, final String toAccountNo) {
private void settleMaturity(final Party party, final String toAccountNo, final Long sundryGlAccountId) {
// Interest up to the maturity date, before anything is paid. The nightly run normally has it
// already, and this returns having done nothing; it earns its place on the night that run did
// not happen - the server was down, or the scheme has automatic posting switched off. Without
@@ -374,11 +397,13 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
final String description = "DDS maturity payout for " + party.getPartyCode();
final boolean toOwnAccount = toAccountNo != null && !toAccountNo.isBlank();
GLAccount parkIn = null;
if (toOwnAccount) {
final Long toSavingsId = payableAccountId(party.clientId(), toAccountNo);
this.ddsLedger.payOutToOwnAccount(party.getSavingsAccountId(), toSavingsId, payoutDate, amount, description);
final PayableAccount toAccount = payableAccountId(party.clientId(), toAccountNo);
payOutToOwn(party, toAccount, payoutDate, amount, description);
} else {
this.ddsLedger.payOutToPayable(party.getSavingsAccountId(), payoutDate, amount, description);
parkIn = this.ddsLedger.sundryAccount(sundryGlAccountId);
this.ddsLedger.payOutToPayable(party, payoutDate, amount, description, parkIn);
}
final BigDecimal balanceAfter = party.debit(amount);
@@ -389,7 +414,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// account for where their money went. The bank's books already carry it; this is DDS's copy.
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), payoutDate, MATURITY_PAYOUT,
DIRECTION_DEBIT, null, null, toOwnAccount ? toAccountNo : null, amount, null, null,
toOwnAccount ? "Maturity paid into " + toAccountNo : "Maturity parked in matured-deposits payable", balanceAfter));
toOwnAccount ? "Maturity paid into " + toAccountNo : "Maturity parked in Sundry " + parkedIn(parkIn), balanceAfter));
}
@Transactional
@@ -412,7 +437,10 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
throw new PartyCreditAccountUnusableException(party.getPartyCode(), "no account is named for the maturity proceeds");
}
settleMaturity(party, toAccountNo);
this.ddsLedger.withVoucher(DdsVoucherService.TYPE_MATURITY_PAYOUT, DdsVoucherService.REF_PARTY, partyId, () -> {
settleMaturity(party, toAccountNo, null);
return null;
});
// The balance is nil now, which is the one thing closing insists on. Closed on the business
// date rather than the maturity date: this is the day the money actually moved, and a close
@@ -423,6 +451,11 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
return new CommandProcessingResultBuilder().withEntityId(partyId).build();
}
/** The Sundry account a balance was parked in, as its passbook line names it. */
private static String parkedIn(final GLAccount account) {
return account == null ? "" : account.getGlCode() + " " + account.getName();
}
/** How a maturity payout appears on the scheme's own ledger, beside PARTY_TRAN and INTEREST_POST. */
private static final String MATURITY_PAYOUT = "MATURITY_PAYOUT";
private static final String DIRECTION_DEBIT = "DEBIT";
@@ -438,6 +471,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
private static final String PREMATURE_PAYOUT = "PREMATURE_PAYOUT";
private static final String SHORTFALL_BORNE_BY = "shortfallBorneBy";
private static final String LOCKIN_OVERRIDE_REASON = "lockinOverrideReason";
private static final String CUSTOMER_BEARS = "CUSTOMER";
private static final String AGENT_BEARS = "AGENT";
@@ -530,7 +564,11 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// A hold placed on the savings account from outside DDS - by the savings screen, or by
// anything else in the bank that can freeze funds. DDS does not put these on, but it is
// still the same money and still cannot leave.
// still the same money and still cannot leave. A DDS account with no savings account behind
// it has nowhere for such a hold to be, and the flag above is its whole lien.
if (party.getSavingsAccountId() == null) {
return;
}
final SavingsAccount account = this.savingsAccountRepository.findById(party.getSavingsAccountId()).orElse(null);
if (account == null) {
return;
@@ -597,10 +635,25 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
* or chosen on the day it is paid - so the answer cannot differ between the two, which is exactly
* the kind of gap that lets a payout fail on the one day it matters.
*/
private Long payableAccountId(final Long ownerClientId, final String accountNo) {
private PayableAccount payableAccountId(final Long ownerClientId, final String accountNo) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(CREDIT_ACCOUNT_SQL, accountNo); // NOSONAR
if (rows.isEmpty()) {
throw new PartyCreditAccountUnusableException(accountNo, "no account with this number exists");
// Not a savings account - a current account, then, which keeps its own table. The same
// checks as far as they apply to one: open, and the customer's own. It is never a DDS
// scheme account or a term deposit, so those two do not arise.
final List<Map<String, Object>> current = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, accountNo); // NOSONAR
if (current.isEmpty()) {
throw new PartyCreditAccountUnusableException(accountNo, "no account with this number exists");
}
final Map<String, Object> account = current.get(0);
if (((Number) account.get("status_enum")).intValue() != ACTIVE_CURRENT_STATUS) {
throw new PartyCreditAccountUnusableException(accountNo, "it is not open");
}
final Object ownerId = account.get("client_id");
if (ownerId == null || !ownerClientId.equals(((Number) ownerId).longValue())) {
throw new PartyCreditAccountUnusableException(accountNo, "it belongs to a different customer");
}
return new PayableAccount(true, ((Number) account.get("id")).longValue());
}
final Map<String, Object> account = rows.get(0);
@@ -631,7 +684,25 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
throw new PartyCreditAccountUnusableException(accountNo, "it is a DDS scheme account, not an account to be paid into");
}
return ((Number) account.get("id")).longValue();
return new PayableAccount(false, ((Number) account.get("id")).longValue());
}
/** An account a maturity can be paid into: a savings account, or a current account, by id. */
private record PayableAccount(boolean current, Long id) {}
private static final String CURRENT_ACCOUNT_SQL = "select a.id, a.client_id, a.status_enum from m_current_account a where a.account_no = ?";
/** Active, on a current account - the same number as on a savings account, in its own column. */
private static final int ACTIVE_CURRENT_STATUS = 300;
/** Pays the balance into the customer's own account, whichever kind it is. */
private void payOutToOwn(final Party party, final PayableAccount to, final LocalDate date, final BigDecimal amount,
final String description) {
if (to.current()) {
this.ddsLedger.payOutToCurrentAccount(party, to.id(), date, amount, description);
} else {
this.ddsLedger.payOutToOwnAccount(party, to.id(), date, amount, description);
}
}
/** Set by 0492. Names the savings product a DDS customer's real account is opened under. */
@@ -740,16 +811,34 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
@Transactional
@Override
public CommandProcessingResult prematureClose(final Long partyId, final JsonCommand command) {
this.fromApiJsonDeserializer.validateForPrematureClose(command.json());
this.fromApiJsonDeserializer.validateForPrematureClose(command.json(), false);
return this.ddsLedger.withVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY, partyId,
() -> closeEarly(partyId, command, null));
}
/**
* Closing early inside the scheme's lock-in, for this one account, with a reason - on its own
* permission, so it reaches a manager for approval rather than being settled by whoever keys it.
*
* Everything else is the ordinary early closure: the charge still falls on the customer or the
* agent, and the account must still be unmatured, unpledged and KYC-verified. Only the lock-in is
* waived, and the scheme's own lock-in is left as it is for every other account on it.
*/
@Transactional
@Override
public CommandProcessingResult prematureCloseWithinLockin(final Long partyId, final JsonCommand command) {
this.fromApiJsonDeserializer.validateForPrematureClose(command.json(), true);
final String reason = command.stringValueOfParameterNamed(LOCKIN_OVERRIDE_REASON).trim();
return this.ddsLedger.withVoucher(DdsVoucherService.TYPE_PREMATURE, DdsVoucherService.REF_PARTY, partyId,
() -> closeEarly(partyId, command, reason));
}
private CommandProcessingResult closeEarly(final Long partyId, final JsonCommand command, final String lockinOverrideReason) {
final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
if (party.isClosed()) {
throw new PartyAlreadyClosedException(party.getPartyCode());
}
if (party.getSavingsAccountId() == null) {
throw new PartyHasNoDepositAccountException(party.getPartyCode());
}
// An account that has reached its term is not closed early - it is paid out on its own terms,
// and charging it for closing early would take money the customer has fully earned.
if (party.getMaturityDate() != null && !party.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) {
@@ -759,15 +848,31 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
refuseIfHeldUnderLien(party);
final DdsAccountType scheme = schemeOf(party);
refuseIfWithinLockin(party, scheme);
if (lockinOverrideReason == null) {
refuseIfWithinLockin(party, scheme);
}
// Taken from the request, the same way an ordinary close takes it, so both ways of closing an
// account agree on where the date comes from and on the format it arrives in.
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
// Interest first, at the rate the account has been running on. Whatever is charged below is
// then charged against a complete figure rather than one that stops at the last batch run,
// and the passbook shows the interest earned and the part given back as two separate lines.
// The interest has to be brought up to date by the branch before the account is closed early:
// calculated and posted through the closing day, so the passbook shows what was earned before
// what is given back. Closing is refused until then, and says which runs are missing.
final BigDecimal interestOwed = this.batchRunWritePlatformService.interestNotYetPosted(party, closeDate);
if (interestOwed.compareTo(BigDecimal.ZERO) > 0) {
final boolean calculatedOnly = party.accruedInterest(false) != null
&& party.accruedInterest(false).compareTo(interestOwed) == 0;
// A rule the request does not meet, not a permission it lacks - so a validation error.
final String message = "Interest of ₹" + interestOwed.setScale(2, RoundingMode.HALF_UP) + " on " + party.getPartyCode()
+ " is not posted yet. "
+ (calculatedOnly ? "Run Interest Posting" : "Run Interest Calculation and then Interest Posting") + " for "
+ closeDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) + ", then close the account.";
throw new PlatformApiDataValidationException("error.msg.dds.party.interest.not.posted", message,
List.of(ApiParameterError.generalError("error.msg.dds.party.interest.not.posted", message)));
}
// Nothing is left to credit by now; kept so a rounding remainder is never lost.
this.batchRunWritePlatformService.postInterestThrough(party, closeDate);
final boolean customerBears = CUSTOMER_BEARS.equals(command.stringValueOfParameterNamed(SHORTFALL_BORNE_BY));
@@ -777,17 +882,22 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
final BigDecimal commissionPenalty = chargeAgentCommission(party, scheme);
payOutRemainder(party, command, closeDate);
payOutRemainder(party, command, closeDate, lockinOverrideReason);
party.close(closeDate);
this.partyRepository.saveAndFlush(party);
final Map<String, Object> changes = new HashMap<>();
changes.put(SHORTFALL_BORNE_BY, customerBears ? CUSTOMER_BEARS : AGENT_BEARS);
changes.put("shortfallAmount", borneAmount);
changes.put("agentCommissionPenalty", commissionPenalty);
if (lockinOverrideReason != null) {
changes.put(LOCKIN_OVERRIDE_REASON, lockinOverrideReason);
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(partyId) //
.with(Map.of(SHORTFALL_BORNE_BY, customerBears ? CUSTOMER_BEARS : AGENT_BEARS, //
"shortfallAmount", borneAmount, //
"agentCommissionPenalty", commissionPenalty)) //
.with(changes) //
.build();
}
@@ -843,7 +953,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
}
final String description = "DDS premature closure interest recovery for " + party.getPartyCode();
this.ddsLedger.reverseInterest(party.getSavingsAccountId(), closeDate, recovered, description);
this.ddsLedger.reverseInterest(party, closeDate, recovered, description);
final BigDecimal balanceAfter = party.debit(recovered);
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_INTEREST_RECOVERY,
DIRECTION_DEBIT, null, null, null, recovered, null, null,
@@ -878,7 +988,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
}
final String description = "DDS premature closure interest funded by agent for " + party.getPartyCode();
this.ddsLedger.postInterest(party.getSavingsAccountId(), closeDate, topUp, description);
this.ddsLedger.postInterest(party, closeDate, topUp, description);
final BigDecimal balanceAfter = party.credit(topUp);
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_AGENT_TOP_UP,
DIRECTION_CREDIT, null, null, null, topUp, null, null,
@@ -917,7 +1027,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
* What is left after the charge goes out the same way a matured payout does - to another account
* of the customer's, or into matured-deposits payable when they have none.
*/
private void payOutRemainder(final Party party, final JsonCommand command, final LocalDate closeDate) {
private void payOutRemainder(final Party party, final JsonCommand command, final LocalDate closeDate,
final String lockinOverrideReason) {
final BigDecimal amount = party.getCurrentBalance();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
@@ -926,18 +1037,23 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
final String description = "DDS premature closure payout for " + party.getPartyCode();
final String toAccountNo = command.stringValueOfParameterNamedAllowingNull("toSavingsAccountNo");
final boolean toOwnAccount = toAccountNo != null && !toAccountNo.isBlank();
GLAccount parkIn = null;
if (toOwnAccount) {
final Long toSavingsId = payableAccountId(party.clientId(), toAccountNo);
this.ddsLedger.payOutToOwnAccount(party.getSavingsAccountId(), toSavingsId, closeDate, amount, description);
final PayableAccount toAccount = payableAccountId(party.clientId(), toAccountNo);
payOutToOwn(party, toAccount, closeDate, amount, description);
} else {
this.ddsLedger.payOutToPayable(party.getSavingsAccountId(), closeDate, amount, description);
parkIn = this.ddsLedger.sundryAccount(command.longValueOfParameterNamed("sundryGlAccountId"));
this.ddsLedger.payOutToPayable(party, closeDate, amount, description, parkIn);
}
// A lock-in waived for this account says so on its passbook line, with the reason given.
String note = toOwnAccount ? "Early closure paid into " + toAccountNo : "Early closure parked in Sundry " + parkedIn(parkIn);
if (lockinOverrideReason != null) {
note = note + " - within lock-in, approved: " + lockinOverrideReason;
}
final BigDecimal balanceAfter = party.debit(amount);
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_PAYOUT, DIRECTION_DEBIT,
null, null, toOwnAccount ? toAccountNo : null, amount, null, null,
toOwnAccount ? "Early closure paid into " + toAccountNo : "Early closure parked in matured-deposits payable",
balanceAfter));
null, null, toOwnAccount ? toAccountNo : null, amount, null, null, note, balanceAfter));
}
/** What this account has actually been credited in interest, which is what a reduction acts on. */
@@ -50,7 +50,22 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
+ "when 'PARTY_TRAN' then 'Collection' " + "when 'AGENT_TRAN' then 'Settlement' "
+ "when 'WITHDRAW_DEPOSITE' then 'Transfer' " + "when 'INTEREST_POST' then 'Interest' "
+ "when 'OD_INTEREST_POST' then 'OD Interest' " + "when 'COMMISSION_POST' then 'Commission Paid' "
+ "when 'MATURITY_PAYOUT' then 'Maturity Payout' " + "else t.transaction_type end";
+ "when 'MATURITY_PAYOUT' then 'Maturity Payout' " + "when 'PREMATURE_PAYOUT' then 'Premature Payout' "
+ "when 'PREMATURE_INT_RECOVERY' then 'Interest Recovered (Premature)' "
+ "when 'PREMATURE_AGENT_TOP_UP' then 'Interest Funded by Agent' " + "else t.transaction_type end";
/**
* What an agent collected and has not yet handed in: everything they wrote down and is still
* unsettled, less what the cashier has already taken from them but no clerk has applied yet.
* The same figure the Agent Tran screen shows as still to take. It used to read a balance on the
* agent row that the cashier flow never moves, so it said nothing once cash came in at the counter.
*/
private static final String AGENT_CASH_IN_HAND = "greatest(coalesce((select sum(e.amount) from m_dds_collection_entry e "
+ "where e.agent_id = a.id and e.status = 'PENDING'), 0) - coalesce((select sum(r.amount) "
+ "from m_cashier_deposit_request r join m_code_value cv on cv.id = r.entry_type_cv_id "
+ "join acc_gl_account g on g.id = a.gl_account_id "
+ "where cv.code_value = 'DDS' and r.status = 'APPROVED' and r.account_no = g.gl_code "
+ "and not exists (select 1 from m_dds_receipt_application_voucher v where v.cashier_deposit_request_id = r.id)), 0), 0)";
/** How an agent's standing is described wherever a list of agents is printed. */
private static final String AGENT_STATUS_LABEL = "case when a.close_date is not null then 'Closed' "
@@ -97,7 +112,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", a.agent_city as \"City\", "
+ AGENT_STATUS_LABEL + " as \"Status\", a.agent_opening_date as \"Opening Date\", "
+ "(select count(*) from m_dds_party p where p.agent_id = a.id and p.status = 'ACTIVE') as \"Accounts\", "
+ "a.agent_current_balance as \"Cash In Hand\", a.commission as \"Commission Due\" "
+ AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" "
+ "from m_dds_agent a order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql);
return available("AGENT_CHECK_LIST",
@@ -143,7 +158,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
/** What each agent is holding and what they are owed - the two figures a settlement turns on. */
private DdsReportData agentWiseBalance(final Long agentFrom, final Long agentTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", " + AGENT_STATUS_LABEL
+ " as \"Status\", a.agent_current_balance as \"Cash In Hand\", a.commission as \"Commission Due\" "
+ " as \"Status\", " + AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" "
+ "from m_dds_agent a where a.id between ? and ? order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo);
return available("AGENT_WISE_BALANCE", List.of("Agent Code", "Agent Name", "Status", "Cash In Hand", "Commission Due"), rows);
@@ -203,17 +218,21 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
*/
private DdsReportData collectionSummaryAgentWise(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
// Collected is what the agent wrote down over the period; settled is the part of it the branch
// has received and credited to customers. Both come from the agent's own entries, since a
// collection reaches the customer's passbook only once it is settled - reading passbooks alone
// counted only what was settled and called it collected.
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "coalesce(sum(case when t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' then t.amount end), 0) "
+ "as \"Collected\", "
+ "coalesce(sum(case when t.transaction_type = 'AGENT_TRAN' and t.direction = 'DEBIT' then t.amount end), 0) "
+ "as \"Settled To Branch\", " + "a.agent_current_balance as \"Cash In Hand\", "
+ "coalesce(sum(case when t.transaction_type = 'COMMISSION_POST' then t.amount end), 0) as \"Commission Paid\" "
+ "from m_dds_agent a "
+ "left join m_dds_party_transaction t on t.agent_id = a.id and t.transaction_date between ? and ? "
+ "where a.id between ? and ? "
+ "group by a.id, a.agent_code, a.agent_name, a.agent_current_balance order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
+ "coalesce((select sum(e.amount) from m_dds_collection_entry e where e.agent_id = a.id "
+ "and e.status in ('PENDING', 'SETTLED') and e.entry_date between ? and ?), 0) as \"Collected\", "
+ "coalesce((select sum(e.amount) from m_dds_collection_entry e where e.agent_id = a.id "
+ "and e.status = 'SETTLED' and e.entry_date between ? and ?), 0) as \"Settled To Branch\", "
+ AGENT_CASH_IN_HAND + " as \"Cash In Hand\", "
+ "coalesce((select sum(t.amount) from m_dds_party_transaction t where t.agent_id = a.id "
+ "and t.transaction_type = 'COMMISSION_POST' and t.transaction_date between ? and ?), 0) as \"Commission Paid\" "
+ "from m_dds_agent a where a.id between ? and ? order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, fromDate, toDate, fromDate, toDate,
agentFrom, agentTo);
return available("COLLECTION_SUMMARY_AGENT_WISE",
List.of("Agent Code", "Agent Name", "Collected", "Settled To Branch", "Cash In Hand", "Commission Paid"), rows);
}
@@ -239,17 +258,15 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
final Long agentTo) {
// Collected against settled, day by day: the difference is what the agent went home holding,
// which is the figure a day-end check is actually looking for.
final String sql = "select t.transaction_date as \"Transaction Date\", a.agent_code as \"Agent Code\", "
+ "a.agent_name as \"Agent Name\", "
+ "count(*) filter (where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT') as \"Entries\", "
+ "coalesce(sum(t.amount) filter (where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT'), 0) "
+ "as \"Collected\", "
+ "coalesce(sum(t.amount) filter (where t.transaction_type = 'AGENT_TRAN' and t.direction = 'DEBIT'), 0) "
+ "as \"Settled To Branch\" "
+ "from m_dds_party_transaction t join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_type in ('PARTY_TRAN', 'AGENT_TRAN') and t.transaction_date between ? and ? "
+ "and a.id between ? and ? group by t.transaction_date, a.id, a.agent_code, a.agent_name "
+ "order by t.transaction_date, a.agent_code";
// Each day's collection from the agent's own entries, and how much of that day's collection
// the branch has since received and credited - the rest is still with the agent.
final String sql = "select e.entry_date as \"Transaction Date\", a.agent_code as \"Agent Code\", "
+ "a.agent_name as \"Agent Name\", count(*) as \"Entries\", coalesce(sum(e.amount), 0) as \"Collected\", "
+ "coalesce(sum(e.amount) filter (where e.status = 'SETTLED'), 0) as \"Settled To Branch\" "
+ "from m_dds_collection_entry e join m_dds_agent a on a.id = e.agent_id "
+ "where e.status in ('PENDING', 'SETTLED') and e.entry_date between ? and ? "
+ "and a.id between ? and ? group by e.entry_date, a.id, a.agent_code, a.agent_name "
+ "order by e.entry_date, a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("DAILY_COLLECTION_REPORT",
List.of("Transaction Date", "Agent Code", "Agent Name", "Entries", "Collected", "Settled To Branch"), rows);
@@ -259,10 +276,13 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "a.agent_code as \"Agent Code\", t.transaction_date as \"Posted On\", " + TRANSACTION_TYPE_LABEL
+ " as \"Interest Type\", t.amount as \"Interest\", t.balance_after_transaction as \"Balance After\" "
+ " as \"Interest Type\", case when t.direction = 'DEBIT' then -t.amount else t.amount end as \"Interest\", "
+ "t.balance_after_transaction as \"Balance After\" "
+ "from m_dds_party_transaction t join m_dds_party p on p.id = t.party_id "
+ "join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST') and t.transaction_date between ? and ? "
// Interest given back on an early closure is part of the interest story, shown negative.
+ "where t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST', 'PREMATURE_AGENT_TOP_UP', "
+ "'PREMATURE_INT_RECOVERY') and t.transaction_date between ? and ? "
+ "and p.id between ? and ? order by t.transaction_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, partyFrom, partyTo);
return available("PARTY_INTEREST_REGISTER",
@@ -282,8 +302,8 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
private DdsReportData balanceSummary(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
final String agentSql = "select count(*) as \"Total Agents\", "
+ "count(*) filter (where is_active and close_date is null) as \"Active Agents\", "
+ "coalesce(sum(agent_current_balance), 0) as \"Cash With Agents\", "
+ "coalesce(sum(commission), 0) as \"Commission Due To Agents\" from m_dds_agent where id between ? and ?";
+ "coalesce(sum(" + AGENT_CASH_IN_HAND + "), 0) as \"Cash With Agents\", "
+ "coalesce(sum(commission), 0) as \"Commission Due To Agents\" from m_dds_agent a where a.id between ? and ?";
final Map<String, Object> agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo);
final String partySql = "select count(*) as \"Total Accounts\", "
@@ -342,7 +362,10 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
+ "p.maturity_amount as \"Maturity Amount\", "
+ "(p.maturity_amount - p.current_balance) as \"Shortfall\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where p.maturity_date is not null and p.maturity_date between ? and ? and p.agent_id between ? and ? "
// Open accounts only: this is the list payouts are arranged from, and a closed account
// has nothing left to pay.
+ "where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date between ? and ? "
+ "and p.agent_id between ? and ? "
+ "and p.id between ? and ? order by p.maturity_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
@@ -363,8 +386,12 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "p.address1 as \"Address\", p.city as \"City\", p.pan_card_no as \"PAN\", "
+ "p.opening_date as \"Opening Date\", p.interest_rate as \"Interest Rate %\", "
+ "p.current_balance as \"Current Balance\", coalesce((select sum(t.amount) from m_dds_party_transaction t "
+ "where t.party_id = p.id and t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST') "
// Net of interest given back on an early closure, and including what the agent funded
// - the interest the customer actually kept, which is what a tax return needs.
+ "p.current_balance as \"Current Balance\", coalesce((select sum(case when t.direction = 'DEBIT' "
+ "then -t.amount else t.amount end) from m_dds_party_transaction t "
+ "where t.party_id = p.id and t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST', "
+ "'PREMATURE_AGENT_TOP_UP', 'PREMATURE_INT_RECOVERY') "
+ "and t.transaction_date between ? and ?), 0) as \"Interest Earned\" from m_dds_party p "
+ "where p.agent_id between ? and ? and p.id between ? and ? order by p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
@@ -31,6 +31,7 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -51,6 +52,7 @@ public class PartyTransactionsApiResource {
private final PlatformSecurityContext context;
private final PartyTransactionReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@@ -85,6 +87,9 @@ public class PartyTransactionsApiResource {
.createPartyTransaction() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
return ddsVoucherService.submitHoldingVoucher(DdsVoucherService.TYPE_TRANSFER, DdsVoucherService.REF_AGENT,
DdsVoucherService.idIn(apiRequestBodyAsJson, "agentId"),
() -> DdsVoucherService.numberIn(apiRequestBodyAsJson, "withdrawAmount"),
() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
}
}
@@ -45,7 +45,7 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
+ " t.agent_id as agentId, a.agent_code as agentCode, t.transaction_date as transactionDate, "
+ " t.transaction_type as transactionType, t.direction as direction, t.transaction_mode as transactionMode, "
+ " t.account_type as accountType, t.account_no as accountNo, t.amount as amount, t.cheque_no as chequeNo, "
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction "
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo "
+ " from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id "
+ " join m_dds_agent a on a.id = t.agent_id";
}
@@ -70,6 +70,7 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
.tokenNo(rs.getString("tokenNo")) //
.description(rs.getString("description")) //
.balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) //
.voucherNo(rs.getString("voucherNo")) //
.build();
}
}
@@ -29,9 +29,11 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.agent.domain.Agent;
@@ -41,6 +43,7 @@ import org.apache.fineract.organisation.workingdays.domain.WorkingDays;
import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysUtil;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.DdsVoucherService;
import org.apache.fineract.portfolio.dds.calculation.DdsSchedule;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.exception.PartyHeldUnderLienException;
@@ -79,6 +82,15 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
@Transactional
@Override
public CommandProcessingResult createPartyTransaction(final JsonCommand command) {
// One DDS voucher for the entry - a transfer names both accounts even though no ledger leg moves.
final String voucherType = "WITHDRAW_DEPOSITE".equals(command.stringValueOfParameterNamed("transactionType"))
? DdsVoucherService.TYPE_TRANSFER
: DdsVoucherService.TYPE_TRANSACTION;
return this.ddsLedger.withVoucher(voucherType, DdsVoucherService.REF_AGENT, command.longValueOfParameterNamed("agentId"),
() -> createWithinVoucher(command));
}
private CommandProcessingResult createWithinVoucher(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
@@ -239,14 +251,10 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
final BigDecimal newBalance = party.credit(amount);
agent.credit(amount);
// The same money on the bank's own books: onto the customer's real account, and into the
// Agent Cash account until the agent hands it over. A party opened before DDS began
// opening real accounts has none, and is left on its DDS balance alone rather than the
// whole round being refused because of one old record.
if (party.getSavingsAccountId() != null) {
this.ddsLedger.collect(party.getSavingsAccountId(), transactionDate, amount, "DDS collection by " + agent.getAgentCode(),
agent.getGlAccount());
}
// The same money on the bank's own books: onto what the customer is owed - their savings
// account where they still have one, DDS Customer Deposits otherwise - and into the agent's
// cash until the agent hands it over.
this.ddsLedger.collect(party, transactionDate, amount, "DDS collection by " + agent.getAgentCode(), agent.getGlAccount());
// Saved as it is made, rather than the whole round together at the end.
//
@@ -264,6 +272,17 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
}
private PartyTransaction postAgentSettlement(final Agent agent, final LocalDate transactionDate, final JsonCommand command) {
// An agent's cash is taken in at the cashier's counter now - Cashier > Transaction Entry, a GL
// receipt tagged DDS into the agent's own GL account - and applied to the customers from Agent
// Tran > Apply DDS Receipt. Taking the same cash in here as well would be a second way in for
// one bag of notes, so this way is closed, and says where the right one is.
if (agent != null) {
throw new PlatformDataIntegrityException("error.msg.dds.agent.tran.moved.to.cashier",
"An agent's cash is no longer taken in here. The cashier receipts it at Cashier > Transaction Entry "
+ "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent.getGlAccount() != null ? agent.getGlAccount().getGlCode()
: "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > "
+ "Apply DDS Receipt.");
}
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount");
if (agent.getAgentCurrentBalance().compareTo(amount) < 0) {
throw new InsufficientAgentBalanceException(agent.getAgentCode());
@@ -288,6 +307,14 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("withdrawAmount");
final String description = command.stringValueOfParameterNamedAllowingNull("withdrawDescription");
// Money moved from an account into itself is no transfer at all - just two lines that cancel.
if (creditorPartyCode != null && creditorPartyCode.trim().equalsIgnoreCase(String.valueOf(debitPartyCode).trim())) {
final String message = "Pick two different accounts - " + creditorPartyCode.trim().toUpperCase()
+ " cannot transfer to itself.";
throw new PlatformApiDataValidationException("error.msg.dds.transfer.same.party", message,
List.of(ApiParameterError.generalError("error.msg.dds.transfer.same.party", message)));
}
final Party creditorParty = partyOf(creditorPartyCode, agent);
final Party debitParty = partyOf(debitPartyCode, agent);
@@ -306,19 +333,21 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
final BigDecimal debtorNewBalance = debitParty.debit(amount);
final BigDecimal creditorNewBalance = creditorParty.credit(amount);
// The same move on the bank's own books. Both parties need real accounts for it: moving one
// side alone would leave the pair of DDS balances agreeing with each other and neither
// agreeing with the bank, which is worse than not moving it at all.
if (debitParty.getSavingsAccountId() != null && creditorParty.getSavingsAccountId() != null) {
this.ddsLedger.transfer(debitParty.getSavingsAccountId(), creditorParty.getSavingsAccountId(), transactionDate, amount,
description == null ? "DDS transfer " + debitPartyCode + " to " + creditorPartyCode : description);
}
// The same move on the bank's own books, wherever each party's account is kept. Two DDS
// accounts are both owed on DDS Customer Deposits, so between them nothing changes account.
this.ddsLedger.transfer(debitParty, creditorParty, transactionDate, amount,
description == null ? "DDS transfer " + debitPartyCode + " to " + creditorPartyCode : description);
// Each side's passbook line says where the money went or came from - a line reading only
// "Transfer" leaves the customer asking whose account took their money.
final String note = description == null || description.isBlank() ? "" : " - " + description;
final List<PartyTransaction> transactions = new ArrayList<>();
transactions.add(new PartyTransaction(debitParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "DEBIT", null, null, null,
amount, null, null, description, debtorNewBalance));
transactions.add(new PartyTransaction(creditorParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "CREDIT", null, null, null,
amount, null, null, description, creditorNewBalance));
transactions.add(new PartyTransaction(debitParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "DEBIT", null, "DDS",
creditorParty.getAccountNo(), amount, null, null,
"Transfer to " + creditorPartyCode + " · " + creditorParty.getPartyName() + note, debtorNewBalance));
transactions.add(new PartyTransaction(creditorParty, agent, transactionDate, "WITHDRAW_DEPOSITE", "CREDIT", null, "DDS",
debitParty.getAccountNo(), amount, null, null,
"Transfer from " + debitPartyCode + " · " + debitParty.getPartyName() + note, creditorNewBalance));
return transactions;
}
@@ -661,4 +661,16 @@
<include file="parts/0511_create_dds_collection_entry.xml" relativeToChangelogFile="true"/>
<include file="parts/0512_dds_collection_entry_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0513_add_dds_agent_gl_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0514_add_dds_party_account_no.xml" relativeToChangelogFile="true"/>
<include file="parts/0515_add_dds_gl_entry_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0516_add_dds_cash_receipt_apply.xml" relativeToChangelogFile="true"/>
<include file="parts/0517_add_dds_receipt_application.xml" relativeToChangelogFile="true"/>
<include file="parts/0518_add_dds_interest_expense_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0519_dds_current_account_payout_and_transaction_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0520_add_dds_collection_entry_days.xml" relativeToChangelogFile="true"/>
<include file="parts/0521_add_dds_premature_close_override_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0522_add_dds_party_transaction_commission_run.xml" relativeToChangelogFile="true"/>
<include file="parts/0523_add_dds_account_type_interest_rate.xml" relativeToChangelogFile="true"/>
<include file="parts/0524_dds_park_in_sundry_creditors.xml" relativeToChangelogFile="true"/>
<include file="parts/0525_add_dds_party_transaction_voucher_no.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A DDS account's own number, kept on the party.
A DDS customer's account used to be a savings account opened behind the party, and its number
was that savings account's. The account is the party now - the scheme it is on, its balance,
agent, maturity and nominees are all already here - so it carries its own number and nothing
is opened in the savings tables for it.
DDS followed by the party's id, zero-padded to nine: unique because the id is, and never
mistaken for a savings, FD or RD account number, which are all digits.
-->
<changeSet id="0514_add_dds_party_account_no" author="CBS">
<addColumn tableName="m_dds_party">
<column name="account_no" type="VARCHAR(20)"/>
</addColumn>
<sql>
update m_dds_party set account_no = 'DDS' || lpad(cast(id as varchar), 9, '0') where account_no is null;
</sql>
<addUniqueConstraint tableName="m_dds_party" columnNames="account_no" constraintName="uq_dds_party_account_no"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
"DDS" as an Entry Type on a cashier's GL receipt.
An agent brings the day's collection to the counter and the cashier receipts it into that
agent's own GL account, the same way any GL receipt is taken. The tag says what the money is:
a DDS agent's collection, so the counter can show the agent and what they are expected to hand
over, and so the receipt can later be told apart from any other money on that account.
Added the same way 0565 seeded General, Share Collection and DD Issue, after them, and only if
the code exists and does not already have it - nothing about the other three changes.
-->
<changeSet id="0515_add_dds_gl_entry_type" author="CBS">
<sql>
INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory)
SELECT c.id, 'DDS', 4, true, false
FROM m_code c
WHERE c.code_name = 'GLEntryType'
AND NOT EXISTS (
SELECT 1 FROM m_code_value cv
WHERE cv.code_id = c.id AND cv.code_value = 'DDS'
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Applying a cashier's DDS receipt to the agent's written-down collections.
The agent's cash reaches the bank at the counter: the cashier receipts it into the agent's own
GL account, tagged DDS (0515). Applying that receipt is what hands the money on to the customers
the agent collected it from - the agent's GL is debited, what the customers are owed is credited,
and each entry is marked settled against the voucher that paid for it. The same shape as a Share
Collection receipt applied to a share account.
-->
<!-- What DDS customers are owed, now that their accounts are DDS's own and not savings accounts. -->
<changeSet id="0516_add_dds_customer_deposits_gl_account" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from acc_gl_account where gl_code = '9.7'</sqlCheck>
</preConditions>
<sql>
insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed,
account_usage, classification_enum, description)
values ('DDS Customer Deposits', null, '.', '9.7', false, true, 1, 2,
'What the bank owes DDS customers on their DDS accounts. Credited when an agent''s collection is applied to the customers it was collected from, debited when an account is paid out.');
</sql>
</changeSet>
<changeSet id="0516_add_dds_deposit_liability_configuration" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from c_configuration where name = 'dds-deposit-liability-account'</sqlCheck>
</preConditions>
<sql>
insert into c_configuration (name, value, enabled, is_trap_door, description)
select 'dds-deposit-liability-account',
(select id from acc_gl_account where gl_code = '9.7' limit 1),
true, false,
'GL account holding what DDS customers are owed on their DDS accounts. Credited when a cashier''s DDS receipt is applied to the agent''s collections.';
</sql>
</changeSet>
<!-- Which voucher settled an entry, so a voucher is applied once and an entry can be traced to it. -->
<changeSet id="0516_add_dds_collection_entry_receipt" author="CBS">
<addColumn tableName="m_dds_collection_entry">
<column name="cashier_deposit_request_id" type="BIGINT"/>
<column name="voucher_no" type="VARCHAR(50)"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_dds_collection_entry" baseColumnNames="cashier_deposit_request_id"
constraintName="fk_dds_collection_entry_receipt" referencedTableName="m_cashier_deposit_request"
referencedColumnNames="id"/>
<createIndex tableName="m_dds_collection_entry" indexName="idx_dds_collection_entry_receipt">
<column name="cashier_deposit_request_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
One application of the cashier's DDS receipts to an agent's entries.
0516 tied each entry to the one voucher that paid for it. An agent's cash does not arrive one
voucher per round: a day's collection can come in two visits, two days' in one. So an
application is its own record - which vouchers, which entries, how much, when and by whom - and
entries and vouchers both point at it.
A voucher can belong to one application only, and that is the database's rule, not only the
screen's: two clerks applying the same voucher at the same moment cannot both succeed.
-->
<changeSet id="0517_create_dds_receipt_application" author="CBS">
<createTable tableName="m_dds_receipt_application">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="agent_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_dds_receipt_app_agent" referencedTableName="m_dds_agent"
referencedColumnNames="id"/>
</column>
<column name="applied_on" type="DATE">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="entry_count" type="INT">
<constraints nullable="false"/>
</column>
<!-- The account the vouchers credited, and so the one this application debits. -->
<column name="gl_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_dds_receipt_app_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</column>
<column name="journal_transaction_id" type="VARCHAR(50)"/>
<column name="createdby_id" type="BIGINT"/>
<column name="created_date" type="TIMESTAMP"/>
</createTable>
<createTable tableName="m_dds_receipt_application_voucher">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="application_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_dds_receipt_app_voucher_app"
referencedTableName="m_dds_receipt_application" referencedColumnNames="id"/>
</column>
<column name="cashier_deposit_request_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_dds_receipt_app_voucher_receipt"
foreignKeyName="fk_dds_receipt_app_voucher_receipt" referencedTableName="m_cashier_deposit_request"
referencedColumnNames="id"/>
</column>
<column name="voucher_no" type="VARCHAR(50)"/>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- Entries point at the application now, in place of 0516's one voucher per entry. -->
<changeSet id="0517_dds_collection_entry_receipt_application" author="CBS">
<addColumn tableName="m_dds_collection_entry">
<column name="receipt_application_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_dds_collection_entry" baseColumnNames="receipt_application_id"
constraintName="fk_dds_collection_entry_receipt_app" referencedTableName="m_dds_receipt_application"
referencedColumnNames="id"/>
<createIndex tableName="m_dds_collection_entry" indexName="idx_dds_collection_entry_receipt_app">
<column name="receipt_application_id"/>
</createIndex>
<dropForeignKeyConstraint baseTableName="m_dds_collection_entry" constraintName="fk_dds_collection_entry_receipt"/>
<dropIndex tableName="m_dds_collection_entry" indexName="idx_dds_collection_entry_receipt"/>
<dropColumn tableName="m_dds_collection_entry" columnName="cashier_deposit_request_id"/>
<dropColumn tableName="m_dds_collection_entry" columnName="voucher_no"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
What interest on a DDS account costs the bank.
Interest on a savings account is booked against its product's "interest on savings" account. A
DDS account is DDS's own now (0514), with no product behind it, so the expense needs an account
of its own - named by a setting, the same way the agent cash, commission and matured-payable
accounts are, so a bank can point it at one of theirs.
-->
<changeSet id="0518_add_dds_interest_expense_gl_account" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from acc_gl_account where gl_code = '11.3'</sqlCheck>
</preConditions>
<sql>
insert into acc_gl_account (name, parent_id, hierarchy, gl_code, disabled, manual_journal_entries_allowed,
account_usage, classification_enum, description)
values ('DDS Interest Expense', null, '.', '11.3', false, true, 1, 5,
'Interest paid to DDS customers on their DDS accounts. Debited when interest is posted, credited back when interest is recovered on an early closure.');
</sql>
</changeSet>
<changeSet id="0518_add_dds_interest_expense_configuration" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from c_configuration where name = 'dds-interest-expense-account'</sqlCheck>
</preConditions>
<sql>
insert into c_configuration (name, value, enabled, is_trap_door, description)
select 'dds-interest-expense-account',
(select id from acc_gl_account where gl_code = '11.3' limit 1),
true, false,
'GL account for what interest on DDS accounts costs the bank. Debited when DDS interest is posted.';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A DDS maturity paid into the customer's current account.
The current account's product names its control account on acc_product_mapping, under the same
product_type as savings products. Where a current product and a savings product share an id, the
two products' mappings sit side by side and nothing in the row says which is which. DdsLedger
uses the mapping when it is the only one, and otherwise this setting - left empty here, because
which account is right is the bank's to say, not a migration's.
-->
<changeSet id="0519_add_dds_current_account_control_configuration" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from c_configuration where name = 'dds-current-account-control-account'</sqlCheck>
</preConditions>
<sql>
insert into c_configuration (name, value, enabled, is_trap_door, description)
values ('dds-current-account-control-account', null, true, false,
'GL control account a DDS maturity paid into a current account is credited to. Only needed when the current product''s GL mapping cannot be told apart from a savings product''s with the same id.');
</sql>
</changeSet>
<!--
Maker-checker on DDS party transactions.
Withdraw Deposite moves one customer's money to another's, and it was the one DDS money movement
that needed nobody's approval. Every other one - collections settled from a cashier's receipt,
payouts, early closures, batch postings - already does.
-->
<changeSet id="0519_enable_maker_checker_on_dds_party_transaction" author="CBS">
<sql>
update m_permission set can_maker_checker = true where code = 'CREATE_PARTYTRANSACTION';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
How many days an agent's written-down collection covers, when it is not just today's.
A customer may pay a few days ahead (up to five) or clear the days they missed (up to three back;
older ones are skipped). The amount follows from the days - the daily instalment times them - so
the days are what the agent actually chose, and they are kept so the counter and the reports
can say "2 days advance" rather than leave a round number to be guessed at. Empty on an ordinary
day's collection.
-->
<changeSet id="0520_add_dds_collection_entry_days" author="CBS">
<addColumn tableName="m_dds_collection_entry">
<column name="advance_days" type="INT"/>
<column name="arrears_days" type="INT"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Closing one DDS account early inside its scheme's lock-in, with a reason, on a manager's approval.
Until now the only way through a lock-in was to set the scheme's lock-in to nothing, close the
account and set it back - which, for as long as it lasted, let every account on the scheme close
early, and left no record of why. This lets one account through and leaves the scheme alone.
Its own permission, not part of PREMATURECLOSE: a clerk who may close an account early on the
scheme's terms should not by that alone be able to waive them. Maker-checker is on from the start.
-->
<changeSet id="0521_add_dds_premature_close_override_permission" author="CBS">
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="PREMATURECLOSEOVERRIDE_PARTY"/>
<column name="entity_name" value="PARTY"/>
<column name="action_name" value="PREMATURECLOSEOVERRIDE"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="PREMATURECLOSEOVERRIDE_PARTY_CHECKER"/>
<column name="entity_name" value="PARTY"/>
<column name="action_name" value="PREMATURECLOSEOVERRIDE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<!-- To the role that approves the rest of DDS, as 0503 did for the ordinary early closure. -->
<sql>
insert into m_role_permission (role_id, permission_id)
select r.id, p.id
from m_role r, m_permission p
where r.name = 'Branch Manager'
and p.code in ('PREMATURECLOSEOVERRIDE_PARTY', 'PREMATURECLOSEOVERRIDE_PARTY_CHECKER')
and not exists (select 1 from m_role_permission rp
where rp.role_id = r.id and rp.permission_id = p.id);
</sql>
</changeSet>
<!--
The Branch Manager approves the rest of DDS but could not approve applying an agent's cash
receipt to their collections - SETTLE_DDSCOLLECTIONENTRY_CHECKER was never granted, so an apply
sent for approval would sit in the inbox with nobody able to pass it.
-->
<changeSet id="0521_grant_branch_manager_dds_collection_entry" author="CBS">
<sql>
insert into m_role_permission (role_id, permission_id)
select r.id, p.id
from m_role r, m_permission p
where r.name = 'Branch Manager'
and p.code in ('READ_DDSCOLLECTIONENTRY', 'SETTLE_DDSCOLLECTIONENTRY', 'SETTLE_DDSCOLLECTIONENTRY_CHECKER')
and not exists (select 1 from m_role_permission rp
where rp.role_id = r.id and rp.permission_id = p.id);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Which commission run each collection was counted in.
Commission used to be counted by the day money was collected, from the day after the last run.
A collection is credited only when the agent's cash receipt is applied, which can be days later,
so one collected on the 30th and applied on the 1st fell before the next run's window and after
the last run - and never earned commission. Each collection is now marked by the run that
counted it, and a run counts every one not yet marked, whatever its date.
Collections already inside an agent's last commission period are marked as counted: they were,
under the old rule. 0 stands for "counted before this column existed" where no posted
calculation run is on file.
-->
<changeSet id="0522_add_dds_party_transaction_commission_run" author="CBS">
<addColumn tableName="m_dds_party_transaction">
<column name="commission_batch_run_id" type="BIGINT"/>
</addColumn>
<sql>
update m_dds_party_transaction t
set commission_batch_run_id = coalesce(
(select max(b.id)
from m_dds_batch_run b
join m_dds_batch_run_row r on r.batch_run_id = b.id
where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and r.agent_id = t.agent_id), 0)
where t.transaction_type = 'PARTY_TRAN'
and t.direction = 'CREDIT'
and t.transaction_date &lt;= (select a.last_commission_date from m_dds_agent a where a.id = t.agent_id);
</sql>
<createIndex tableName="m_dds_party_transaction" indexName="idx_dds_party_txn_commission_pending">
<column name="agent_id"/>
<column name="commission_batch_run_id"/>
</createIndex>
</changeSet>
<!--
The marking above goes by collection date, which is the very rule being replaced: a collection
dated on or before the last run but applied after it was posted was never in that run, yet was
marked as counted. Those are unmarked, so the next run pays them.
-->
<changeSet id="0522_unmark_collections_applied_after_their_run" author="CBS">
<sql>
update m_dds_party_transaction t
set commission_batch_run_id = null
where t.commission_batch_run_id &gt; 0
and t.created_date &gt; (select b.lastmodified_date from m_dds_batch_run b where b.id = t.commission_batch_run_id);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
The interest rate belongs to the scheme, not to each account.
It was typed on every Party, so two customers on the same scheme could be opened at different
rates by a slip at the counter. The scheme now carries it, mandatory, and an account takes it
when it is opened. Accounts keep their own copy - a rate the bank changes later does not
reprice accounts already open.
Existing schemes are given the rate most of their accounts were opened at, so nothing that is
already running changes; a scheme with no accounts yet starts at 0 for the bank to set.
-->
<changeSet id="0523_add_dds_account_type_interest_rate" author="CBS">
<addColumn tableName="m_dds_account_type">
<column name="interest_rate" type="DECIMAL(19,6)"/>
</addColumn>
<sql>
update m_dds_account_type t
set interest_rate = coalesce(
(select p.interest_rate
from m_dds_party p
where upper(trim(p.sub_type)) = upper(trim(t.account_type_code))
and p.interest_rate is not null
group by p.interest_rate
order by count(*) desc, p.interest_rate
limit 1), 0);
</sql>
<addNotNullConstraint tableName="m_dds_account_type" columnName="interest_rate" columnDataType="DECIMAL(19,6)"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
A DDS balance with no account here to go to is parked in the bank's own Sundry Creditors.
0494 opened a GL of DDS's own for it, 9.6 DDS Matured Deposits Payable, which the chart did not
need: 200902 SUNDRY CREDITORS A/C was already there for exactly this. DDS now parks in the
Sundry account the clerk picks, Sundry Creditors to start with, so 9.6 is pointed away from and
removed - but only where nothing was ever posted to it, so a database that did use it keeps it
and its history.
-->
<changeSet id="0524_point_dds_matured_payable_at_sundry_creditors" author="cbsnexora">
<sql>
update c_configuration
set value = (select id from acc_gl_account where gl_code = '200902')
where name = 'dds-matured-payable-account'
and exists (select 1 from acc_gl_account where gl_code = '200902')
and not exists (select 1 from acc_gl_journal_entry j join acc_gl_account g on g.id = j.account_id
where g.gl_code = '9.6');
</sql>
</changeSet>
<changeSet id="0524_remove_unused_dds_matured_payable_gl" author="cbsnexora">
<sql>
delete from acc_gl_account g
where g.gl_code = '9.6'
and not exists (select 1 from acc_gl_journal_entry j where j.account_id = g.id)
and not exists (select 1 from acc_gl_account child where child.parent_id = g.id)
and not exists (select 1 from c_configuration c where c.name = 'dds-matured-payable-account' and c.value = g.id);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Each DDS passbook line carries the voucher its money moved under.
DDS raises its vouchers in the bank's own register (m_voucher), numbered in a series of its
own - DDS/DDMMYYYY/NNNN. The number is stamped here so the passbook, the account's transactions
and its closure can show which slip each line belongs to. Lines written before vouchers were
raised keep it empty.
-->
<changeSet id="0525_add_dds_party_transaction_voucher_no" author="cbsnexora">
<addColumn tableName="m_dds_party_transaction">
<column name="voucher_no" type="VARCHAR(30)"/>
</addColumn>
<createIndex tableName="m_dds_party_transaction" indexName="idx_dds_party_txn_voucher_no">
<column name="voucher_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>