feat(dds): DDS product accounting tab; DDS posts only through product GLs

- DDS product names its own Customer Deposits, Interest Expense and
  Agent Commission Expense GLs (validated by kind), shown on the product
  view and in the approval review with old -> new on edits
- DdsLedger posts every DDS movement through the party's product GLs;
  commission is shared over the products an agent collected for
- dds-* settings removed from Global Configurations (0620); scheme
  accounts recognised by the DDS party linked to them, not a product id
- 0621 drops the agent cash, matured payable and current control GL
  columns, which nothing posts to
- Approval preview of interest/commission runs shows the product GLs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 12:43:41 +05:30
co-authored by Claude Opus 5.5
parent 6e89217e59
commit 422ea459be
18 changed files with 635 additions and 189 deletions
@@ -55,6 +55,16 @@ public final class DdsAccountTypeData implements Serializable {
/** The scheme's annual interest rate, which an account opened on it takes. */ /** The scheme's annual interest rate, which an account opened on it takes. */
private final BigDecimal interestRate; private final BigDecimal interestRate;
private final Boolean active; private final Boolean active;
/** The product's own ledger accounts (see DdsAccountType), each with its code and name to show it by. */
private final Long depositLiabilityGlId;
private final String depositLiabilityGlCode;
private final String depositLiabilityGlName;
private final Long interestExpenseGlId;
private final String interestExpenseGlCode;
private final String interestExpenseGlName;
private final Long commissionExpenseGlId;
private final String commissionExpenseGlCode;
private final String commissionExpenseGlName;
private final LocalDateTime createdDate; private final LocalDateTime createdDate;
private final LocalDateTime lastModifiedDate; private final LocalDateTime lastModifiedDate;
} }
@@ -115,6 +115,24 @@ public class DdsAccountType extends AbstractAuditableCustom {
@Column(name = "is_active", nullable = false) @Column(name = "is_active", nullable = false)
private boolean active; private boolean active;
/**
* The ledger accounts this product posts through, chosen on its Accounting tab the way a savings
* product chooses its own. DDS posts through these alone - there is no bank-wide account to fall
* back on - so a product missing one stops its postings with a message naming what to set.
*
* What the bank owes this product's customers - DDS Customer Deposits, or the product's own.
*/
@Column(name = "deposit_liability_gl_id")
private Long depositLiabilityGlId;
/** What interest paid to this product's customers costs the bank. */
@Column(name = "interest_expense_gl_id")
private Long interestExpenseGlId;
/** What the agents' commission on collecting for this product costs the bank. */
@Column(name = "commission_expense_gl_id")
private Long commissionExpenseGlId;
protected DdsAccountType() { protected DdsAccountType() {
// //
} }
@@ -131,6 +149,9 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION); this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION);
this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY); this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY);
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate"); this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
// A newly created account type is available for use straight away. // A newly created account type is available for use straight away.
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active"); this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
} }
@@ -238,6 +259,23 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.interestRate = newValue; this.interestRate = newValue;
} }
// The product's ledger accounts. All three are required; the deserializer refuses a null.
if (command.parameterExists("depositLiabilityGlId")
&& command.isChangeInLongParameterNamed("depositLiabilityGlId", this.depositLiabilityGlId)) {
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
actualChanges.put("depositLiabilityGlId", this.depositLiabilityGlId);
}
if (command.parameterExists("interestExpenseGlId")
&& command.isChangeInLongParameterNamed("interestExpenseGlId", this.interestExpenseGlId)) {
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
actualChanges.put("interestExpenseGlId", this.interestExpenseGlId);
}
if (command.parameterExists("commissionExpenseGlId")
&& command.isChangeInLongParameterNamed("commissionExpenseGlId", this.commissionExpenseGlId)) {
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
actualChanges.put("commissionExpenseGlId", this.commissionExpenseGlId);
}
if (command.isChangeInBooleanParameterNamed("active", this.active)) { if (command.isChangeInBooleanParameterNamed("active", this.active)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active"); final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
actualChanges.put("active", newValue); actualChanges.put("active", newValue);
@@ -303,9 +303,13 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
+ "join m_savings_product p on p.id = a.product_id " + "join m_savings_product p on p.id = a.product_id "
+ "where a.account_no = ? and a.status_enum = 300"; + "where a.account_no = ? and a.status_enum = 300";
/** The product DDS opens customers' scheme accounts under - see 0492. */ /**
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration " * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
+ "where name = 'dds-deposit-product' and enabled = true"; * Read off the parties themselves rather than a configured product id: what makes an account a
* scheme account is that a scheme runs on it, whatever product it was opened under.
*/
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
private static final int SAVINGS_DEPOSIT_TYPE = 100; private static final int SAVINGS_DEPOSIT_TYPE = 100;
@@ -353,9 +357,8 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
// A DDS scheme account is a savings account too, so the check above lets it through - but it // A DDS scheme account is a savings account too, so the check above lets it through - but it
// belongs to a customer's scheme, and commission paid into it would be counted as that // belongs to a customer's scheme, and commission paid into it would be counted as that
// customer's deposits by every DDS interest run from then on. // customer's deposits by every DDS interest run from then on.
final Long productId = ((Number) account.get("product_id")).longValue(); final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR if (schemes != null && schemes > 0) {
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme"); throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
} }
} }
@@ -19,9 +19,13 @@
package org.apache.fineract.portfolio.dds.accounting; package org.apache.fineract.portfolio.dds.accounting;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.glaccount.domain.GLAccount;
@@ -83,35 +87,16 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DdsLedger { public class DdsLedger {
/** Cash an agent has collected but not yet handed to a branch. Set by 0493. */
public static final String AGENT_CASH = "dds-agent-cash-account";
/** What an agent's commission costs the bank. Set by 0491. */
public static final String COMMISSION_EXPENSE = "dds-agent-commission-expense-account";
/** The savings product DDS opens customer accounts under. Set by 0492. */
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
/** 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 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 * 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 * 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 * 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, * 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 * 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 * sum of customer or agent accounts - a DDS product's customer deposits account, a savings or
* account, a current-account control, an agent's own cash account - since parked money in any of * deposit product's control account, an agent's own cash account - since parked money in any of
* those would leave it no longer adding up. * 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\" " private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
@@ -120,8 +105,7 @@ public class DdsLedger {
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 " + "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 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 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', " + "and g.id not in (select t.deposit_liability_gl_id from m_dds_account_type t where t.deposit_liability_gl_id is not null) "
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
+ "order by g.gl_code"; + "order by g.gl_code";
/** /**
@@ -164,7 +148,7 @@ public class DdsLedger {
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both. * 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) { 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(), DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, depositLiabilityOf(party).getId(),
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description)); party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
} }
@@ -183,17 +167,6 @@ public class DdsLedger {
return deposit; return deposit;
} }
/**
* Moves cash from an agent's hands into a branch. No customer account is involved - the customers
* were credited when their money was collected; this is the bank catching up with the cash.
*/
public void settle(final Office office, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount agentCashAccount) {
final Long productId = depositProductId();
postPair(office, currencyOf(productId), date, amount, productAccount(productId, FUND_SOURCE), agentCashOr(agentCashAccount),
description, null, null);
}
/** /**
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry. * Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
* *
@@ -207,11 +180,13 @@ public class DdsLedger {
* DR the account the receipts credited the agent's account clears * DR the account the receipts credited the agent's account clears
* CR DDS Customer Deposits what the bank now owes the customers * CR DDS Customer Deposits what the bank now owes the customers
* </pre> * </pre>
*
* One pair per customer-deposits account rather than one in all: each DDS product names its own
* (see depositLiabilityOf), so customers on two products are owed on two accounts.
*/ */
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount, public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
final String transactionId, final String description) { final GLAccount depositAccount, final String transactionId, final String description) {
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description, writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, depositAccount, description, null, null);
null, null);
} }
/** /**
@@ -242,7 +217,7 @@ public class DdsLedger {
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount); collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
return; return;
} }
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description); ddsPair(party, date, amount, agentCashOr(agentCashAccount), depositLiabilityOf(party), description);
} }
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */ /** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
@@ -251,7 +226,7 @@ public class DdsLedger {
postInterest(party.getSavingsAccountId(), date, amount, description); postInterest(party.getSavingsAccountId(), date, amount, description);
return; return;
} }
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description); ddsPair(party, date, amount, interestExpenseOf(party), depositLiabilityOf(party), description);
} }
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */ /** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
@@ -260,7 +235,7 @@ public class DdsLedger {
reverseInterest(party.getSavingsAccountId(), date, amount, description); reverseInterest(party.getSavingsAccountId(), date, amount, description);
return; return;
} }
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description); ddsPair(party, date, amount, depositLiabilityOf(party), interestExpenseOf(party), description);
} }
/** /**
@@ -275,7 +250,7 @@ public class DdsLedger {
} }
final SavingsAccount to = load(toSavingsAccountId); final SavingsAccount to = load(toSavingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description); postPair(to, deposit, date, amount, depositLiabilityOf(party), controlAccountOf(to), description);
} }
/** /**
@@ -305,7 +280,7 @@ public class DdsLedger {
owed = controlAccountOf(account); owed = controlAccountOf(account);
office = account.office(); office = account.office();
} else { } else {
owed = configured(DEPOSIT_LIABILITY); owed = depositLiabilityOf(party);
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice(); office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
} }
@@ -329,9 +304,6 @@ public class DdsLedger {
+ " join acc_product_mapping apm on apm.product_id = ca.product_id " + " 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"; + " 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. */ /** 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) { 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 final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
@@ -348,23 +320,16 @@ public class DdsLedger {
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
} }
/** /** The current account's control account, from the current product's own accounting. */
* 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) { private GLAccount currentAccountControl(final Long currentAccountId) {
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
if (ids.size() == 1) { if (ids.size() == 1) {
return this.glAccountRepository.findById(ids.get(0)) return this.glAccountRepository.findById(ids.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId)); .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 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)"); : "a single control account for current account " + currentAccountId
+ " (its 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. */ /** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
@@ -374,7 +339,7 @@ public class DdsLedger {
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn); payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
return; return;
} }
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description); ddsPair(party, date, amount, depositLiabilityOf(party), parkIn, description);
} }
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */ /** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
@@ -423,15 +388,22 @@ public class DdsLedger {
this.savingsAccountAssembler.getPivotConfigStatus()); this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked); relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
configured(DEPOSIT_LIABILITY), description, null, account.getId()); depositLiabilityOf(from), description, null, account.getId());
} else if (toSavings) { } else if (toSavings) {
final SavingsAccount account = load(to.getSavingsAccountId()); final SavingsAccount account = load(to.getSavingsAccountId());
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description); postPair(account, deposit, date, amount, depositLiabilityOf(to), controlAccountOf(account), description);
} else { } else {
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom. final GLAccount fromDeposits = depositLiabilityOf(from);
final GLAccount toDeposits = depositLiabilityOf(to);
if (fromDeposits.getId().equals(toDeposits.getId())) {
// Both owed on the same account: nothing to book, but the slip still names who paid whom.
voucherSideOnDeposits("DR", from, amount, description); voucherSideOnDeposits("DR", from, amount, description);
voucherSideOnDeposits("CR", to, amount, description); voucherSideOnDeposits("CR", to, amount, description);
} else {
// Two products with customer-deposit accounts of their own: the debt moves from one to the other.
ddsPair(from, date, amount, fromDeposits, toDeposits, description);
}
} }
} }
@@ -444,17 +416,133 @@ public class DdsLedger {
} }
/** /**
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one * The account playing the agent's part on an entry - the agent's own. An agent cannot be saved
* when they have none. * without one, so a missing one here is an agent set up before that was required.
*
* Only the account differs. Both sides of the entry, their direction and the amount are the same
* either way - an agent with their own account is not accounted for differently, only separately.
* That is what makes the shared account a safe fallback rather than a second behaviour: an agent
* set up before this existed posts exactly as they always did, and a branch can move agents onto
* their own accounts one at a time.
*/ */
private GLAccount agentCashOr(final GLAccount agentCashAccount) { private GLAccount agentCashOr(final GLAccount agentCashAccount) {
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH); if (agentCashAccount == null) {
throw new DdsLedgerNotConfiguredException("the agent's GL account (Organization > Agent Master)");
}
return agentCashAccount;
}
// ---------------------------------------------------------------------------------------------
// A party's product's own ledger accounts.
//
// Each DDS product names the accounts it posts through on its Accounting tab (0620), as a savings
// product does, and those are the only accounts DDS posts through - there is no bank-wide account
// behind them. A product that has not named one stops the posting with a message saying which
// account to set on which product, rather than the money landing somewhere nobody chose.
// ---------------------------------------------------------------------------------------------
/** Columns of m_dds_account_type a product's account may be read from - never anything a caller typed. */
private static final Set<String> PRODUCT_GL_COLUMNS = Set.of("deposit_liability_gl_id", "interest_expense_gl_id",
"commission_expense_gl_id");
/** What the bank owes this party - their product's customer-deposits account. */
public GLAccount depositLiabilityOf(final Party party) {
return productAccount(party, "deposit_liability_gl_id", "DDS Customer Deposits");
}
/** What this party's interest costs the bank - their product's interest-expense account. */
public GLAccount interestExpenseOf(final Party party) {
return productAccount(party, "interest_expense_gl_id", "Interest Expense");
}
private GLAccount productAccount(final Party party, final String column, final String label) {
if (!PRODUCT_GL_COLUMNS.contains(column)) {
throw new IllegalArgumentException(column);
}
final String productCode = party == null || party.getSubType() == null ? null : party.getSubType().trim().toUpperCase();
if (productCode == null) {
throw new DdsLedgerNotConfiguredException("a DDS product for party " + (party == null ? "?" : party.getPartyCode()));
}
final List<Long> ids = this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type " // NOSONAR
+ "where account_type_code = ?", Long.class, productCode);
if (ids.isEmpty()) {
throw new DdsLedgerNotConfiguredException("DDS product " + productCode + " (party " + party.getPartyCode() + ")");
}
return accountNamed(ids.get(0), productCode, label);
}
private GLAccount accountNamed(final Long glAccountId, final String productCode, final String label) {
final Optional<GLAccount> account = glAccountId == null ? Optional.empty() : this.glAccountRepository.findById(glAccountId);
return account.orElseThrow(() -> new DdsLedgerNotConfiguredException(
"the " + label + " account on DDS product " + productCode + " (Products > DDS Products > Edit > Accounting)"));
}
// ---------------------------------------------------------------------------------------------
// An agent's commission, by product.
//
// Commission is paid to an agent in one sum for a round that may collect for several products, and
// each product books its share on its own commission account. The share is each product's part of
// the collections the commission was earned on: those a commission run has counted and no payment
// has yet covered. Paying marks them, so the next payment is shared out on the next collections.
// ---------------------------------------------------------------------------------------------
private static final String COMMISSION_BASIS_SQL = "select upper(trim(p.sub_type)) as \"product\", t.commission_expense_gl_id as \"glId\", "
+ "sum(pt.amount) as \"volume\" from m_dds_party_transaction pt join m_dds_party p on p.id = pt.party_id "
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
+ "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' "
+ "and pt.commission_batch_run_id is not null %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id "
+ "order by upper(trim(p.sub_type))";
private static final String UNPAID_COMMISSION_FILTER = "and pt.commission_paid_batch_run_id is null";
private static final String MARK_COMMISSION_PAID_SQL = "update m_dds_party_transaction set commission_paid_batch_run_id = ? "
+ "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is not null "
+ "and commission_paid_batch_run_id is null";
/**
* An agent's commission split across the commission accounts of the products they collected for,
* in proportion to what they collected for each. The last share takes the rounding, so the shares
* always add up to the amount paid.
*
* Collections counted before payments were tracked by run have no payment marked against them; if
* nothing unpaid is left, the agent's counted collections as a whole set the proportions instead.
*/
public Map<GLAccount, BigDecimal> commissionSplit(final Long agentId, final BigDecimal amount) {
List<Map<String, Object>> basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, UNPAID_COMMISSION_FILTER), // NOSONAR
agentId);
if (basis.isEmpty()) {
basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId); // NOSONAR
}
if (basis.isEmpty()) {
throw new DdsLedgerNotConfiguredException("a collection by this agent to tell which DDS product's Agent Commission Expense "
+ "account the commission belongs to");
}
final Map<Long, GLAccount> accounts = new LinkedHashMap<>();
final Map<Long, BigDecimal> volumes = new LinkedHashMap<>();
BigDecimal total = BigDecimal.ZERO;
for (final Map<String, Object> row : basis) {
final String product = (String) row.get("product");
final Long glId = row.get("glId") == null ? null : ((Number) row.get("glId")).longValue();
final GLAccount account = accountNamed(glId, product, "Agent Commission Expense");
final BigDecimal volume = (BigDecimal) row.get("volume");
accounts.putIfAbsent(account.getId(), account);
volumes.merge(account.getId(), volume, BigDecimal::add);
total = total.add(volume);
}
final Map<GLAccount, BigDecimal> shares = new LinkedHashMap<>();
BigDecimal allotted = BigDecimal.ZERO;
final List<Long> ids = List.copyOf(volumes.keySet());
for (int i = 0; i < ids.size(); i++) {
final Long id = ids.get(i);
final BigDecimal share = i == ids.size() - 1 || total.signum() == 0 ? amount.subtract(allotted)
: amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_EVEN);
allotted = allotted.add(share);
if (share.signum() != 0) {
shares.put(accounts.get(id), share);
}
}
return shares;
}
/** Marks the collections a commission payment was shared out on, so the next payment starts after them. */
public void markCommissionPaid(final Long agentId, final Long batchRunId) {
this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId); // NOSONAR
} }
/** /**
@@ -592,12 +680,18 @@ public class DdsLedger {
return deposit; return deposit;
} }
/** Credits an agent's own savings account with commission the bank owes them. */ /**
* Credits an agent's own savings account with commission the bank owes them - one deposit to the
* agent, and one entry per product on that product's commission account (see commissionSplit).
*/
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) { final String description, final Map<GLAccount, BigDecimal> shares) {
final SavingsAccount account = load(savingsAccountId); final SavingsAccount account = load(savingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description); final GLAccount control = controlAccountOf(account);
for (final Map.Entry<GLAccount, BigDecimal> share : shares.entrySet()) {
postPair(account, deposit, date, share.getValue(), share.getKey(), control, description);
}
return deposit; return deposit;
} }
@@ -683,31 +777,10 @@ public class DdsLedger {
} }
} }
/** A ledger account named by a setting, so a bank can point it at one of their own. */
private GLAccount configured(final String settingName) {
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, settingName); // NOSONAR
if (ids.isEmpty() || ids.get(0) == null) {
throw new DdsLedgerNotConfiguredException(settingName);
}
return this.glAccountRepository.findById(ids.get(0)).orElseThrow(() -> new DdsLedgerNotConfiguredException(settingName));
}
private GLAccount controlAccountOf(final SavingsAccount account) { private GLAccount controlAccountOf(final SavingsAccount account) {
return productAccount(account.productId(), CONTROL_ACCOUNT); return productAccount(account.productId(), CONTROL_ACCOUNT);
} }
/**
* The savings product DDS opens customer accounts under. A settlement touches no single customer,
* so its ledger accounts and currency come from the product rather than from an account.
*/
private Long depositProductId() {
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, DEPOSIT_PRODUCT); // NOSONAR
if (ids.isEmpty() || ids.get(0) == null) {
throw new DdsLedgerNotConfiguredException(DEPOSIT_PRODUCT);
}
return ids.get(0);
}
/** The bank's own currency, for an entry that belongs to no savings product. */ /** The bank's own currency, for an entry that belongs to no savings product. */
private String organisationCurrency() { private String organisationCurrency() {
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
@@ -717,14 +790,6 @@ public class DdsLedger {
return codes.get(0); 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
if (codes.isEmpty()) {
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
}
return codes.get(0);
}
/** An account off the savings product's own mapping, so this stays right per product. */ /** An account off the savings product's own mapping, so this stays right per product. */
private GLAccount productAccount(final Long productId, final int financialAccountType) { private GLAccount productAccount(final Long productId, final int financialAccountType) {
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -356,7 +357,7 @@ public class DdsVoucherService {
return new ArrayList<>(); return new ArrayList<>();
} }
final Long depositGlId = depositLiabilityGlId(); final Set<Long> depositGlIds = depositLiabilityGlIds();
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>(); 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 for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
transactionIds.toArray())) { transactionIds.toArray())) {
@@ -383,7 +384,7 @@ public class DdsVoucherService {
} }
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId"))); final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
if (partyLeg.matches() && glId.equals(depositGlId)) { if (partyLeg.matches() && depositGlIds.contains(glId)) {
final String partyCode = partyLeg.group(1); final String partyCode = partyLeg.group(1);
final Map<String, Object> party = parties.computeIfAbsent(partyCode, final Map<String, Object> party = parties.computeIfAbsent(partyCode,
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
@@ -417,11 +418,13 @@ public class DdsVoucherService {
return lines; return lines;
} }
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */ /**
private Long depositLiabilityGlId() { * The GLs that carry what DDS customers are owed - a leg on one is the customer's own. Each DDS
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR * product names its own on its Accounting tab (0620).
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class); */
return ids.isEmpty() ? null : ids.get(0); private Set<Long> depositLiabilityGlIds() {
return new HashSet<>(this.jdbcTemplate.queryForList( // NOSONAR
"select deposit_liability_gl_id from m_dds_account_type where deposit_liability_gl_id is not null", Long.class));
} }
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) { private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
@@ -47,6 +47,7 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode", private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active", "accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate", "prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
"depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId",
"dateFormat", "locale")); "dateFormat", "locale"));
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */ /** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
@@ -71,6 +72,14 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
v.reset().parameter("accountTypeDescription") v.reset().parameter("accountTypeDescription")
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank() .value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
.notExceedingLengthOf(200); .notExceedingLengthOf(200);
// Mandatory, as a savings product's control and interest accounts are: every posting for the
// product's customers and agents goes through them, with nothing bank-wide behind them.
v.reset().parameter("depositLiabilityGlId").value(this.fromApiJsonHelper.extractLongNamed("depositLiabilityGlId", element))
.notNull().integerGreaterThanZero();
v.reset().parameter("interestExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("interestExpenseGlId", element))
.notNull().integerGreaterThanZero();
v.reset().parameter("commissionExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("commissionExpenseGlId", element))
.notNull().integerGreaterThanZero();
// Mandatory: every account opened on the scheme takes this rate. // Mandatory: every account opened on the scheme takes this rate.
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element)) v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
@@ -104,6 +113,14 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
} }
// Once named, a product's customer and interest accounts can be changed but not cleared.
for (final String required : List.of("depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId")) {
if (this.fromApiJsonHelper.parameterExists(required, element)) {
v.reset().parameter(required).value(this.fromApiJsonHelper.extractLongNamed(required, element)).notNull()
.integerGreaterThanZero();
}
}
validateOptionalFields(element, v); validateOptionalFields(element, v);
throwExceptionIfValidationWarningsExist(dataValidationErrors); throwExceptionIfValidationWarningsExist(dataValidationErrors);
@@ -50,8 +50,17 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, " + " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
+ " t.interest_rate as interestRate, " + " t.interest_rate as interestRate, "
+ " t.is_active as active, " + " t.is_active as active, "
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate " + " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate, "
+ " from m_dds_account_type t"; + " t.deposit_liability_gl_id as depositLiabilityGlId, dl.gl_code as depositLiabilityGlCode, "
+ " dl.name as depositLiabilityGlName, "
+ " t.interest_expense_gl_id as interestExpenseGlId, ie.gl_code as interestExpenseGlCode, "
+ " ie.name as interestExpenseGlName, "
+ " t.commission_expense_gl_id as commissionExpenseGlId, ce.gl_code as commissionExpenseGlCode, "
+ " ce.name as commissionExpenseGlName "
+ " from m_dds_account_type t "
+ " left join acc_gl_account dl on dl.id = t.deposit_liability_gl_id "
+ " left join acc_gl_account ie on ie.id = t.interest_expense_gl_id "
+ " left join acc_gl_account ce on ce.id = t.commission_expense_gl_id";
} }
@Override @Override
@@ -69,6 +78,15 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) // .prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
.interestRate(rs.getBigDecimal("interestRate")) // .interestRate(rs.getBigDecimal("interestRate")) //
.active(rs.getBoolean("active")) // .active(rs.getBoolean("active")) //
.depositLiabilityGlId(JdbcSupport.getLong(rs, "depositLiabilityGlId")) //
.depositLiabilityGlCode(rs.getString("depositLiabilityGlCode")) //
.depositLiabilityGlName(rs.getString("depositLiabilityGlName")) //
.interestExpenseGlId(JdbcSupport.getLong(rs, "interestExpenseGlId")) //
.interestExpenseGlCode(rs.getString("interestExpenseGlCode")) //
.interestExpenseGlName(rs.getString("interestExpenseGlName")) //
.commissionExpenseGlId(JdbcSupport.getLong(rs, "commissionExpenseGlId")) //
.commissionExpenseGlCode(rs.getString("commissionExpenseGlCode")) //
.commissionExpenseGlName(rs.getString("commissionExpenseGlName")) //
.createdDate(localDateTime(rs, "createdDate")) // .createdDate(localDateTime(rs, "createdDate")) //
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) // .lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
.build(); .build();
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.dds.accounttype.service; package org.apache.fineract.portfolio.dds.accounttype.service;
import jakarta.persistence.PersistenceException; import jakarta.persistence.PersistenceException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -35,7 +36,10 @@ import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNot
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer; 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.Party;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +50,15 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer; private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final DdsAccountTypeRepository ddsAccountTypeRepository; private final DdsAccountTypeRepository ddsAccountTypeRepository;
private final PartyRepository partyRepository; private final PartyRepository partyRepository;
private final JdbcTemplate jdbcTemplate;
private static final String GL_SQL = "select classification_enum, account_usage, disabled, gl_code, name from acc_gl_account where id = ?";
/** acc_gl_account.classification_enum: 2 liability, 5 expense. */
private static final int LIABILITY = 2;
private static final int EXPENSE = 5;
/** acc_gl_account.account_usage 1 - a detail account an entry can be posted to, not a header. */
private static final int DETAIL = 1;
@Transactional @Transactional
@Override @Override
@@ -53,6 +66,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
try { try {
this.fromApiJsonDeserializer.validateForCreate(command.json()); this.fromApiJsonDeserializer.validateForCreate(command.json());
validateLedgerAccounts(command);
final DdsAccountType accountType = DdsAccountType.fromJson(command); final DdsAccountType accountType = DdsAccountType.fromJson(command);
this.ddsAccountTypeRepository.saveAndFlush(accountType); this.ddsAccountTypeRepository.saveAndFlush(accountType);
@@ -77,6 +91,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
try { try {
this.fromApiJsonDeserializer.validateForUpdate(command.json()); this.fromApiJsonDeserializer.validateForUpdate(command.json());
validateLedgerAccounts(command);
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId) final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId)); .orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
@@ -115,6 +130,47 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
} }
} }
/**
* Each ledger account a product names must be one an entry can land on, and of the kind its job
* needs - the same rule the savings product's Accounting tab applies by offering only those. The
* customers' money is owed, so a liability; their interest and the agents' commission are costs, so
* expenses.
*/
private void validateLedgerAccounts(final JsonCommand command) {
final List<ApiParameterError> errors = new ArrayList<>();
checkLedgerAccount(command, "depositLiabilityGlId", "Customer Deposits", List.of(LIABILITY), errors);
checkLedgerAccount(command, "interestExpenseGlId", "Interest Expense", List.of(EXPENSE), errors);
checkLedgerAccount(command, "commissionExpenseGlId", "Agent Commission Expense", List.of(EXPENSE), errors);
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private void checkLedgerAccount(final JsonCommand command, final String parameter, final String label, final List<Integer> kinds,
final List<ApiParameterError> errors) {
final Long glId = command.parameterExists(parameter) ? command.longValueOfParameterNamed(parameter) : null;
if (glId == null) {
return;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(GL_SQL, glId); // NOSONAR
final String code = "error.msg.dds.account.type." + parameter + ".invalid";
if (rows.isEmpty()) {
errors.add(ApiParameterError.parameterError(code, label + ": GL account " + glId + " does not exist.", parameter));
return;
}
final Map<String, Object> gl = rows.get(0);
final String name = gl.get("name") + " (" + gl.get("gl_code") + ")";
if (Boolean.TRUE.equals(gl.get("disabled"))) {
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is disabled.", parameter));
} else if (((Number) gl.get("account_usage")).intValue() != DETAIL) {
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is a header account and cannot take entries.",
parameter));
} else if (!kinds.contains(((Number) gl.get("classification_enum")).intValue())) {
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is not "
+ (kinds.get(0) == LIABILITY ? "a liability" : "an expense") + " account.", parameter));
}
}
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) { private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) { if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) {
throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code", throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code",
@@ -43,7 +43,8 @@ public class DdsAccountTypeConfiguration {
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class) @ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService( public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository, DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository,
PartyRepository partyRepository) { PartyRepository partyRepository, JdbcTemplate jdbcTemplate) {
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository); return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository,
jdbcTemplate);
} }
} }
@@ -30,6 +30,8 @@ import java.util.Objects;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.commands.exception.CommandNotFoundException;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -64,6 +66,7 @@ public class DdsApprovalReadService {
private static final String REF_KEY_PREFIX = "DDS_COMMAND:"; private static final String REF_KEY_PREFIX = "DDS_COMMAND:";
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DdsLedger ddsLedger;
public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) { 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 StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? ");
@@ -408,6 +411,7 @@ public class DdsApprovalReadService {
private static final String GROUP_PRODUCT = "Product"; private static final String GROUP_PRODUCT = "Product";
private static final String GROUP_INTEREST = "Interest"; private static final String GROUP_INTEREST = "Interest";
private static final String GROUP_CLOSURE = "Maturity & Premature Closure"; private static final String GROUP_CLOSURE = "Maturity & Premature Closure";
private static final String GROUP_ACCOUNTING = "Accounting";
private static final List<ProductField> PRODUCT_FIELDS = List.of( // private static final List<ProductField> PRODUCT_FIELDS = List.of( //
new ProductField("account_type_group", "accountTypeGroup", "Account Type Group", "text", GROUP_PRODUCT), // new ProductField("account_type_group", "accountTypeGroup", "Account Type Group", "text", GROUP_PRODUCT), //
@@ -422,7 +426,11 @@ public class DdsApprovalReadService {
new ProductField("premature_interest_reduction", "prematureInterestReduction", "Premature Interest Reduction", "percent", new ProductField("premature_interest_reduction", "prematureInterestReduction", "Premature Interest Reduction", "percent",
GROUP_CLOSURE), // GROUP_CLOSURE), //
new ProductField("premature_agent_commission_penalty", "prematureAgentCommissionPenalty", "Agent Commission Penalty", new ProductField("premature_agent_commission_penalty", "prematureAgentCommissionPenalty", "Agent Commission Penalty",
"percent", GROUP_CLOSURE)); "percent", GROUP_CLOSURE), //
new ProductField("deposit_liability_gl_id", "depositLiabilityGlId", "DDS Customer Deposits (Liability)", "gl",
GROUP_ACCOUNTING), //
new ProductField("interest_expense_gl_id", "interestExpenseGlId", "Interest Expense", "gl", GROUP_ACCOUNTING), //
new ProductField("commission_expense_gl_id", "commissionExpenseGlId", "Agent Commission Expense", "gl", GROUP_ACCOUNTING));
/** /**
* What an entry that moves no money actually asks for, field by field, so the checker can read the * What an entry that moves no money actually asks for, field by field, so the checker can read the
@@ -453,7 +461,7 @@ public class DdsApprovalReadService {
final List<Map<String, Object>> fields = new ArrayList<>(); final List<Map<String, Object>> fields = new ArrayList<>();
for (final ProductField field : PRODUCT_FIELDS) { for (final ProductField field : PRODUCT_FIELDS) {
final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull(); final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull();
final String proposed = requested ? format(json.get(field.parameter()).getAsString(), field.format()) : null; final String proposed = requested ? display(json.get(field.parameter()).getAsString(), field.format()) : null;
final Map<String, Object> out = new LinkedHashMap<>(); final Map<String, Object> out = new LinkedHashMap<>();
out.put("key", field.parameter()); out.put("key", field.parameter());
out.put("label", field.label()); out.put("label", field.label());
@@ -461,7 +469,7 @@ public class DdsApprovalReadService {
out.put("format", field.format()); out.put("format", field.format());
if (compare) { if (compare) {
final Object stored = current.get(field.column()); final Object stored = current.get(field.column());
final String was = stored == null ? null : format(String.valueOf(stored), field.format()); final String was = stored == null ? null : display(String.valueOf(stored), field.format());
final String now = requested ? proposed : was; final String now = requested ? proposed : was;
out.put("current", was); out.put("current", was);
out.put("value", now); out.put("value", now);
@@ -476,6 +484,16 @@ public class DdsApprovalReadService {
return fields; return fields;
} }
/** As format, and a ledger account id read as the account itself: "DDS CUSTOMER DEPOSITS (200601)". */
private String display(final String value, final String format) {
if (!"gl".equals(format) || value == null || value.isBlank()) {
return format(value, format);
}
final Map<String, Object> gl = this.jdbcTemplate.queryForList("select gl_code, name from acc_gl_account where id = ?", // NOSONAR
parseLong(value)).stream().findFirst().orElse(Map.of());
return gl.isEmpty() ? "GL " + value.trim() + " (not found)" : gl.get("name") + " (" + gl.get("gl_code") + ")";
}
/** A stored or requested value as the screen shows it: Yes/No, 2%, 12 months. */ /** A stored or requested value as the screen shows it: Yes/No, 2%, 12 months. */
private static String format(final String value, final String format) { private static String format(final String value, final String format) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
@@ -560,13 +578,40 @@ public class DdsApprovalReadService {
} }
} }
case "DDSBATCH:POST" -> { case "DDSBATCH:POST" -> {
final String expense = first("select c.name from m_dds_batch_run r join c_configuration c on c.name = " // The expense side, on the accounts of the DDS products concerned - interest on each
+ "case when r.job_type like 'COMMISSION%' then 'dds-agent-commission-expense-account' " // customer's product's interest account, commission shared over the commission accounts
+ "else 'dds-interest-expense-account' end where r.id = ?", resourceId); // of the products each agent collected for, as the posting itself will book it.
final Map<String, Object> gl = expense == null ? Map.of() final String jobType = first("select job_type from m_dds_batch_run where id = ?", resourceId);
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from c_configuration c " // NOSONAR if (jobType != null && jobType.startsWith("COMMISSION")) {
+ "join acc_gl_account g on g.id = c.value where c.name = ?", expense).stream().findFirst().orElse(Map.of()); final Map<String, BigDecimal> byAccount = new LinkedHashMap<>();
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount)); final Map<String, String> names = new LinkedHashMap<>();
for (final Map<String, Object> runRow : this.jdbcTemplate.queryForList( // NOSONAR
"select agent_id, amount from m_dds_batch_run_row where batch_run_id = ? and amount > 0 order by id", resourceId)) {
try {
this.ddsLedger.commissionSplit(((Number) runRow.get("agent_id")).longValue(), (BigDecimal) runRow.get("amount"))
.forEach((account, share) -> {
byAccount.merge(account.getGlCode(), share, BigDecimal::add);
names.put(account.getGlCode(), account.getName());
});
} catch (final DdsLedgerNotConfiguredException e) {
byAccount.merge("", (BigDecimal) runRow.get("amount"), BigDecimal::add);
names.put("", "Agent Commission Expense not set on the DDS product");
}
}
byAccount.forEach((code, share) -> lines.add(line("DR", code.isEmpty() ? null : code, names.get(code), "GL", share)));
} else {
for (final Map<String, Object> gl : this.jdbcTemplate.queryForList( // NOSONAR
"select g.gl_code as \"code\", coalesce(g.name, 'Interest Expense not set on DDS product ' || "
+ "upper(trim(p.sub_type))) as \"name\", sum(r.amount) as \"amount\" from m_dds_batch_run_row r "
+ "join m_dds_party p on p.id = r.party_id "
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
+ "left join acc_gl_account g on g.id = t.interest_expense_gl_id "
+ "where r.batch_run_id = ? and r.amount > 0 group by g.gl_code, g.name, upper(trim(p.sub_type)) "
+ "order by g.gl_code",
resourceId)) {
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", (BigDecimal) gl.get("amount")));
}
}
for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR
"select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", " "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\", " + "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", "
@@ -29,6 +29,7 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
@@ -141,9 +142,13 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum " private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum "
+ "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?"; + "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?";
/** The product DDS opens customers' scheme accounts under - see 0492. */ /**
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration " * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
+ "where name = 'dds-deposit-product' and enabled = true"; * Read off the parties themselves rather than a configured product id: what makes an account a
* scheme account is that a scheme runs on it, whatever product it was opened under.
*/
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
private static final int SAVINGS_DEPOSIT_TYPE = 100; private static final int SAVINGS_DEPOSIT_TYPE = 100;
private static final int FIXED_DEPOSIT_TYPE = 200; private static final int FIXED_DEPOSIT_TYPE = 200;
@@ -773,8 +778,8 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
+ " Set an ordinary savings account for this agent"); + " Set an ordinary savings account for this agent");
} }
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) { if (schemes != null && schemes > 0) {
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo
+ " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their" + " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their"
+ " deposits. Set an ordinary savings account for this agent"); + " deposits. Set an ordinary savings account for this agent");
@@ -787,8 +792,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
"account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet"); "account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet");
} }
// Each product the agent collected for books its share on its own commission account; the
// collections the share was worked out on are then marked as paid for.
final Map<GLAccount, BigDecimal> shares = this.ddsLedger.commissionSplit(agent.getId(), amount);
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount, this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
"DDS commission, batch run #" + batchRun.getId()); "DDS commission, batch run #" + batchRun.getId(), shares);
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId());
} }
/** Fineract's savings statuses, named the way the screens name them. */ /** Fineract's savings statuses, named the way the screens name them. */
@@ -336,14 +336,19 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
} }
final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos); final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos);
BigDecimal pooled = BigDecimal.ZERO; // What the customers on DDS's own books are now owed, by the customer-deposits account of
// their product - two products may owe on two accounts.
final Map<Long, BigDecimal> pooled = new LinkedHashMap<>();
final Map<Long, GLAccount> depositAccounts = new LinkedHashMap<>();
for (final CollectionEntry entry : pending) { for (final CollectionEntry entry : pending) {
final Party party = collectableParty(entry.getParty().getPartyCode(), agent); final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
if (party.getSavingsAccountId() != null) { if (party.getSavingsAccountId() != null) {
this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description); this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description);
} else { } else {
pooled = pooled.add(entry.getAmount()); final GLAccount deposits = this.ddsLedger.depositLiabilityOf(party);
depositAccounts.putIfAbsent(deposits.getId(), deposits);
pooled.merge(deposits.getId(), entry.getAmount(), BigDecimal::add);
} }
// The customer's own statement records the day the money was collected, as it always has. // The customer's own statement records the day the money was collected, as it always has.
@@ -358,10 +363,17 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
this.collectionEntryRepository.save(entry); this.collectionEntryRepository.save(entry);
} }
// Everyone on DDS's own accounts, in one pair. // Everyone on DDS's own accounts, in one pair per customer-deposits account, all under one journal id.
final String journalId = "DDSR-A" + applicationId; final String journalId = "DDSR-A" + applicationId;
if (pooled.signum() > 0) { boolean journalled = false;
this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, pooled, receiptAccount, journalId, description); for (final Map.Entry<Long, BigDecimal> owed : pooled.entrySet()) {
if (owed.getValue().signum() > 0) {
this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, owed.getValue(), receiptAccount,
depositAccounts.get(owed.getKey()), journalId, description);
journalled = true;
}
}
if (journalled) {
this.jdbcTemplate.update(SET_JOURNAL_SQL, journalId, applicationId); // NOSONAR this.jdbcTemplate.update(SET_JOURNAL_SQL, journalId, applicationId); // NOSONAR
} }
@@ -326,8 +326,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
+ "p.name as \"productName\", a.account_balance_derived as balance, 'SAVINGS' as \"accountKind\" from m_savings_account a " + "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 " + "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 " + "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 " + "and not exists (select 1 from m_dds_party dp where dp.savings_account_id = a.id) "
+ "where name = 'dds-deposit-product' and enabled = true) "
+ "union all " + "union all "
+ "select c.id as id, c.account_no as \"accountNo\", cp.name as \"productName\", c.account_balance_derived as balance, " + "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 " + "'CURRENT' as \"accountKind\" from m_current_account c join m_current_product cp on cp.id = c.product_id "
@@ -79,7 +79,6 @@ import org.apache.fineract.portfolio.dds.party.exception.PartySubTypeNotActiveEx
import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer; import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction; import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.portfolio.savings.data.SavingsAccountDataDTO;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService; import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
@@ -678,9 +677,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// Sending one maturity into another moves money between two collection accounts instead of // Sending one maturity into another moves money between two collection accounts instead of
// handing it back, and if that scheme has already closed the money is stranded in an account // handing it back, and if that scheme has already closed the money is stranded in an account
// nobody can operate. The same refusal an agent's commission account gets. // nobody can operate. The same refusal an agent's commission account gets.
final Long productId = ((Number) account.get("product_id")).longValue(); final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR if (schemes != null && schemes > 0) {
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
throw new PartyCreditAccountUnusableException(accountNo, "it is a DDS scheme account, not an account to be paid into"); throw new PartyCreditAccountUnusableException(accountNo, "it is a DDS scheme account, not an account to be paid into");
} }
@@ -705,9 +703,13 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
} }
} }
/** Set by 0492. Names the savings product a DDS customer's real account is opened under. */ /**
private static final String DEPOSIT_PRODUCT_SQL = "select value from c_configuration " * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
+ "where name = 'dds-deposit-product' and enabled = true"; * Read off the parties themselves rather than a configured product id: what makes an account a
* scheme account is that a scheme runs on it, whatever product it was opened under.
*/
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
/** /**
* Opens the customer's real deposit account, unless one was named and linked above. * Opens the customer's real deposit account, unless one was named and linked above.
@@ -724,24 +726,12 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
return; return;
} }
final List<Long> productIds = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR // DDS keeps its customers' accounts itself (0514), with each DDS product's own ledger accounts
if (productIds.isEmpty() || productIds.get(0) == null) { // behind them, and no savings product is named for DDS any more - so there is no savings
// account to open. A party's balance is already on the books through its product.
throw new DdsDepositProductNotConfiguredException(); throw new DdsDepositProductNotConfiguredException();
} }
final SavingsAccountDataDTO accountDetails = new SavingsAccountDataDTO(client, null, productIds.get(0), party.getOpeningDate(),
this.context.authenticatedUser(), DateUtils.DEFAULT_DATE_FORMATTER);
final CommandProcessingResult result = this.savingsApplicationProcessWritePlatformService.createActiveApplication(accountDetails);
final Long savingsAccountId = result.getSavingsId() != null ? result.getSavingsId() : result.getResourceId();
party.linkSavingsAccount(savingsAccountId);
// Withdrawals are blocked from the moment the account exists. A scheme account is a savings
// account like any other, and left open a customer could empty a twelve-month scheme at the
// counter the next day - which is the one thing daily deposit saving exists to prevent.
// Deposits are untouched; only the way out is closed, and DDS lifts it for its own payouts.
this.ddsLedger.lockDebits(savingsAccountId);
}
/** /**
* The branch an account belongs to, by its id. * The branch an account belongs to, by its id.
* *
@@ -53,7 +53,6 @@ import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode; import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction; import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository; import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.apache.fineract.portfolio.dds.transaction.exception.InsufficientAgentBalanceException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException; 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.PartyArrearsDaysExceededException;
import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException; import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException;
@@ -276,29 +275,13 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
// receipt tagged DDS into the agent's own GL account - and applied to the customers from Agent // 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 // 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. // 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", 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 " "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() + "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent != null && agent.getGlAccount() != null
? agent.getGlAccount().getGlCode()
: "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > " : "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > "
+ "Apply DDS Receipt."); + "Apply DDS Receipt.");
} }
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount");
if (agent.getAgentCurrentBalance().compareTo(amount) < 0) {
throw new InsufficientAgentBalanceException(agent.getAgentCode());
}
final BigDecimal newBalance = agent.settle(amount);
// The cash reaches the branch. No customer is involved - they were credited when their money
// was collected; this is the bank catching up with where the notes actually are.
this.ddsLedger.settle(agent.getOffice(), transactionDate, amount, "DDS settlement by " + agent.getAgentCode(), agent.getGlAccount());
return new PartyTransaction(null, agent, transactionDate, "AGENT_TRAN", "DEBIT",
DdsTransactionMode.canonicalOrOriginal(command.stringValueOfParameterNamedAllowingNull("transactionMode")),
command.stringValueOfParameterNamedAllowingNull("accountType"),
command.stringValueOfParameterNamedAllowingNull("accountNo"), amount, null, null,
command.stringValueOfParameterNamedAllowingNull("description"), newBalance);
}
private List<PartyTransaction> postWithdrawDeposite(final Agent agent, final LocalDate transactionDate, private List<PartyTransaction> postWithdrawDeposite(final Agent agent, final LocalDate transactionDate,
final JsonCommand command) { final JsonCommand command) {
@@ -191,4 +191,6 @@
<include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/> <include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
<include file="parts/0618_drop_dds_account_type_post_day.xml" relativeToChangelogFile="true"/> <include file="parts/0618_drop_dds_account_type_post_day.xml" relativeToChangelogFile="true"/>
<include file="parts/0619_dds_gl_six_digit_codes_and_agent_gl.xml" relativeToChangelogFile="true"/> <include file="parts/0619_dds_gl_six_digit_codes_and_agent_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0620_dds_product_gl_mapping.xml" relativeToChangelogFile="true"/>
<include file="parts/0621_dds_product_drop_unused_gl.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,135 @@
<?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 product names its own ledger accounts on its Accounting tab, the way a savings product
does, and DDS posts through those alone - the bank-wide dds-* account settings stop being read.
Six accounts are the product's: what the bank owes the product's customers (liability), what
their interest costs it (expense), what the agents' commission on collecting for it costs it
(expense), where a collection is held when the collecting agent has no ledger account of their
own (asset or liability), where a matured balance is parked when no account is named to pay it
into (liability), and the current-account control a payout into a current account is credited
to when the current product's own mapping is ambiguous (liability).
Existing products are filled from the settings they were posting through until now, so the first
posting after this lands exactly where the last one before it did.
-->
<changeSet id="0620_dds_product_gl_columns" author="CBS">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_dds_account_type" columnName="deposit_liability_gl_id"/>
</not>
</preConditions>
<addColumn tableName="m_dds_account_type">
<column name="deposit_liability_gl_id" type="BIGINT"/>
<column name="interest_expense_gl_id" type="BIGINT"/>
<column name="commission_expense_gl_id" type="BIGINT"/>
<column name="agent_cash_gl_id" type="BIGINT"/>
<column name="matured_payable_gl_id" type="BIGINT"/>
<column name="current_control_gl_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="deposit_liability_gl_id"
constraintName="fk_dds_account_type_deposit_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="interest_expense_gl_id"
constraintName="fk_dds_account_type_interest_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="commission_expense_gl_id"
constraintName="fk_dds_account_type_commission_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="agent_cash_gl_id"
constraintName="fk_dds_account_type_agent_cash_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="matured_payable_gl_id"
constraintName="fk_dds_account_type_matured_payable_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="current_control_gl_id"
constraintName="fk_dds_account_type_current_control_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
<changeSet id="0620_dds_product_gl_backfill" author="CBS">
<sql>
update m_dds_account_type
set deposit_liability_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
where c.name = 'dds-deposit-liability-account')
where deposit_liability_gl_id is null;
update m_dds_account_type
set interest_expense_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
where c.name = 'dds-interest-expense-account')
where interest_expense_gl_id is null;
update m_dds_account_type
set commission_expense_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
where c.name = 'dds-agent-commission-expense-account')
where commission_expense_gl_id is null;
update m_dds_account_type
set agent_cash_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
where c.name = 'dds-agent-cash-account')
where agent_cash_gl_id is null;
update m_dds_account_type
set matured_payable_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
where c.name = 'dds-matured-payable-account')
where matured_payable_gl_id is null;
update m_dds_account_type
set current_control_gl_id = (select g.id from c_configuration c join acc_gl_account g on g.id = c.value
where c.name = 'dds-current-account-control-account')
where current_control_gl_id is null;
</sql>
</changeSet>
<!--
Commission is paid to an agent in one sum for collections that may span several products, and
each product now books its share on its own commission account. The share is worked out from the
collections the paid commission was earned on - those a commission run has counted and no payment
has yet covered - so a collection is marked with the run that paid for it.
-->
<changeSet id="0620_dds_party_transaction_commission_paid_run" author="CBS">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_dds_party_transaction" columnName="commission_paid_batch_run_id"/>
</not>
</preConditions>
<addColumn tableName="m_dds_party_transaction">
<column name="commission_paid_batch_run_id" type="BIGINT"/>
</addColumn>
</changeSet>
<!--
The DDS settings on Global Configurations, removed. Every ledger account DDS posts through is now
named on the DDS product itself, and copied there above before these go. dds-deposit-product named
the savings product DDS customers' accounts were opened under before DDS kept its own (0514);
nothing opens one now, and a scheme account is recognised by the DDS party linked to it.
-->
<changeSet id="0620_dds_remove_configuration_settings" author="CBS">
<sql>
delete from c_configuration
where name in ('dds-deposit-liability-account', 'dds-interest-expense-account', 'dds-agent-commission-expense-account',
'dds-agent-cash-account', 'dds-matured-payable-account', 'dds-current-account-control-account',
'dds-deposit-product');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,60 @@
<?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">
<!--
Three of the accounts 0620 put on the DDS product are never posted to, so they come off it:
agent_cash_gl_id - an agent cannot be saved without a ledger account of their own, so a
collection always lands on the agent's account and never on this one.
matured_payable_gl_id - the payout and premature-closure screens both require the clerk to pick
the Sundry account a balance is parked in, so there is never a default to use.
current_control_gl_id - a payout into a current account is credited to that current product's
own control account, from the current product's own accounting.
-->
<changeSet id="0621_dds_product_drop_agent_cash_gl" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_dds_account_type" columnName="agent_cash_gl_id"/>
</preConditions>
<dropForeignKeyConstraint baseTableName="m_dds_account_type" constraintName="fk_dds_account_type_agent_cash_gl"/>
<dropColumn tableName="m_dds_account_type" columnName="agent_cash_gl_id"/>
</changeSet>
<changeSet id="0621_dds_product_drop_matured_payable_gl" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_dds_account_type" columnName="matured_payable_gl_id"/>
</preConditions>
<dropForeignKeyConstraint baseTableName="m_dds_account_type" constraintName="fk_dds_account_type_matured_payable_gl"/>
<dropColumn tableName="m_dds_account_type" columnName="matured_payable_gl_id"/>
</changeSet>
<changeSet id="0621_dds_product_drop_current_control_gl" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_dds_account_type" columnName="current_control_gl_id"/>
</preConditions>
<dropForeignKeyConstraint baseTableName="m_dds_account_type" constraintName="fk_dds_account_type_current_control_gl"/>
<dropColumn tableName="m_dds_account_type" columnName="current_control_gl_id"/>
</changeSet>
</databaseChangeLog>