diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java index e9b80080a..07012545c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java @@ -55,6 +55,16 @@ public final class DdsAccountTypeData implements Serializable { /** The scheme's annual interest rate, which an account opened on it takes. */ private final BigDecimal interestRate; 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 lastModifiedDate; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java index 6b636e3ea..0c47f1e3b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java @@ -115,6 +115,24 @@ public class DdsAccountType extends AbstractAuditableCustom { @Column(name = "is_active", nullable = false) 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() { // } @@ -131,6 +149,9 @@ public class DdsAccountType extends AbstractAuditableCustom { this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION); this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY); 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. this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active"); } @@ -238,6 +259,23 @@ public class DdsAccountType extends AbstractAuditableCustom { 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)) { final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active"); actualChanges.put("active", newValue); diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformServiceJpaRepositoryImpl.java index cf0caa81a..44b2fb9c0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/agent/service/AgentWritePlatformServiceJpaRepositoryImpl.java @@ -303,9 +303,13 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla + "join m_savings_product p on p.id = a.product_id " + "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 " - + "where name = 'dds-deposit-product' and enabled = true"; + /** + * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to. + * 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; @@ -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 // 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. - final Long productId = ((Number) account.get("product_id")).longValue(); - final List ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR - if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) { + final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR + if (schemes != null && schemes > 0) { throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme"); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java index 1383990c9..c6ec15c6d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java @@ -19,9 +19,13 @@ package org.apache.fineract.portfolio.dds.accounting; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.glaccount.domain.GLAccount; @@ -83,35 +87,16 @@ import org.springframework.stereotype.Component; @RequiredArgsConstructor 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 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 + * sum of customer or agent accounts - a DDS product's customer deposits account, a savings or + * 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. */ 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 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) " + + "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) " + "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. */ 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)); } @@ -183,17 +167,6 @@ public class DdsLedger { 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. * @@ -207,11 +180,13 @@ public class DdsLedger { * DR the account the receipts credited the agent's account clears * CR DDS Customer Deposits what the bank now owes the customers * + * + * 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, - final String transactionId, final String description) { - writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description, - null, null); + final GLAccount depositAccount, final String transactionId, final String description) { + writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, depositAccount, description, null, null); } /** @@ -242,7 +217,7 @@ public class DdsLedger { collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount); 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. */ @@ -251,7 +226,7 @@ public class DdsLedger { postInterest(party.getSavingsAccountId(), date, amount, description); 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)}. */ @@ -260,7 +235,7 @@ public class DdsLedger { reverseInterest(party.getSavingsAccountId(), date, amount, description); 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 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); office = account.office(); } else { - owed = configured(DEPOSIT_LIABILITY); + owed = depositLiabilityOf(party); 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 " + " 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> 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 } - /** - * 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. - */ + /** The current account's control account, from the current product's own accounting. */ private GLAccount currentAccountControl(final Long currentAccountId) { final List 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 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)"); + : "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. */ @@ -374,7 +339,7 @@ public class DdsLedger { payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn); 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. */ @@ -423,15 +388,22 @@ public class DdsLedger { this.savingsAccountAssembler.getPivotConfigStatus()); relockDebits(account, wasBlocked); 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) { final SavingsAccount account = load(to.getSavingsAccountId()); 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 { - // 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); + 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("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 - * when they have none. - * - * 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. + * The account playing the agent's part on an entry - the agent's own. An agent cannot be saved + * without one, so a missing one here is an agent set up before that was required. */ 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 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 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 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 commissionSplit(final Long agentId, final BigDecimal amount) { + List> 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 accounts = new LinkedHashMap<>(); + final Map volumes = new LinkedHashMap<>(); + BigDecimal total = BigDecimal.ZERO; + for (final Map 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 shares = new LinkedHashMap<>(); + BigDecimal allotted = BigDecimal.ZERO; + final List 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; } - /** 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, - final String description) { + final String description, final Map shares) { final SavingsAccount account = load(savingsAccountId); 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 share : shares.entrySet()) { + postPair(account, deposit, date, share.getValue(), share.getKey(), control, description); + } 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 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) { 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 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. */ private String organisationCurrency() { final List codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR @@ -717,14 +790,6 @@ public class DdsLedger { 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 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. */ private GLAccount productAccount(final Long productId, final int financialAccountType) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java index daef9db89..0f70081a7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -356,7 +357,7 @@ public class DdsVoucherService { return new ArrayList<>(); } - final Long depositGlId = depositLiabilityGlId(); + final Set depositGlIds = depositLiabilityGlIds(); final Map> parties = new LinkedHashMap<>(); for (final Map leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR transactionIds.toArray())) { @@ -383,7 +384,7 @@ public class DdsVoucherService { } 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 Map party = parties.computeIfAbsent(partyCode, code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR @@ -417,11 +418,13 @@ public class DdsVoucherService { 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 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); + /** + * The GLs that carry what DDS customers are owed - a leg on one is the customer's own. Each DDS + * product names its own on its Accounting tab (0620). + */ + private Set 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 ids) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java index 4c4444f4f..891da538e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java @@ -47,6 +47,7 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer { private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode", "accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active", "prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate", + "depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId", "dateFormat", "locale")); /** 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") .value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank() .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. v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element)) .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); @@ -104,6 +113,14 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer { .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); throwExceptionIfValidationWarningsExist(dataValidationErrors); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java index a9e7467f5..a4edf5f31 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java @@ -50,8 +50,17 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead + " 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"; + + " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate, " + + " 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 @@ -69,6 +78,15 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead .prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) // .interestRate(rs.getBigDecimal("interestRate")) // .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")) // .lastModifiedDate(localDateTime(rs, "lastModifiedDate")) // .build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java index e6308f130..e6306bd71 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.dds.accounttype.service; import jakarta.persistence.PersistenceException; +import java.util.ArrayList; import java.util.List; import java.util.Map; 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.party.domain.Party; 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.jdbc.core.JdbcTemplate; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +50,15 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer; private final DdsAccountTypeRepository ddsAccountTypeRepository; 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 @Override @@ -53,6 +66,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA try { this.fromApiJsonDeserializer.validateForCreate(command.json()); + validateLedgerAccounts(command); final DdsAccountType accountType = DdsAccountType.fromJson(command); this.ddsAccountTypeRepository.saveAndFlush(accountType); @@ -77,6 +91,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA try { this.fromApiJsonDeserializer.validateForUpdate(command.json()); + validateLedgerAccounts(command); final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(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 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 kinds, + final List errors) { + final Long glId = command.parameterExists(parameter) ? command.longValueOfParameterNamed(parameter) : null; + if (glId == null) { + return; + } + final List> 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 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) { if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) { throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/starter/DdsAccountTypeConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/starter/DdsAccountTypeConfiguration.java index 68623db36..8d22f2c54 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/starter/DdsAccountTypeConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/starter/DdsAccountTypeConfiguration.java @@ -43,7 +43,8 @@ public class DdsAccountTypeConfiguration { @ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class) public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService( DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository, - PartyRepository partyRepository) { - return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository); + PartyRepository partyRepository, JdbcTemplate jdbcTemplate) { + return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository, + jdbcTemplate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java index 745f45b42..66b6f6075 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java @@ -30,6 +30,8 @@ import java.util.Objects; import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; 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.stereotype.Component; @@ -64,6 +66,7 @@ public class DdsApprovalReadService { private static final String REF_KEY_PREFIX = "DDS_COMMAND:"; private final JdbcTemplate jdbcTemplate; + private final DdsLedger ddsLedger; public List> list(final String status, final String fromDate, final String toDate) { 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_INTEREST = "Interest"; private static final String GROUP_CLOSURE = "Maturity & Premature Closure"; + private static final String GROUP_ACCOUNTING = "Accounting"; private static final List PRODUCT_FIELDS = List.of( // 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", GROUP_CLOSURE), // 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 @@ -453,7 +461,7 @@ public class DdsApprovalReadService { final List> fields = new ArrayList<>(); for (final ProductField field : PRODUCT_FIELDS) { 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 out = new LinkedHashMap<>(); out.put("key", field.parameter()); out.put("label", field.label()); @@ -461,7 +469,7 @@ public class DdsApprovalReadService { out.put("format", field.format()); if (compare) { 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; out.put("current", was); out.put("value", now); @@ -476,6 +484,16 @@ public class DdsApprovalReadService { 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 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. */ private static String format(final String value, final String format) { if (value == null || value.isBlank()) { @@ -560,13 +578,40 @@ public class DdsApprovalReadService { } } 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 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)); + // The expense side, on the accounts of the DDS products concerned - interest on each + // customer's product's interest account, commission shared over the commission accounts + // of the products each agent collected for, as the posting itself will book it. + final String jobType = first("select job_type from m_dds_batch_run where id = ?", resourceId); + if (jobType != null && jobType.startsWith("COMMISSION")) { + final Map byAccount = new LinkedHashMap<>(); + final Map names = new LinkedHashMap<>(); + for (final Map 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 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 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\", " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java index d203003c2..95197ac1e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java @@ -29,6 +29,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; 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.exception.PlatformDataIntegrityException; 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 " + "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 " - + "where name = 'dds-deposit-product' and enabled = true"; + /** + * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to. + * 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 FIXED_DEPOSIT_TYPE = 200; @@ -773,8 +778,8 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr + " Set an ordinary savings account for this agent"); } - final List ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR - if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) { + final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR + if (schemes != null && schemes > 0) { 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" + " 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"); } + // 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 shares = this.ddsLedger.commissionSplit(agent.getId(), 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. */ diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java index 7ac74af18..54c51b9a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java @@ -336,14 +336,19 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col } 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 pooled = new LinkedHashMap<>(); + final Map depositAccounts = new LinkedHashMap<>(); 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()); + 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. @@ -358,10 +363,17 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col 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; - if (pooled.signum() > 0) { - this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, pooled, receiptAccount, journalId, description); + boolean journalled = false; + for (final Map.Entry 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 } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java index f34c495d3..56c4bf421 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java @@ -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 " + "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) " + + "and not exists (select 1 from m_dds_party dp where dp.savings_account_id = a.id) " + "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 " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java index 0a425cfa3..26950bc72 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java @@ -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.transaction.domain.PartyTransaction; 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.SavingsAccountRepository; 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 // 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. - final Long productId = ((Number) account.get("product_id")).longValue(); - final List ddsProduct = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR - if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) { + final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR + if (schemes != null && schemes > 0) { 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 " - + "where name = 'dds-deposit-product' and enabled = true"; + /** + * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to. + * 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. @@ -724,22 +726,10 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla return; } - final List productIds = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR - if (productIds.isEmpty() || productIds.get(0) == null) { - 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); + // DDS keeps its customers' accounts itself (0514), with each DDS product's own ledger accounts + // 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(); } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformServiceJpaRepositoryImpl.java index 11d81deb3..a39e93e78 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionWritePlatformServiceJpaRepositoryImpl.java @@ -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.PartyTransaction; 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.PartyArrearsDaysExceededException; import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException; @@ -276,28 +275,12 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa // 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()); - } - 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); + 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 != null && agent.getGlAccount() != null + ? agent.getGlAccount().getGlCode() + : "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > " + + "Apply DDS Receipt."); } private List postWithdrawDeposite(final Agent agent, final LocalDate transactionDate, diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 495d7895d..683d2f3d7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -191,4 +191,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0620_dds_product_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0620_dds_product_gl_mapping.xml new file mode 100644 index 000000000..352476487 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0620_dds_product_gl_mapping.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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; + + + + + + + + + + + + + + + + + + + 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'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0621_dds_product_drop_unused_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0621_dds_product_drop_unused_gl.xml new file mode 100644 index 000000000..26a857912 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0621_dds_product_drop_unused_gl.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +