done
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
This commit is contained in:
+64
-1
@@ -24,6 +24,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -104,6 +105,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor
|
|||||||
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.dao.NonTransientDataAccessException;
|
import org.springframework.dao.NonTransientDataAccessException;
|
||||||
@@ -136,6 +139,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
||||||
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
||||||
private final LoanTransactionRepository loanTransactionRepository;
|
private final LoanTransactionRepository loanTransactionRepository;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -232,14 +236,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raise the voucher from the entries that were actually written, rather than from the
|
||||||
|
// command: an accounting rule can supply a side the request never named, so reading
|
||||||
|
// back by transaction id is the only view that matches what the ledger now holds.
|
||||||
|
final String voucherNo = raiseJournalVoucher(transactionId);
|
||||||
|
if (voucherNo != null) {
|
||||||
|
changes.put("voucherNo", voucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
||||||
.withTransactionId(transactionId).build();
|
.withTransactionId(transactionId).with(changes).build();
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
final Throwable throwable = dve.getMostSpecificCause();
|
final Throwable throwable = dve.getMostSpecificCause();
|
||||||
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a manual journal in the voucher register and returns its number.
|
||||||
|
*
|
||||||
|
* The sides are read back from the entries just written rather than taken from the request,
|
||||||
|
* because an accounting rule can contribute a debit or credit the caller never named; only the
|
||||||
|
* persisted rows show what the ledger actually holds.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the journal. The entries are already posted and correct, and
|
||||||
|
* refusing them because the register could not be written would reject a sound accounting
|
||||||
|
* transaction over its paperwork. The problem is logged and the journal stands without a
|
||||||
|
* voucher, which reads as a gap in the register rather than as a missing ledger entry.
|
||||||
|
*/
|
||||||
|
private String raiseJournalVoucher(final String transactionId) {
|
||||||
|
// A register that posts its ledger movement through this command raises its own voucher,
|
||||||
|
// naming the member and the instrument. Raising a journal one beside it would put the same
|
||||||
|
// money in the register twice.
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<JournalEntry> entries = this.glJournalEntryRepository
|
||||||
|
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||||
|
if (CollectionUtils.isEmpty(entries)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
for (final JournalEntry entry : entries) {
|
||||||
|
final GLAccount account = entry.getGlAccount();
|
||||||
|
final Long accountId = account == null ? null : account.getId();
|
||||||
|
final String name = account == null ? null : account.getName();
|
||||||
|
final String code = account == null ? null : account.getGlCode();
|
||||||
|
|
||||||
|
final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount())
|
||||||
|
: VoucherLine.creditGl(accountId, name, code, entry.getAmount());
|
||||||
|
lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debits first, so the voucher prints the way a slip is read.
|
||||||
|
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null,
|
||||||
|
transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for journal transaction {}", transactionId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
||||||
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
||||||
/**
|
/**
|
||||||
|
|||||||
+4
-2
@@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
|
|||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
||||||
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
||||||
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
||||||
LoanTransactionRepository loanTransactionRepository) {
|
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
|
||||||
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
||||||
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
||||||
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
||||||
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
||||||
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
||||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
|
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
|
||||||
|
voucherService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-32
@@ -41,19 +41,21 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much profit a share dividend may actually be declared out of, for a given period.
|
* How much profit is available to fund a share dividend, for a given period.
|
||||||
*
|
*
|
||||||
* A co-operative society may not distribute raw net profit: the Co-operative Societies Act requires a Statutory Reserve
|
* This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has
|
||||||
* Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared
|
* to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared
|
||||||
* for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one
|
* for another share product over the same period, since they all draw on the same balance.
|
||||||
* place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
|
|
||||||
*
|
*
|
||||||
* Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration
|
* The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution
|
||||||
* row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error.
|
* screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a
|
||||||
|
* second time would hold back money that has already left the account.
|
||||||
|
*
|
||||||
|
* Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
|
||||||
*/
|
*/
|
||||||
@Path("/v1/accounting/distributable-profit")
|
@Path("/v1/accounting/distributable-profit")
|
||||||
@Component
|
@Component
|
||||||
@Tag(name = "Distributable Profit", description = "Net profit reduced by statutory appropriations and dividends already declared, giving the ceiling a new share dividend may be declared within.")
|
@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class DistributableProfitApiResource {
|
public class DistributableProfitApiResource {
|
||||||
|
|
||||||
@@ -66,8 +68,6 @@ public class DistributableProfitApiResource {
|
|||||||
*/
|
*/
|
||||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||||
|
|
||||||
private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage";
|
|
||||||
private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage";
|
|
||||||
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
@@ -103,19 +103,14 @@ public class DistributableProfitApiResource {
|
|||||||
final boolean yearClosed = closedProfit != null;
|
final boolean yearClosed = closedProfit != null;
|
||||||
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
|
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
|
||||||
|
|
||||||
final BigDecimal statutoryReservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE);
|
|
||||||
final BigDecimal educationFundPercentage = configuredPercentage(CONFIG_EDUCATION_FUND);
|
|
||||||
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
|
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
|
||||||
|
|
||||||
// Appropriations only ever reduce a profit. A loss-making period has nothing to appropriate
|
// The statutory reserve and education fund are NOT deducted here. They are appropriated on
|
||||||
// and nothing to distribute, so the whole waterfall collapses to zero rather than producing
|
// the profit-distribution screen before any dividend is declared, so the profit this reads
|
||||||
// a negative "reserve" that would read as money owed back to the society.
|
// has already had them taken out — subtracting them again would hold back money that is no
|
||||||
final boolean profitable = netProfit.signum() > 0;
|
// longer in the account. A loss-making period funds nothing, so it collapses to zero rather
|
||||||
final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO;
|
// than a negative figure a caller could read as a credit.
|
||||||
final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO;
|
final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO;
|
||||||
|
|
||||||
final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount)
|
|
||||||
: BigDecimal.ZERO;
|
|
||||||
|
|
||||||
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
|
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
|
||||||
BigDecimal alreadyDeclared = BigDecimal.ZERO;
|
BigDecimal alreadyDeclared = BigDecimal.ZERO;
|
||||||
@@ -137,10 +132,6 @@ public class DistributableProfitApiResource {
|
|||||||
// that is still moving — rather than showing a bare number the officer has to interpret.
|
// that is still moving — rather than showing a bare number the officer has to interpret.
|
||||||
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
|
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
|
||||||
response.put("netProfit", netProfit);
|
response.put("netProfit", netProfit);
|
||||||
response.put("statutoryReservePercentage", statutoryReservePercentage);
|
|
||||||
response.put("statutoryReserveAmount", statutoryReserveAmount);
|
|
||||||
response.put("educationFundPercentage", educationFundPercentage);
|
|
||||||
response.put("educationFundAmount", educationFundAmount);
|
|
||||||
response.put("distributableProfit", distributableProfit);
|
response.put("distributableProfit", distributableProfit);
|
||||||
response.put("alreadyDeclared", alreadyDeclared);
|
response.put("alreadyDeclared", alreadyDeclared);
|
||||||
response.put("availableForDeclaration", availableForDeclaration);
|
response.put("availableForDeclaration", availableForDeclaration);
|
||||||
@@ -217,9 +208,8 @@ public class DistributableProfitApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every dividend already declared against a period overlapping this one, across ALL share products — the
|
* Every dividend already declared against a period overlapping this one, across ALL share products — the profit is
|
||||||
* appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable
|
* society-wide, so a second product's declaration competes for the same balance as the first.
|
||||||
* profit as the first.
|
|
||||||
*
|
*
|
||||||
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
|
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
|
||||||
* declaration is separately blocked outright.
|
* declaration is separately blocked outright.
|
||||||
@@ -267,10 +257,6 @@ public class DistributableProfitApiResource {
|
|||||||
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
|
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) {
|
|
||||||
return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
|
|
||||||
}
|
|
||||||
|
|
||||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
return new WebApplicationException(
|
return new WebApplicationException(
|
||||||
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
|||||||
+71
-1
@@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -70,6 +73,10 @@ import com.google.common.base.Splitter;
|
|||||||
@lombok.extern.slf4j.Slf4j
|
@lombok.extern.slf4j.Slf4j
|
||||||
public class CashierDepositRequestApiResource {
|
public class CashierDepositRequestApiResource {
|
||||||
|
|
||||||
|
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
|
||||||
|
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -421,6 +428,10 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sides of the slip, recorded now rather than at submission: which accounts the money
|
||||||
|
// actually moved between is only settled once the request is approved and posted.
|
||||||
|
addDepositVoucherLines(record, savingsAccountId, amount);
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
);
|
);
|
||||||
@@ -555,6 +566,62 @@ public class CashierDepositRequestApiResource {
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the two sides of an approved deposit on its voucher.
|
||||||
|
*
|
||||||
|
* A deposit is money arriving at the counter, so cash is debited and whatever receives it is
|
||||||
|
* credited — a member's savings account, or a GL account when the deposit was raised against
|
||||||
|
* one. The credited side is read from the request, because that is what the cashier actually
|
||||||
|
* filled in and what the member signed for.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the approval. The deposit is already posted and the member's
|
||||||
|
* balance already raised; refusing that over its slip would take a correct transaction back for
|
||||||
|
* a paperwork reason. The problem is logged, and the voucher keeps its number without sides.
|
||||||
|
*/
|
||||||
|
private void addDepositVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
||||||
|
final String voucherNo = (String) record.get("voucher_no");
|
||||||
|
if (voucherNo == null || voucherNo.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
||||||
|
final String accountNo = (String) record.get("account_no");
|
||||||
|
|
||||||
|
// Cash reaching the counter. For a GL deposit the contra is the cash/vault account the
|
||||||
|
// request named; for a member deposit it is the cashier's own till, which the deposit
|
||||||
|
// register does not carry a GL id for — the line still names it, without an id.
|
||||||
|
final Object contraObj = record.get("contra_gl_id");
|
||||||
|
final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null;
|
||||||
|
final Map<String, Object> contra = glAccountForVoucher(contraGlId);
|
||||||
|
final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"),
|
||||||
|
(String) contra.get("gl_code"), amount);
|
||||||
|
|
||||||
|
final VoucherLine credit;
|
||||||
|
if (isGl) {
|
||||||
|
final Object glObj = record.get("gl_account_id");
|
||||||
|
final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null;
|
||||||
|
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
|
||||||
|
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
|
||||||
|
} else {
|
||||||
|
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
voucherService.addLines(voucherNo, List.of(debit, credit));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
||||||
|
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||||
|
if (glAccountId == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||||
|
.orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||||
@@ -748,7 +815,10 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
// The deposit register raises its own voucher, naming the member and their account; the
|
||||||
|
// journal command must not raise a second one over the same money.
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||||
return result.getTransactionId() != null ? result.getTransactionId() : null;
|
return result.getTransactionId() != null ? result.getTransactionId() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ public class ShareAdvanceCollectionApiResource {
|
|||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
// GET — One unapplied receipt by the voucher number quoted by the member
|
// GET — One unapplied receipt by the voucher number quoted by the member
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
// The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001:
|
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
|
||||||
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
|
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
|
||||||
// firewall with a 400 before the request reaches this resource. The older
|
// firewall with a 400 before the request reaches this resource. The older
|
||||||
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
|
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
|
||||||
|
|||||||
+55
-1
@@ -35,6 +35,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||||
import org.apache.fineract.organisation.office.domain.Office;
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||||
@@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
||||||
private final SavingsHelper savingsHelper;
|
private final SavingsHelper savingsHelper;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
|
||||||
|
/** A cheque book issuance charge, keyed by its m_cheque_book id. */
|
||||||
|
private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE";
|
||||||
|
|
||||||
|
|
||||||
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
||||||
private FacilityChargeMapping resolveMapping() {
|
private FacilityChargeMapping resolveMapping() {
|
||||||
@@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
||||||
accountId);
|
accountId);
|
||||||
|
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
||||||
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
||||||
chequeBookId);
|
chequeBookId);
|
||||||
}
|
}
|
||||||
@@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
this.currentAccountRepository.saveAndFlush(account);
|
this.currentAccountRepository.saveAndFlush(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a cheque book issuance charge in the voucher register.
|
||||||
|
*
|
||||||
|
* The account is debited and the charge's income account credited — the society's fee, taken
|
||||||
|
* from the customer. The debit names the customer and their account number even though it posts
|
||||||
|
* against the product's control GL, so the slip reads as the customer's own.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the charge. The money is already taken and the ledger already
|
||||||
|
* posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The
|
||||||
|
* problem is logged and the charge stands.
|
||||||
|
*/
|
||||||
|
private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId,
|
||||||
|
final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) {
|
||||||
|
try {
|
||||||
|
final Map<String, Object> holder = accountHolderForVoucher(accountType, accountId);
|
||||||
|
final Map<String, Object> incomeGl = this.voucherService.glAccount(incomeGlId);
|
||||||
|
final String narration = "Cheque book issuance charge - book #" + chequeBookId;
|
||||||
|
|
||||||
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId,
|
||||||
|
this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT,
|
||||||
|
List.of(VoucherLine
|
||||||
|
.debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount)
|
||||||
|
.withDescription(narration),
|
||||||
|
VoucherLine
|
||||||
|
.creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount)
|
||||||
|
.withDescription(narration)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customer and account number a charge is taken from, for printing on a voucher. */
|
||||||
|
private Map<String, Object> accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) {
|
||||||
|
final String sql = accountType == ChequeAccountType.SAVINGS
|
||||||
|
? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
|
||||||
|
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"
|
||||||
|
: "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
|
||||||
|
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
|
||||||
|
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
||||||
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
|
||||||
@@ -320,7 +371,10 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
// The charge raises its own voucher, naming the account charged and the fee; the journal
|
||||||
|
// command must not raise a second one over the same money.
|
||||||
|
final CommandProcessingResult result = this.voucherService
|
||||||
|
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+94
-1
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
@@ -32,6 +33,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -49,6 +53,11 @@ public class ClearingJournalService {
|
|||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
|
||||||
|
/** A clearing posting — a CTS item, a NACH movement, a settlement — keyed by its GL transaction id. */
|
||||||
|
private static final String REF_CLEARING_POSTING = "CLEARING_POSTING";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
|
* Posts a single-debit / single-credit journal entry and returns the resulting GL transaction id (or null
|
||||||
@@ -57,6 +66,19 @@ public class ClearingJournalService {
|
|||||||
*/
|
*/
|
||||||
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||||
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
|
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId) {
|
||||||
|
return postJournalEntry(officeId, currencyCode, txnDate, debitGlId, creditGlId, amount, comments, linkAccountId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, naming the kind of account {@code linkAccountId} refers to — SAVINGS or CURRENT.
|
||||||
|
*
|
||||||
|
* Savings and current accounts number independently, so the same id exists in both tables and the id
|
||||||
|
* alone cannot say whose account it is. A caller that knows the type passes it and the voucher names
|
||||||
|
* the right customer; one that has no account at all — a settlement summary — passes neither.
|
||||||
|
*/
|
||||||
|
public String postJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||||
|
final Long creditGlId, final BigDecimal amount, final String comments, final Long linkAccountId,
|
||||||
|
final String linkAccountType) {
|
||||||
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
|
if (amount == null || amount.signum() <= 0 || debitGlId == null || creditGlId == null || officeId == null) {
|
||||||
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
|
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
|
||||||
creditGlId, amount);
|
creditGlId, amount);
|
||||||
@@ -86,7 +108,10 @@ public class ClearingJournalService {
|
|||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
// Clearing raises its own voucher below, naming the customer account the item belongs to;
|
||||||
|
// the journal command must not raise a second one over the same money.
|
||||||
|
final CommandProcessingResult result = this.voucherService
|
||||||
|
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
log.warn("Clearing journal entry did not return a result ({}).", comments);
|
log.warn("Clearing journal entry did not return a result ({}).", comments);
|
||||||
return null;
|
return null;
|
||||||
@@ -95,9 +120,77 @@ public class ClearingJournalService {
|
|||||||
if (glTxnId != null && linkAccountId != null) {
|
if (glTxnId != null && linkAccountId != null) {
|
||||||
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
linkDebitEntryToAccount(glTxnId, linkAccountId);
|
||||||
}
|
}
|
||||||
|
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
|
||||||
return glTxnId;
|
return glTxnId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records one clearing posting in the voucher register.
|
||||||
|
*
|
||||||
|
* Raised here rather than in each caller because CTS items, NACH movements and settlement
|
||||||
|
* summaries all post through this one path: a voucher per caller would be the same slip written
|
||||||
|
* three times, and one of them would drift.
|
||||||
|
*
|
||||||
|
* The caller's comment carries what the posting is — the item, the movement, the batch — so it
|
||||||
|
* becomes the narration on both sides. Where the caller named a customer account, the debit line
|
||||||
|
* carries that customer and account number; a settlement summary names no account and its lines
|
||||||
|
* stand on the GL names alone, which is what a summary is.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the posting. The entry is already in the ledger, and clearing runs
|
||||||
|
* over batches where one unwritable slip must not take a whole settlement down with it. The
|
||||||
|
* problem is logged and the posting stands.
|
||||||
|
*/
|
||||||
|
private void raiseClearingVoucher(final String glTxnId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
|
||||||
|
final String comments, final Long linkAccountId, final String linkAccountType) {
|
||||||
|
if (glTxnId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final Map<String, Object> debitGl = this.voucherService.glAccount(debitGlId);
|
||||||
|
final Map<String, Object> creditGl = this.voucherService.glAccount(creditGlId);
|
||||||
|
final Map<String, Object> holder = accountHolderForVoucher(linkAccountId, linkAccountType);
|
||||||
|
|
||||||
|
// The debit is the side a customer account is named against, matching the row this
|
||||||
|
// posting links to the account for reporting.
|
||||||
|
final String debitParty = holder.get("party") != null ? (String) holder.get("party") : (String) debitGl.get("name");
|
||||||
|
final String debitAccountNo = holder.get("account_no") != null ? (String) holder.get("account_no")
|
||||||
|
: (String) debitGl.get("gl_code");
|
||||||
|
|
||||||
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_CLEARING_POSTING, null, glTxnId,
|
||||||
|
this.context.authenticatedUser(), VoucherService.PREFIX_JOURNAL,
|
||||||
|
List.of(VoucherLine.debitGl(debitGlId, debitParty, debitAccountNo, amount).withDescription(comments),
|
||||||
|
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
|
||||||
|
.withDescription(comments)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for clearing journal transaction {}", glTxnId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer and account number a clearing item belongs to, for printing on a voucher.
|
||||||
|
*
|
||||||
|
* The type decides which table to read: savings and current accounts number independently, so
|
||||||
|
* the same id exists in both and looking in the wrong one would put another customer's name on
|
||||||
|
* the slip. Empty when the caller named no account, as a settlement summary does, or named one
|
||||||
|
* without saying which kind — a wrong name is worse on a voucher than no name.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> accountHolderForVoucher(final Long accountId, final String accountType) {
|
||||||
|
if (accountId == null || accountType == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
final String sql;
|
||||||
|
if ("SAVINGS".equalsIgnoreCase(accountType)) {
|
||||||
|
sql = "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
|
||||||
|
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?";
|
||||||
|
} else if ("CURRENT".equalsIgnoreCase(accountType)) {
|
||||||
|
sql = "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
|
||||||
|
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
|
||||||
|
} else {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
|
||||||
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
final List<Long> rowIds = this.jdbcTemplate.queryForList(
|
||||||
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
|
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
|
||||||
|
|||||||
+105
-3
@@ -46,6 +46,7 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -65,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
|
|||||||
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -719,6 +721,11 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
|
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sides of the slip, built from the entries as approved. Only reached once every
|
||||||
|
// posting above has succeeded, so the voucher describes a completed movement rather than
|
||||||
|
// one that was attempted.
|
||||||
|
addDisbursementVoucherLines(record, creditEntries, sourceGlId, requestType);
|
||||||
|
|
||||||
final String txnRefsStr = String.join(",", txnRefs);
|
final String txnRefsStr = String.join(",", txnRefs);
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
||||||
@@ -1004,7 +1011,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
/**
|
/**
|
||||||
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
* Bank-transfer settlement voucher, drawn from the same central register as every other
|
||||||
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
|
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
|
||||||
* the debited accounts, so it reads as a debit: DR/001/2108/0001.
|
* the debited accounts, so it reads as a debit: DR/2108/0001.
|
||||||
*/
|
*/
|
||||||
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
|
||||||
return voucherService.createVoucher(
|
return voucherService.createVoucher(
|
||||||
@@ -1042,6 +1049,97 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the sides of an approved disbursement, collection or bank transfer on its voucher.
|
||||||
|
*
|
||||||
|
* The register covers three directions under one request type, and each puts the society's GL
|
||||||
|
* on a different side:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>DISBURSEMENT — money out of the source GL into the listed accounts: GL debited, each
|
||||||
|
* account credited.</li>
|
||||||
|
* <li>COLLECTION — money into the target GL out of the listed accounts: each account debited,
|
||||||
|
* GL credited.</li>
|
||||||
|
* <li>BANK_TRANSFER — account to account with no GL involved at all; each entry already carries
|
||||||
|
* its own side, so they are used as given.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* The society's side is written as a single line for the total rather than one per counterparty,
|
||||||
|
* because that is how it posts and how the slip reads.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the approval: every transaction is posted by this point, and the
|
||||||
|
* money has moved. The problem is logged, and the voucher keeps its number without sides.
|
||||||
|
*/
|
||||||
|
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
|
||||||
|
final String requestType) {
|
||||||
|
|
||||||
|
final String voucherNo = (String) record.get("voucher_no");
|
||||||
|
if (voucherNo == null || voucherNo.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final boolean isBankTransfer = "BANK_TRANSFER".equals(requestType);
|
||||||
|
final boolean isCollection = "COLLECTION".equals(requestType);
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
BigDecimal counterpartyTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (final JsonElement el : creditEntries) {
|
||||||
|
final JsonObject entry = el.getAsJsonObject();
|
||||||
|
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
|
||||||
|
final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
|
||||||
|
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
|
||||||
|
? entry.get("accountName").getAsString()
|
||||||
|
: accountNo;
|
||||||
|
final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
|
||||||
|
|
||||||
|
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
|
||||||
|
? entry.get("glAccountId").getAsLong()
|
||||||
|
: null;
|
||||||
|
final Long savingsId = !isGlEntry && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
|
||||||
|
? entry.get("savingsAccountId").getAsLong()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// A bank transfer states each side on the entry itself; the other two directions
|
||||||
|
// put every counterparty on the side opposite the society's GL.
|
||||||
|
final boolean entryIsDebit = isBankTransfer
|
||||||
|
? "Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")
|
||||||
|
: isCollection;
|
||||||
|
|
||||||
|
final VoucherLine line;
|
||||||
|
if (isGlEntry) {
|
||||||
|
line = entryIsDebit ? VoucherLine.debitGl(glId, accountName, accountNo, entryAmount)
|
||||||
|
: VoucherLine.creditGl(glId, accountName, accountNo, entryAmount);
|
||||||
|
} else {
|
||||||
|
line = entryIsDebit ? VoucherLine.debitSavings(savingsId, accountName, accountNo, entryAmount)
|
||||||
|
: VoucherLine.creditSavings(savingsId, accountName, accountNo, entryAmount);
|
||||||
|
}
|
||||||
|
lines.add(line);
|
||||||
|
|
||||||
|
if (!isBankTransfer) {
|
||||||
|
counterpartyTotal = counterpartyTotal.add(entryAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The society's own side. A bank transfer has none — the money never leaves the members
|
||||||
|
// between them — so its entries already balance on their own.
|
||||||
|
if (!isBankTransfer) {
|
||||||
|
final String glName = (String) record.get("gl_account_name");
|
||||||
|
final String glNo = (String) record.get("gl_account_no");
|
||||||
|
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
|
||||||
|
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debits first, so the voucher prints the way a slip is read.
|
||||||
|
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
||||||
|
|
||||||
|
voucherService.addLines(voucherNo, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not record voucher lines for disbursement request voucher {}", voucherNo, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||||
@@ -1226,7 +1324,8 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
commandsSourceWritePlatformService.logCommandSource(cmd);
|
// The disbursement register raises its own voucher; suppress the journal command's own.
|
||||||
|
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
||||||
@@ -1663,7 +1762,10 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
// The disbursement register raises its own voucher, naming every account involved; the
|
||||||
|
// journal command must not raise a second one over the same money.
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
return result != null ? result.getTransactionId() : null;
|
return result != null ? result.getTransactionId() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
@@ -54,7 +54,10 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -69,6 +72,10 @@ import com.google.common.base.Splitter;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClerkWithdrawalRequestApiResource {
|
public class ClerkWithdrawalRequestApiResource {
|
||||||
|
|
||||||
|
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
|
||||||
|
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -457,6 +464,10 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
||||||
|
// debited and the till that pays it out is credited.
|
||||||
|
addWithdrawalVoucherLines(record, savingsAccountId, amount);
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
);
|
);
|
||||||
@@ -697,6 +708,32 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the two sides of an approved withdrawal on its voucher.
|
||||||
|
*
|
||||||
|
* Money leaves the member and is paid out over the counter, so the member's account is debited
|
||||||
|
* and the till is credited. The withdrawal register does not carry a GL id for the till — it
|
||||||
|
* pays out of whichever cashier's drawer serves the request — so that side is named without one.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the approval: the withdrawal is already posted and the member
|
||||||
|
* already paid. The problem is logged, and the voucher keeps its number without sides.
|
||||||
|
*/
|
||||||
|
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
||||||
|
final String voucherNo = (String) record.get("voucher_no");
|
||||||
|
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
voucherService.addLines(voucherNo,
|
||||||
|
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
|
||||||
|
(String) record.get("account_no"), amount),
|
||||||
|
VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||||
|
|||||||
+1
-1
@@ -225,7 +225,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
|
|||||||
controlGlId, suspenseGlId);
|
controlGlId, suspenseGlId);
|
||||||
} else {
|
} else {
|
||||||
this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount,
|
this.clearingJournalService.postJournalEntry(debit.officeId, debit.currencyCode, today, controlGlId, suspenseGlId, amount,
|
||||||
"CTS inward clearing pass - item #" + itemId, item.getAccountId());
|
"CTS inward clearing pass - item #" + itemId, item.getAccountId(), item.getAccountType());
|
||||||
}
|
}
|
||||||
|
|
||||||
markLeaf(item, true, "CTS cleared", null, userId);
|
markLeaf(item, true, "CTS cleared", null, userId);
|
||||||
|
|||||||
+96
-14
@@ -44,6 +44,7 @@ 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;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -51,6 +52,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -81,15 +84,20 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Component
|
@Component
|
||||||
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.")
|
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class DividendClaimRequestApiResource {
|
public class DividendClaimRequestApiResource {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final VoucherService voucherService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
|
||||||
|
/** A dividend payout, keyed by its m_share_dividend_claim_request id. */
|
||||||
|
private static final String REF_DIVIDEND_CLAIM = "DIVIDEND_CLAIM_REQUEST";
|
||||||
/** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
|
/** Dividend Paid — seeded by changelog 0481; see there for why it is a liability, not an expense. */
|
||||||
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
|
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
|
||||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
@@ -126,6 +134,9 @@ public class DividendClaimRequestApiResource {
|
|||||||
final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root));
|
final String ddBeneficiaryName = trimToNull(fromJsonHelper.extractStringNamed("ddBeneficiaryName", root));
|
||||||
final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root));
|
final String ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", root));
|
||||||
final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root));
|
final String ddPayableAtCity = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtCity", root));
|
||||||
|
// Which physical leaflet the clerk took off the shelf. Optional: a caller that has not
|
||||||
|
// chosen one gets the oldest available instead.
|
||||||
|
final Long requestedLeafletId = fromJsonHelper.extractLongNamed("leafletId", root);
|
||||||
if (lockId == null) {
|
if (lockId == null) {
|
||||||
throw apiError(400, "lockId is required");
|
throw apiError(400, "lockId is required");
|
||||||
}
|
}
|
||||||
@@ -153,15 +164,6 @@ public class DividendClaimRequestApiResource {
|
|||||||
throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft");
|
throw apiError(400, "ddPayableAtBranch and ddPayableAtCity are both required to draw a Demand Draft");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A draft can only be raised if there is stock to raise it on. Checked here rather than at
|
|
||||||
// approval so the clerk finds out while the member is still at the counter, instead of the
|
|
||||||
// claim sitting pending and failing later.
|
|
||||||
final Long availableLeaflets = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'", Long.class);
|
|
||||||
if (availableLeaflets == null || availableLeaflets == 0) {
|
|
||||||
throw apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long existingPending = jdbcTemplate.queryForObject(
|
final Long existingPending = jdbcTemplate.queryForObject(
|
||||||
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
|
||||||
if (existingPending != null && existingPending > 0) {
|
if (existingPending != null && existingPending > 0) {
|
||||||
@@ -170,19 +172,63 @@ public class DividendClaimRequestApiResource {
|
|||||||
|
|
||||||
validateNotGuarantorOfOverdueLoan(clientId);
|
validateNotGuarantorOfOverdueLoan(clientId);
|
||||||
|
|
||||||
|
// The leaflet is taken now rather than at approval, so the DD number exists from the moment
|
||||||
|
// the claim is raised and the draft has a place in the queue that nothing else can take.
|
||||||
|
//
|
||||||
|
// The clerk chooses which one on the leaflets screen, and that choice is honoured: the draft
|
||||||
|
// has to be drawn on the physical form in their hand, or the number written on the
|
||||||
|
// counterfoil is not the number in the books. Locked so two concurrent claims cannot take the
|
||||||
|
// same leaflet; if it was taken in the meantime the clerk is told rather than being given a
|
||||||
|
// different draft than the one they are holding.
|
||||||
|
final Map<String, Object> leaflet = requestedLeafletId != null
|
||||||
|
? jdbcTemplate
|
||||||
|
.queryForList("SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ? AND status = 'AVAILABLE' "
|
||||||
|
+ "FOR UPDATE SKIP LOCKED", requestedLeafletId)
|
||||||
|
.stream().findFirst()
|
||||||
|
.orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has "
|
||||||
|
+ "just taken it. Go back and choose another leaflet."))
|
||||||
|
// No leaflet named (a caller that is not the leaflets screen): fall back to the
|
||||||
|
// oldest available, which is the order a series is meant to be consumed in.
|
||||||
|
: jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE'
|
||||||
|
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||||
|
""").stream().findFirst().orElseThrow(() -> apiError(409,
|
||||||
|
"No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD."));
|
||||||
|
final Long leafletId = ((Number) leaflet.get("id")).longValue();
|
||||||
|
final String ddNumber = (String) leaflet.get("dd_number");
|
||||||
|
final Long branchId = jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||||
|
|
||||||
// loan/savings/cash apply amounts are written as zero: those routes are retired, but the
|
// loan/savings/cash apply amounts are written as zero: those routes are retired, but the
|
||||||
// columns remain for claims settled through them historically.
|
// columns remain for claims settled through them historically.
|
||||||
final Long newId = jdbcTemplate.queryForObject("""
|
final Long newId = jdbcTemplate.queryForObject("""
|
||||||
INSERT INTO m_share_dividend_claim_request
|
INSERT INTO m_share_dividend_claim_request
|
||||||
(lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount,
|
(lock_id, client_id, requested_amount, loan_apply_amount, savings_apply_amount, cash_apply_amount,
|
||||||
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
|
dd_apply_amount, dd_beneficiary_name, dd_payable_at_branch, dd_payable_at_city,
|
||||||
status, clerk_id, clerk_name, submitted_at)
|
status, clerk_id, clerk_name, submitted_at, dd_number)
|
||||||
VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
|
""", Long.class, lockId, clientId, ddApplyAmount, ddApplyAmount, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
|
||||||
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
|
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), ddNumber);
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
// Raised as an ordinary PENDING issue request so the draft joins the DD Issuance Approvals
|
||||||
|
// queue with every other draft, rather than sitting in a second approvals screen of its own.
|
||||||
|
// payment_method 'DIVIDEND' is what tells that approval there is no purchaser to debit —
|
||||||
|
// the society already owes this money, so the draft is funded from Dividend Payable and the
|
||||||
|
// member pays nothing: no charge, no GST, which is why those columns are zero.
|
||||||
|
final Long issueRequestId = jdbcTemplate.queryForObject("""
|
||||||
|
INSERT INTO m_sponsor_dd_issue_request
|
||||||
|
(leaflet_id, client_id, walkin_purchaser_name, beneficiary_name, payable_at_branch, payable_at_city,
|
||||||
|
face_value, charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date,
|
||||||
|
payment_method, status, maker_id, created_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 'DIVIDEND', 'PENDING', ?, ?)
|
||||||
|
RETURNING id
|
||||||
|
""", Long.class, leafletId, clientId, PURCHASER_SOCIETY_DIVIDEND, ddBeneficiaryName, ddPayableAtBranch, ddPayableAtCity,
|
||||||
|
ddApplyAmount, branchId, java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), currentUser.getId(),
|
||||||
|
Timestamp.from(Instant.now()));
|
||||||
|
|
||||||
|
jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET dd_issue_request_id = ? WHERE id = ?", issueRequestId, newId);
|
||||||
|
|
||||||
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "ddNumber", ddNumber, "ddIssueRequestId", issueRequestId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -471,13 +517,13 @@ public class DividendClaimRequestApiResource {
|
|||||||
// say how much of each year's declaration has actually been claimed.
|
// say how much of each year's declaration has actually been claimed.
|
||||||
final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #"
|
final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #"
|
||||||
+ claimId + " (lock #" + lockId + ")";
|
+ claimId + " (lock #" + lockId + ")";
|
||||||
|
|
||||||
if (dividendPaidAccountId == null) {
|
if (dividendPaidAccountId == null) {
|
||||||
// Dividend Paid not mapped: post straight across, exactly as before this account existed.
|
// Dividend Paid not mapped: post straight across, exactly as before this account existed.
|
||||||
// A society that has not configured it still gets a correct payout, just without the
|
// A society that has not configured it still gets a correct payout, just without the
|
||||||
// separate "how much has been claimed" measure.
|
// separate "how much has been claimed" measure.
|
||||||
postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description,
|
postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description,
|
||||||
currentUser);
|
currentUser);
|
||||||
|
raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +541,42 @@ public class DividendClaimRequestApiResource {
|
|||||||
// Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed.
|
// Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed.
|
||||||
postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate,
|
postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate,
|
||||||
description + " (draft raised)", currentUser);
|
description + " (draft raised)", currentUser);
|
||||||
|
|
||||||
|
// The voucher names the two accounts the payout actually moves between, not the three the
|
||||||
|
// ledger touches. Dividend Paid nets to nil across the pair above: it is there to measure
|
||||||
|
// how much has been claimed, and putting it on the slip would show the member a middle
|
||||||
|
// account that holds none of their money.
|
||||||
|
raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the payout in the voucher register: Dividend Payable debited, the DD liability credited.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the claim. The dividend is already posted and the draft already
|
||||||
|
* raised, and refusing an approved payout because its slip could not be written would hold back
|
||||||
|
* a member's money over paperwork. The claim stands, and the register shows a gap.
|
||||||
|
*/
|
||||||
|
private void raiseDividendVoucher(final Long claimId, final Long debitGlId, final Long creditGlId, final BigDecimal amount,
|
||||||
|
final String description, final AppUser currentUser) {
|
||||||
|
try {
|
||||||
|
final Map<String, Object> debitGl = glAccountForVoucher(debitGlId);
|
||||||
|
final Map<String, Object> creditGl = glAccountForVoucher(creditGlId);
|
||||||
|
|
||||||
|
voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser,
|
||||||
|
VoucherService.PREFIX_DEBIT,
|
||||||
|
List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount)
|
||||||
|
.withDescription(description),
|
||||||
|
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
|
||||||
|
.withDescription(description)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for dividend claim {}", claimId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
||||||
|
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||||
|
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||||
|
.orElse(Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+152
-2
@@ -26,6 +26,7 @@ import com.google.gson.JsonObject;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
@@ -127,6 +128,64 @@ public class DividendDashboardApiResource {
|
|||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-dashboard/declaration/{id}/report-header ──────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading of a Share Dividend Distribution Report: what rate the board resolved, what it came to, and when it
|
||||||
|
* was paid.
|
||||||
|
*
|
||||||
|
* The per-member breakdown screen already lists who gets what, but a distribution report is not just the list — it
|
||||||
|
* has to state the rate and the payment date, because that is what an AGM resolution and an auditor both ask for.
|
||||||
|
* Those two facts live on the declaration rather than on any member row, so they are fetched once here rather than
|
||||||
|
* repeated on every row of the member query.
|
||||||
|
*
|
||||||
|
* The rate is read as declared, never re-derived from amount over capital: a share movement back-dated into a
|
||||||
|
* closed period would silently change a rate the board actually resolved.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/declaration/{dividendId}/report-header")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String declarationReportHeader(@PathParam("dividendId") final Long dividendId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.id, p.name AS product_name, d.amount, d.rate_percentage, d.status,
|
||||||
|
d.dividend_period_start_date, d.dividend_period_end_date,
|
||||||
|
d.created_date, d.lastmodified_date,
|
||||||
|
COUNT(sad.id) FILTER (WHERE sad.amount > 0) AS paid_members,
|
||||||
|
COUNT(sad.id) FILTER (WHERE sad.amount = 0) AS unpaid_members,
|
||||||
|
COALESCE(SUM(sad.amount), 0) AS allocated_amount
|
||||||
|
FROM m_share_product_dividend_pay_out d
|
||||||
|
JOIN m_share_product p ON p.id = d.product_id
|
||||||
|
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = d.id
|
||||||
|
WHERE d.id = ?
|
||||||
|
GROUP BY d.id, p.name
|
||||||
|
""", dividendId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new NotFoundException("Dividend declaration not found: " + dividendId);
|
||||||
|
}
|
||||||
|
final Map<String, Object> row = rows.get(0);
|
||||||
|
final boolean approved = ((Number) row.get("status")).intValue() == DIVIDEND_PAYOUT_STATUS_APPROVED;
|
||||||
|
|
||||||
|
final JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("dividendId", ((Number) row.get("id")).longValue());
|
||||||
|
out.addProperty("productName", (String) row.get("product_name"));
|
||||||
|
final Object periodEnd = row.get("dividend_period_end_date");
|
||||||
|
out.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
||||||
|
out.addProperty("periodEndDate", String.valueOf(periodEnd));
|
||||||
|
out.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd)));
|
||||||
|
out.addProperty("ratePercentage", (BigDecimal) row.get("rate_percentage"));
|
||||||
|
out.addProperty("declaredAmount", (BigDecimal) row.get("amount"));
|
||||||
|
out.addProperty("allocatedAmount", (BigDecimal) row.get("allocated_amount"));
|
||||||
|
out.addProperty("paidMembers", ((Number) row.get("paid_members")).longValue());
|
||||||
|
out.addProperty("unpaidMembers", ((Number) row.get("unpaid_members")).longValue());
|
||||||
|
out.addProperty("status", approved ? "APPROVED" : "PENDING_APPROVAL");
|
||||||
|
out.addProperty("declaredOn", row.get("created_date") == null ? null : String.valueOf(row.get("created_date")));
|
||||||
|
// The payment date IS the approval date: approval is what makes a member's entitlement
|
||||||
|
// claimable, so nothing is payable before it and it is the date the report must quote.
|
||||||
|
out.addProperty("paymentDate",
|
||||||
|
approved && row.get("lastmodified_date") != null ? String.valueOf(row.get("lastmodified_date")) : null);
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
|
// ── GET /v1/clerk/dividend-dashboard/declarations ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +235,76 @@ public class DividendDashboardApiResource {
|
|||||||
|
|
||||||
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
|
// ── GET /v1/clerk/dividend-dashboard/locks ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One member's dividend record across every financial year — what they were entitled to, what they collected, what
|
||||||
|
* they let lapse, and on which draft.
|
||||||
|
*
|
||||||
|
* The lock history answers "what is outstanding right now"; this answers "what has this member ever been paid",
|
||||||
|
* which is the question at the counter when someone asks about a dividend from years ago. Forfeited years are
|
||||||
|
* included deliberately: a member asking why they never received a dividend needs to be told it lapsed, not to be
|
||||||
|
* shown nothing.
|
||||||
|
*
|
||||||
|
* Searched by name or share account number, because a member at the counter has a passbook rather than a client id.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/member-history")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String memberHistory(@QueryParam("search") final String search) {
|
||||||
|
final String term = search == null ? "" : search.trim();
|
||||||
|
if (term.length() < 2) {
|
||||||
|
return gson.toJson(new JsonArray());
|
||||||
|
}
|
||||||
|
final String like = "%" + term.toLowerCase() + "%";
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT c.id AS client_id, c.display_name AS client_name, sha.account_no AS share_account_no,
|
||||||
|
p.name AS product_name, d.dividend_period_start_date, d.dividend_period_end_date,
|
||||||
|
sad.amount AS entitled_amount,
|
||||||
|
l.id AS lock_id, l.status AS lock_status, l.amount AS lock_amount,
|
||||||
|
l.locked_date, l.release_due_date, ln.account_no AS applied_loan_account_no,
|
||||||
|
cr.status AS claim_status, cr.dd_number, cr.actioned_at
|
||||||
|
FROM m_share_account_dividend_details sad
|
||||||
|
JOIN m_share_account sha ON sha.id = sad.account_id
|
||||||
|
JOIN m_client c ON c.id = sha.client_id
|
||||||
|
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
|
||||||
|
JOIN m_share_product p ON p.id = d.product_id
|
||||||
|
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
|
||||||
|
LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id
|
||||||
|
LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED')
|
||||||
|
WHERE sad.amount > 0
|
||||||
|
AND (LOWER(c.display_name) LIKE ? OR LOWER(COALESCE(sha.account_no, '')) LIKE ?)
|
||||||
|
ORDER BY c.display_name, d.dividend_period_end_date DESC
|
||||||
|
""", like, like);
|
||||||
|
|
||||||
|
final JsonArray result = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
||||||
|
entry.addProperty("clientName", (String) row.get("client_name"));
|
||||||
|
entry.addProperty("shareAccountNo", (String) row.get("share_account_no"));
|
||||||
|
entry.addProperty("productName", (String) row.get("product_name"));
|
||||||
|
final Object periodEnd = row.get("dividend_period_end_date");
|
||||||
|
entry.addProperty("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
||||||
|
entry.addProperty("periodEndDate", String.valueOf(periodEnd));
|
||||||
|
entry.addProperty("financialYear", financialYearOf(String.valueOf(periodEnd)));
|
||||||
|
entry.addProperty("entitledAmount", (BigDecimal) row.get("entitled_amount"));
|
||||||
|
final Object lockId = row.get("lock_id");
|
||||||
|
entry.addProperty("lockId", lockId == null ? null : ((Number) lockId).longValue());
|
||||||
|
// No lock row at all means the entitlement was below the payable minimum and was swept
|
||||||
|
// with the rounding residue — never anybody's money to collect.
|
||||||
|
entry.addProperty("status", lockId == null ? "BELOW_MINIMUM" : (String) row.get("lock_status"));
|
||||||
|
entry.addProperty("amount", (BigDecimal) row.get("lock_amount"));
|
||||||
|
entry.addProperty("lockedDate", row.get("locked_date") == null ? null : String.valueOf(row.get("locked_date")));
|
||||||
|
entry.addProperty("releaseDueDate", row.get("release_due_date") == null ? null : String.valueOf(row.get("release_due_date")));
|
||||||
|
entry.addProperty("appliedLoanAccountNo", (String) row.get("applied_loan_account_no"));
|
||||||
|
entry.addProperty("claimStatus", (String) row.get("claim_status"));
|
||||||
|
entry.addProperty("ddNumber", (String) row.get("dd_number"));
|
||||||
|
entry.addProperty("claimedOn", row.get("actioned_at") == null ? null : String.valueOf(row.get("actioned_at")));
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a
|
* Full history of every 3-year dividend lock, regardless of status -- UNCLAIMED (still counting down), CLAIMED (a
|
||||||
* member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3
|
* member claimed it, or a manager applied it to an overdue loan -- see {@code applied_loan_id}) or FORFEITED (3
|
||||||
@@ -193,14 +322,26 @@ public class DividendDashboardApiResource {
|
|||||||
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
|
final String sql = "SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, "
|
||||||
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
|
+ "sav.account_no AS savings_account_no, l.amount, l.locked_date, l.release_due_date, l.status, "
|
||||||
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no, "
|
+ "l.applied_loan_id, ln.account_no AS applied_loan_account_no, "
|
||||||
+ "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name " //
|
+ "d.id AS declaration_id, d.dividend_period_start_date, d.dividend_period_end_date, p.name AS product_name, " //
|
||||||
|
// The share account the dividend was earned on. A clerk searching for a member at the
|
||||||
|
// counter has the passbook in front of them, which carries the account number and not
|
||||||
|
// the display name as the system spells it — so the number has to be searchable too.
|
||||||
|
+ "sha.account_no AS share_account_no, " //
|
||||||
|
// The draft this dividend is being paid out as, and how far along it is. Once a claim
|
||||||
|
// exists the clerk needs the DD number rather than another Claim button — it is what
|
||||||
|
// goes on the counterfoil and what the member asks about when they come back.
|
||||||
|
+ "cr.status AS claim_status, cr.dd_number AS claim_dd_number " //
|
||||||
+ "FROM m_share_dividend_lock l " //
|
+ "FROM m_share_dividend_lock l " //
|
||||||
+ "JOIN m_client c ON c.id = l.client_id " //
|
+ "JOIN m_client c ON c.id = l.client_id " //
|
||||||
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
|
+ "LEFT JOIN m_savings_account sav ON sav.id = l.savings_account_id " //
|
||||||
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
|
+ "LEFT JOIN m_loan ln ON ln.id = l.applied_loan_id " //
|
||||||
+ "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " //
|
+ "LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id " //
|
||||||
|
+ "LEFT JOIN m_share_account sha ON sha.id = sad.account_id " //
|
||||||
+ "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " //
|
+ "LEFT JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id " //
|
||||||
+ "LEFT JOIN m_share_product p ON p.id = d.product_id " //
|
+ "LEFT JOIN m_share_product p ON p.id = d.product_id " //
|
||||||
|
// The live claim only: a rejected one leaves the dividend claimable again, so it must
|
||||||
|
// not keep showing a DD number the member will never receive.
|
||||||
|
+ "LEFT JOIN m_share_dividend_claim_request cr ON cr.lock_id = l.id AND cr.status IN ('PENDING', 'APPROVED') " //
|
||||||
+ (filterByStatus ? "WHERE l.status = ? " : "") //
|
+ (filterByStatus ? "WHERE l.status = ? " : "") //
|
||||||
// Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that
|
// Oldest unclaimed first: a lock nearing its three-year forfeiture is the one that
|
||||||
// needs attention, and it is the one a member should be paid out of first.
|
// needs attention, and it is the one a member should be paid out of first.
|
||||||
@@ -214,6 +355,9 @@ public class DividendDashboardApiResource {
|
|||||||
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
|
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
|
||||||
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
entry.addProperty("clientId", ((Number) row.get("client_id")).longValue());
|
||||||
entry.addProperty("clientName", (String) row.get("client_name"));
|
entry.addProperty("clientName", (String) row.get("client_name"));
|
||||||
|
entry.addProperty("shareAccountNo", (String) row.get("share_account_no"));
|
||||||
|
entry.addProperty("claimStatus", (String) row.get("claim_status"));
|
||||||
|
entry.addProperty("claimDdNumber", (String) row.get("claim_dd_number"));
|
||||||
final Object savingsAccountId = row.get("savings_account_id");
|
final Object savingsAccountId = row.get("savings_account_id");
|
||||||
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||||
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
|
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
|
||||||
@@ -247,9 +391,14 @@ public class DividendDashboardApiResource {
|
|||||||
public String list() {
|
public String list() {
|
||||||
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> locks = jdbcTemplate.queryForList("""
|
||||||
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
|
SELECT l.id AS lock_id, l.client_id, c.display_name AS client_name, l.savings_account_id, l.amount AS locked_amount,
|
||||||
l.locked_date, l.release_due_date
|
l.locked_date, l.release_due_date,
|
||||||
|
-- The share account the dividend was earned on, so a clerk holding the
|
||||||
|
-- member's passbook can find the row by its number rather than by name.
|
||||||
|
sha.account_no AS share_account_no
|
||||||
FROM m_share_dividend_lock l
|
FROM m_share_dividend_lock l
|
||||||
JOIN m_client c ON c.id = l.client_id
|
JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
|
||||||
|
LEFT JOIN m_share_account sha ON sha.id = sad.account_id
|
||||||
WHERE l.status = 'UNCLAIMED'
|
WHERE l.status = 'UNCLAIMED'
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
SELECT 1 FROM m_loan ln JOIN m_loan_repayment_schedule ri ON ri.loan_id = ln.id
|
||||||
@@ -266,6 +415,7 @@ public class DividendDashboardApiResource {
|
|||||||
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
|
row.addProperty("lockId", ((Number) lock.get("lock_id")).longValue());
|
||||||
row.addProperty("clientId", clientId);
|
row.addProperty("clientId", clientId);
|
||||||
row.addProperty("clientName", (String) lock.get("client_name"));
|
row.addProperty("clientName", (String) lock.get("client_name"));
|
||||||
|
row.addProperty("shareAccountNo", (String) lock.get("share_account_no"));
|
||||||
final Object savingsAccountId = lock.get("savings_account_id");
|
final Object savingsAccountId = lock.get("savings_account_id");
|
||||||
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
|
||||||
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
|
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
|
||||||
|
|||||||
+71
@@ -217,6 +217,8 @@ import org.apache.fineract.portfolio.note.domain.Note;
|
|||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecks;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler;
|
||||||
@@ -232,6 +234,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService {
|
public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService {
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final VoucherService voucherService;
|
||||||
private final LoanTransactionValidator loanTransactionValidator;
|
private final LoanTransactionValidator loanTransactionValidator;
|
||||||
private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer;
|
private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer;
|
||||||
private final LoanRepositoryWrapper loanRepositoryWrapper;
|
private final LoanRepositoryWrapper loanRepositoryWrapper;
|
||||||
@@ -521,6 +524,17 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
|
|||||||
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
|
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
|
||||||
|
|
||||||
|
// Money leaving the society to the borrower: the Loan Portfolio account is debited, because
|
||||||
|
// the loan is now an asset owed back, and the fund the money came out of is credited.
|
||||||
|
// Skipped for an account transfer, where the disbursement lands in another account rather
|
||||||
|
// than being paid out and that account's own posting carries the voucher.
|
||||||
|
if (!isAccountTransfer && disbursalTransactionId != null) {
|
||||||
|
final String voucherNo = raiseLoanVoucher(loan, disbursalTransactionId, loan.getDisbursedAmount(), true);
|
||||||
|
if (voucherNo != null) {
|
||||||
|
changes.put("voucherNo", voucherNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(command.commandId()) //
|
.withCommandId(command.commandId()) //
|
||||||
.withEntityId(loan.getId()) //
|
.withEntityId(loan.getId()) //
|
||||||
@@ -1073,6 +1087,56 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a loan disbursement or repayment in the voucher register, and returns its number.
|
||||||
|
*
|
||||||
|
* The borrower's side is the product's Loan Portfolio account, because that is what the balance
|
||||||
|
* posts against; the line still names the borrower and their loan account number, so the slip
|
||||||
|
* reads as theirs. The other side is the till.
|
||||||
|
*
|
||||||
|
* A disbursement debits the portfolio — the society now holds a receivable — and credits the
|
||||||
|
* cash paid out. A repayment reverses that.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the transaction. The disbursement or repayment is already posted
|
||||||
|
* and the borrower's balance already moved; taking that back over a slip would undo a correct
|
||||||
|
* transaction for a paperwork reason. The problem is logged and the transaction stands.
|
||||||
|
*/
|
||||||
|
private String raiseLoanVoucher(final Loan loan, final Long transactionId, final BigDecimal amount, final boolean isDisbursement) {
|
||||||
|
// A register further out may have already raised a voucher naming the instrument this
|
||||||
|
// movement belongs to; its voucher describes it better than a bare loan posting would.
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final Map<String, Object> portfolio = this.voucherService.loanPortfolioAccount(loan.getId());
|
||||||
|
final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue();
|
||||||
|
|
||||||
|
final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null;
|
||||||
|
final String accountNo = loan.getAccountNumber();
|
||||||
|
final String partyName = party != null ? party : (String) portfolio.get("name");
|
||||||
|
|
||||||
|
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount)
|
||||||
|
: VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount);
|
||||||
|
|
||||||
|
// The till. The loan module does not resolve a GL for the cashier's drawer, so the line
|
||||||
|
// names it without an id rather than guessing at one.
|
||||||
|
final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
|
||||||
|
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide);
|
||||||
|
|
||||||
|
return this.voucherService.createVoucherWithLines(
|
||||||
|
isDisbursement ? VoucherService.TYPE_DISBURSEMENT : VoucherService.TYPE_REPAYMENT,
|
||||||
|
VoucherService.REF_LOAN_TRANSACTION, transactionId, currentUser,
|
||||||
|
isDisbursement ? VoucherService.PREFIX_DEBIT : VoucherService.PREFIX_CREDIT, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for loan transaction {}", transactionId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void saveNote(String noteText, Loan loan, LoanTransaction loanTransaction) {
|
private void saveNote(String noteText, Loan loan, LoanTransaction loanTransaction) {
|
||||||
if (StringUtils.isNotBlank(noteText)) {
|
if (StringUtils.isNotBlank(noteText)) {
|
||||||
final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText);
|
final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText);
|
||||||
@@ -1118,6 +1182,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
|
|||||||
loan = loanTransaction.getLoan();
|
loan = loanTransaction.getLoan();
|
||||||
this.loanAccountDomainService.updateAndSaveLoanCollateralTransactionsForIndividualAccounts(loan, loanTransaction);
|
this.loanAccountDomainService.updateAndSaveLoanCollateralTransactionsForIndividualAccounts(loan, loanTransaction);
|
||||||
|
|
||||||
|
// Money coming back from the borrower: cash is debited and the Loan Portfolio account is
|
||||||
|
// credited, reducing what the loan still carries as owed.
|
||||||
|
final String repaymentVoucherNo = raiseLoanVoucher(loan, loanTransaction.getId(), transactionAmount, false);
|
||||||
|
if (repaymentVoucherNo != null) {
|
||||||
|
changes.put("voucherNo", repaymentVoucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()) //
|
||||||
.withLoanId(loan.getId()) //
|
.withLoanId(loan.getId()) //
|
||||||
.withEntityId(loanTransaction.getId()) //
|
.withEntityId(loanTransaction.getId()) //
|
||||||
|
|||||||
+4
-2
@@ -176,6 +176,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
|
|||||||
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||||
import org.apache.fineract.portfolio.rate.service.RateAssembler;
|
import org.apache.fineract.portfolio.rate.service.RateAssembler;
|
||||||
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
|
||||||
@@ -413,7 +414,7 @@ public class LoanAccountConfiguration {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean(LoanWritePlatformService.class)
|
@ConditionalOnMissingBean(LoanWritePlatformService.class)
|
||||||
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context,
|
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, VoucherService voucherService,
|
||||||
LoanTransactionValidator loanTransactionValidator,
|
LoanTransactionValidator loanTransactionValidator,
|
||||||
LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper,
|
LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper,
|
||||||
LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository,
|
LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository,
|
||||||
@@ -446,7 +447,8 @@ public class LoanAccountConfiguration {
|
|||||||
LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper,
|
LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper,
|
||||||
LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService,
|
LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService,
|
||||||
LoanTransactionService loanTransactionService) {
|
LoanTransactionService loanTransactionService) {
|
||||||
return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer,
|
return new LoanWritePlatformServiceJpaRepositoryImpl(context, voucherService, loanTransactionValidator,
|
||||||
|
loanUpdateCommandFromApiJsonDeserializer,
|
||||||
loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository,
|
loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository,
|
||||||
loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService,
|
loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService,
|
||||||
holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService,
|
holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService,
|
||||||
|
|||||||
+2
-2
@@ -338,10 +338,10 @@ public class NachWritePlatformServiceImpl implements NachWritePlatformService {
|
|||||||
}
|
}
|
||||||
if (txn.isCredit()) {
|
if (txn.isCredit()) {
|
||||||
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, suspenseGlId, controlGlId,
|
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, suspenseGlId, controlGlId,
|
||||||
amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId());
|
amount, "NACH credit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType());
|
||||||
} else {
|
} else {
|
||||||
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, controlGlId, suspenseGlId,
|
this.clearingJournalService.postJournalEntry(movement.officeId, movement.currencyCode, txnDate, controlGlId, suspenseGlId,
|
||||||
amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId());
|
amount, "NACH debit pass - transaction #" + txn.getId(), txn.getAccountId(), txn.getAccountType());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
|
||||||
|
*
|
||||||
|
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
|
||||||
|
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
|
||||||
|
* be retried without recomputing the accrual, and an accrual can be rebuilt after a backdated
|
||||||
|
* transaction without disturbing what has already been paid.
|
||||||
|
*
|
||||||
|
* Each product posts in its own transaction, so one product failing does not roll back another
|
||||||
|
* that already settled. The failure is recorded against that product's run and the day continues.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SavingsDailyInterestOrchestrator {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final SavingsDailyInterestService accrualService;
|
||||||
|
private final SavingsPooledInterestPostingService postingService;
|
||||||
|
|
||||||
|
/** Accrue for the day, then post for any product whose cycle ends today. */
|
||||||
|
public void runForDate(final LocalDate businessDate) {
|
||||||
|
accrualService.accrueForDate(businessDate);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
|
||||||
|
SELECT s.savings_product_id, s.posting_period_enum
|
||||||
|
FROM m_savings_interest_posting_schedule s
|
||||||
|
JOIN m_savings_product p ON p.id = s.savings_product_id
|
||||||
|
WHERE s.is_active = true AND p.deposit_type_enum = 100
|
||||||
|
ORDER BY s.savings_product_id
|
||||||
|
""");
|
||||||
|
|
||||||
|
for (final Map<String, Object> schedule : schedules) {
|
||||||
|
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
||||||
|
final Long dueCount = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true
|
||||||
|
AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0
|
||||||
|
""", Long.class, productId, businessDate);
|
||||||
|
if (dueCount == null || dueCount == 0) {
|
||||||
|
updateSchedule(productId, businessDate, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
||||||
|
updateSchedule(productId, businessDate, "COMPLETED");
|
||||||
|
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// The product rolled back on its own; record why and let the others run.
|
||||||
|
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_run
|
||||||
|
SET status = 'FAILED', failure_reason = ?
|
||||||
|
WHERE savings_product_id = ? AND posting_date = ?
|
||||||
|
""", truncate(e.getMessage()), productId, businessDate);
|
||||||
|
updateSchedule(productId, businessDate, "FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger
|
||||||
|
* itself recorded, so the schedule and the rows can never drift apart.
|
||||||
|
*/
|
||||||
|
private void updateSchedule(final long productId, final LocalDate businessDate, final String status) {
|
||||||
|
final List<Map<String, Object>> cycle = jdbcTemplate.queryForList("""
|
||||||
|
SELECT MIN(cycle_end_date) AS next_end FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = ? AND balance_date = ? AND cycle_end_date >= ?
|
||||||
|
""", productId, businessDate, businessDate);
|
||||||
|
|
||||||
|
LocalDate nextPosting = null;
|
||||||
|
if (!cycle.isEmpty() && cycle.get(0).get("next_end") != null) {
|
||||||
|
nextPosting = ((java.sql.Date) cycle.get(0).get("next_end")).toLocalDate();
|
||||||
|
if (nextPosting.isEqual(businessDate) && "COMPLETED".equals(status)) {
|
||||||
|
final int period = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT posting_period_enum FROM m_savings_interest_posting_schedule WHERE savings_product_id = ?",
|
||||||
|
Integer.class, productId);
|
||||||
|
nextPosting = SavingsInterestCycleCalculator.nextCycleEndDate(businessDate, period,
|
||||||
|
financialYearBeginningMonth());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_schedule
|
||||||
|
SET next_posting_date = ?,
|
||||||
|
last_posting_date = CASE WHEN ? = 'COMPLETED' THEN ? ELSE last_posting_date END,
|
||||||
|
last_run_status = COALESCE(?, last_run_status),
|
||||||
|
last_run_at = CURRENT_TIMESTAMP, updated_on = CURRENT_TIMESTAMP
|
||||||
|
WHERE savings_product_id = ?
|
||||||
|
""", nextPosting, status, businessDate, status, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int financialYearBeginningMonth() {
|
||||||
|
final Integer month = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class);
|
||||||
|
return month == null || month < 1 || month > 12 ? 1 : month;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(final String message) {
|
||||||
|
if (message == null) {
|
||||||
|
return "unknown failure";
|
||||||
|
}
|
||||||
|
return message.length() > 1000 ? message.substring(0, 1000) : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
+445
@@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the day-by-day working behind each savings account's interest.
|
||||||
|
*
|
||||||
|
* The engine already computes interest correctly; what it does not do is leave a record. It keeps
|
||||||
|
* one derived total per account and recomputes it from the whole transaction history whenever
|
||||||
|
* anybody looks, so there is no way to ask what an account earned on a particular day, or why a
|
||||||
|
* day earned nothing. This service produces that record: one row per account per day, carrying the
|
||||||
|
* balance it was worked out on and the running total for the cycle.
|
||||||
|
*
|
||||||
|
* The interest terms - rate, posting period, compounding, calculation type, days in year - are read
|
||||||
|
* from the PRODUCT, deliberately. Fineract copies them onto each account at creation and the engine
|
||||||
|
* afterwards reads the account's copy, so the two can drift: in this tenant seventeen accounts on a
|
||||||
|
* quarterly 10% product still carry a daily 7% from whenever they were opened. Interest here is a
|
||||||
|
* property of the product a member bought, not of a stale copy, so this ledger states the product's
|
||||||
|
* terms and the whole product posts on one rhythm. The consequence is that these figures can differ
|
||||||
|
* from total_interest_earned_derived on any account whose copy has drifted; that difference is the
|
||||||
|
* point, and the reconciliation reports it rather than hiding it.
|
||||||
|
*
|
||||||
|
* The minimum balance is the exception: it is held only on the account, never on the product.
|
||||||
|
*
|
||||||
|
* Two rules the engine enforces and this has to match, or the ledger and the account will disagree:
|
||||||
|
*
|
||||||
|
* - Interest is taken on the day's CLOSING balance. EndOfDayBalance holds an opening balance too
|
||||||
|
* but never uses it in the arithmetic; opening is kept here only because it chains.
|
||||||
|
* - A day whose closing balance is under the account's minimum earns nothing at all, not a
|
||||||
|
* reduced amount. That is a cliff, and the row records it so a zero does not read as a fault.
|
||||||
|
*
|
||||||
|
* Scope is savings only - deposit_type_enum 100. Fixed and recurring deposits share
|
||||||
|
* m_savings_account and the same interest engine, but they carry maturity and lock-in rules this
|
||||||
|
* ledger does not model, so they are left alone.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SavingsDailyInterestService {
|
||||||
|
|
||||||
|
private static final MathContext MC = new MathContext(19, RoundingMode.HALF_EVEN);
|
||||||
|
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||||
|
private static final int ACTIVE_STATUS = 300;
|
||||||
|
|
||||||
|
/** m_savings_account.interest_calculation_type_enum: 1 daily balance, 2 average daily balance. */
|
||||||
|
private static final class SavingsInterestCalculationType {
|
||||||
|
|
||||||
|
private SavingsInterestCalculationType() {}
|
||||||
|
|
||||||
|
static final int AVERAGE_DAILY_BALANCE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one day's rows for every active savings account, for both savings products.
|
||||||
|
*
|
||||||
|
* Safe to run more than once for the same day: rows are keyed on (account, date) and a repeat
|
||||||
|
* updates in place rather than accruing twice. That matters because a day rewritten after a
|
||||||
|
* backdated transaction has to be able to replace what is already there.
|
||||||
|
*
|
||||||
|
* @return how many rows were written
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int accrueForDate(final LocalDate businessDate) {
|
||||||
|
final int financialYearBeginningMonth = financialYearBeginningMonth();
|
||||||
|
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id, sa.product_id, sa.activatedon_date,
|
||||||
|
sp.nominal_annual_interest_rate AS rate,
|
||||||
|
sp.interest_posting_period_enum AS posting_period,
|
||||||
|
sp.interest_compounding_period_enum AS compounding_period,
|
||||||
|
sp.interest_calculation_type_enum AS calculation_type,
|
||||||
|
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
|
||||||
|
sa.min_balance_for_interest_calculation AS min_balance
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sp.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum = ?
|
||||||
|
AND sa.activatedon_date IS NOT NULL
|
||||||
|
AND sa.activatedon_date <= ?
|
||||||
|
ORDER BY sa.product_id, sa.id
|
||||||
|
""", SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS, businessDate);
|
||||||
|
|
||||||
|
int written = 0;
|
||||||
|
for (final Map<String, Object> account : accounts) {
|
||||||
|
written += accrueOneAccount(account, businessDate, financialYearBeginningMonth) ? 1 : 0;
|
||||||
|
}
|
||||||
|
log.info("Savings daily interest: wrote {} rows for {}", written, businessDate);
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
final Long accountId = ((Number) account.get("id")).longValue();
|
||||||
|
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||||
|
final LocalDate activatedOn = ((java.sql.Date) account.get("activatedon_date")).toLocalDate();
|
||||||
|
final BigDecimal rate = (BigDecimal) account.get("rate");
|
||||||
|
final int postingPeriod = ((Number) account.get("posting_period")).intValue();
|
||||||
|
final Number compounding = (Number) account.get("compounding_period");
|
||||||
|
final Number calcType = (Number) account.get("calculation_type");
|
||||||
|
final int daysInYear = daysInYear(account.get("days_in_year"), businessDate);
|
||||||
|
final BigDecimal minBalance = (BigDecimal) account.get("min_balance");
|
||||||
|
|
||||||
|
// The previous row is the only thing that carries the chain forward. Its absence means
|
||||||
|
// this is the account's first day; a gap would mean a day the job never ran, and rather
|
||||||
|
// than silently re-anchoring we take the last row we do have and let the reconciliation
|
||||||
|
// report the break.
|
||||||
|
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||||
|
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
|
||||||
|
cycle_start_date, cycle_end_date, is_posting_day
|
||||||
|
FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ? AND balance_date < ?
|
||||||
|
ORDER BY balance_date DESC LIMIT 1
|
||||||
|
""", accountId, businessDate);
|
||||||
|
|
||||||
|
final BigDecimal openingBalance;
|
||||||
|
final BigDecimal carriedInterest;
|
||||||
|
final int cycleNumber;
|
||||||
|
final LocalDate cycleStart;
|
||||||
|
final LocalDate cycleEnd;
|
||||||
|
|
||||||
|
if (previous.isEmpty()) {
|
||||||
|
openingBalance = BigDecimal.ZERO;
|
||||||
|
carriedInterest = BigDecimal.ZERO;
|
||||||
|
cycleNumber = 1;
|
||||||
|
// Cycle 1 is a stub: it starts when the account was activated, not on the grid.
|
||||||
|
cycleStart = activatedOn;
|
||||||
|
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth);
|
||||||
|
} else {
|
||||||
|
final Map<String, Object> prev = previous.get(0);
|
||||||
|
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||||
|
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
||||||
|
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
||||||
|
if (prevWasPosting) {
|
||||||
|
// The cycle closed yesterday: the sum went to the member and starts again at zero.
|
||||||
|
carriedInterest = BigDecimal.ZERO;
|
||||||
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
||||||
|
cycleStart = prevCycleEnd.plusDays(1);
|
||||||
|
cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod,
|
||||||
|
financialYearBeginningMonth);
|
||||||
|
} else {
|
||||||
|
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
||||||
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
||||||
|
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
||||||
|
cycleEnd = prevCycleEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal movement = dayMovement(accountId, businessDate);
|
||||||
|
final BigDecimal closingBalance = openingBalance.add(movement);
|
||||||
|
|
||||||
|
// The minimum is a cliff, not a taper: below it nothing is earned. It is tested against
|
||||||
|
// whichever balance the account actually earns on, which is not always today's closing.
|
||||||
|
final boolean averageDailyBalance = calcType != null
|
||||||
|
&& calcType.intValue() == SavingsInterestCalculationType.AVERAGE_DAILY_BALANCE;
|
||||||
|
|
||||||
|
final BigDecimal dailyInterest;
|
||||||
|
final BigDecimal cumulative;
|
||||||
|
final boolean belowMinimumForInterest;
|
||||||
|
|
||||||
|
if (averageDailyBalance) {
|
||||||
|
// Average daily balance is a statement about the whole period, not about one day:
|
||||||
|
// the engine averages every closing balance in the cycle and applies the rate once
|
||||||
|
// across all of the days at once. So what this works out is the cycle's running
|
||||||
|
// total, and the day's own interest is whatever that total moved by since yesterday.
|
||||||
|
// Early in a cycle the figure is provisional - a later deposit shifts the average and
|
||||||
|
// restates it - which is inherent to the method, not an artefact of writing it down.
|
||||||
|
final int daysSoFar = (int) (businessDate.toEpochDay() - cycleStart.toEpochDay()) + 1;
|
||||||
|
final BigDecimal averageBalance = averageBalanceForCycle(accountId, cycleStart, businessDate,
|
||||||
|
closingBalance);
|
||||||
|
belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
&& averageBalance.compareTo(minBalance) < 0;
|
||||||
|
if (belowMinimumForInterest || averageBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null
|
||||||
|
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
cumulative = carriedInterest;
|
||||||
|
dailyInterest = BigDecimal.ZERO;
|
||||||
|
} else {
|
||||||
|
cumulative = averageBalance.multiply(rate, MC)
|
||||||
|
.divide(BigDecimal.valueOf(100L), MC)
|
||||||
|
.divide(BigDecimal.valueOf(daysInYear), MC)
|
||||||
|
.multiply(BigDecimal.valueOf(daysSoFar), MC);
|
||||||
|
dailyInterest = cumulative.subtract(carriedInterest);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
belowMinimumForInterest = minBalance != null && minBalance.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
&& closingBalance.compareTo(minBalance) < 0;
|
||||||
|
if (belowMinimumForInterest || closingBalance.compareTo(BigDecimal.ZERO) <= 0 || rate == null
|
||||||
|
|| rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
dailyInterest = BigDecimal.ZERO;
|
||||||
|
} else {
|
||||||
|
dailyInterest = closingBalance.multiply(rate, MC)
|
||||||
|
.divide(BigDecimal.valueOf(100L), MC)
|
||||||
|
.divide(BigDecimal.valueOf(daysInYear), MC);
|
||||||
|
}
|
||||||
|
cumulative = carriedInterest.add(dailyInterest);
|
||||||
|
}
|
||||||
|
final boolean isPostingDay = !businessDate.isBefore(cycleEnd);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_daily_interest
|
||||||
|
(savings_product_id, savings_account_id, balance_date, opening_balance, closing_balance,
|
||||||
|
daily_interest, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date,
|
||||||
|
posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum,
|
||||||
|
posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET
|
||||||
|
opening_balance = EXCLUDED.opening_balance,
|
||||||
|
-- A day whose interest has already been paid keeps the balance it was left
|
||||||
|
-- with. Re-accruing that day recomputes the closing balance from
|
||||||
|
-- transactions alone, which does not include this ledger's own posting, and
|
||||||
|
-- writing it back would erase the credit from the chain: the next day would
|
||||||
|
-- open lower than the member's real balance and they would stop earning on
|
||||||
|
-- interest they have been given.
|
||||||
|
closing_balance = CASE
|
||||||
|
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
|
||||||
|
THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount
|
||||||
|
ELSE EXCLUDED.closing_balance END,
|
||||||
|
daily_interest = EXCLUDED.daily_interest,
|
||||||
|
-- Likewise the total that was actually paid: once money has moved on the
|
||||||
|
-- strength of this figure, recomputing it would leave the ledger disagreeing
|
||||||
|
-- with the member's passbook. A posted day is settled.
|
||||||
|
cycle_cumulative_interest = CASE
|
||||||
|
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
|
||||||
|
THEN m_savings_daily_interest.cycle_cumulative_interest
|
||||||
|
ELSE EXCLUDED.cycle_cumulative_interest END,
|
||||||
|
cycle_number = EXCLUDED.cycle_number,
|
||||||
|
cycle_start_date = EXCLUDED.cycle_start_date,
|
||||||
|
cycle_end_date = EXCLUDED.cycle_end_date,
|
||||||
|
posting_date = EXCLUDED.posting_date,
|
||||||
|
is_posting_day = EXCLUDED.is_posting_day,
|
||||||
|
below_min_balance = EXCLUDED.below_min_balance,
|
||||||
|
-- The terms are re-stamped too. A row rewritten under different product terms
|
||||||
|
-- must not keep the old rate beside a figure the new one produced, or the row
|
||||||
|
-- stops explaining itself. Days already posted keep their money above; this
|
||||||
|
-- only restates what the money was worked out from.
|
||||||
|
interest_rate = EXCLUDED.interest_rate,
|
||||||
|
days_in_year = EXCLUDED.days_in_year,
|
||||||
|
compounding_period_enum = EXCLUDED.compounding_period_enum,
|
||||||
|
posting_period_enum = EXCLUDED.posting_period_enum,
|
||||||
|
calculation_type_enum = EXCLUDED.calculation_type_enum,
|
||||||
|
min_balance_for_interest = EXCLUDED.min_balance_for_interest,
|
||||||
|
last_recomputed_on = CURRENT_TIMESTAMP
|
||||||
|
""", productId, accountId, businessDate, openingBalance, closingBalance, dailyInterest, cumulative,
|
||||||
|
cycleNumber, cycleStart, cycleEnd, cycleEnd, isPostingDay, rate, daysInYear,
|
||||||
|
compounding == null ? null : compounding.intValue(), postingPeriod,
|
||||||
|
calcType == null ? null : calcType.intValue(), minBalance, belowMinimumForInterest);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mean of the cycle's daily closing balances, today included.
|
||||||
|
*
|
||||||
|
* Rows already written for this cycle carry the earlier days; today's closing is not among
|
||||||
|
* them yet, so it is added by the caller. A day the ledger never wrote would pull the mean
|
||||||
|
* off, which is the other reason the chain check matters.
|
||||||
|
*/
|
||||||
|
private BigDecimal averageBalanceForCycle(final Long accountId, final LocalDate cycleStart,
|
||||||
|
final LocalDate businessDate, final BigDecimal todaysClosing) {
|
||||||
|
final BigDecimal earlierSum = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(closing_balance), 0) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ?
|
||||||
|
""", BigDecimal.class, accountId, cycleStart, businessDate);
|
||||||
|
final Long earlierDays = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ? AND balance_date >= ? AND balance_date < ?
|
||||||
|
""", Long.class, accountId, cycleStart, businessDate);
|
||||||
|
final long days = (earlierDays == null ? 0L : earlierDays) + 1L;
|
||||||
|
final BigDecimal total = (earlierSum == null ? BigDecimal.ZERO : earlierSum).add(todaysClosing);
|
||||||
|
return total.divide(BigDecimal.valueOf(days), MC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day's net movement, excluding interest postings.
|
||||||
|
*
|
||||||
|
* Interest postings are left out on purpose. The posting is this ledger's own output, and
|
||||||
|
* folding it back in here would let it accrue interest on itself a second time. It is added to
|
||||||
|
* the closing balance by the posting run, on the posting day, once.
|
||||||
|
*/
|
||||||
|
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
|
||||||
|
final BigDecimal movement = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CASE
|
||||||
|
WHEN t.transaction_type_enum IN (1, 8) THEN t.amount
|
||||||
|
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
|
||||||
|
ELSE 0 END), 0)
|
||||||
|
FROM m_savings_account_transaction t
|
||||||
|
WHERE t.savings_account_id = ?
|
||||||
|
AND t.transaction_date = ?
|
||||||
|
AND t.is_reversed = false
|
||||||
|
""", BigDecimal.class, accountId, businessDate);
|
||||||
|
return movement == null ? BigDecimal.ZERO : movement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 365, 360, or the actual length of the year. The column holds the enum value, which for the
|
||||||
|
* two fixed options is the number itself; ACTUAL is 1 and means the calendar year.
|
||||||
|
*/
|
||||||
|
private int daysInYear(final Object raw, final LocalDate businessDate) {
|
||||||
|
if (raw == null) {
|
||||||
|
return 365;
|
||||||
|
}
|
||||||
|
final int value = ((Number) raw).intValue();
|
||||||
|
if (value == 360 || value == 365) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return businessDate.lengthOfYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int financialYearBeginningMonth() {
|
||||||
|
final Integer month = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT value FROM c_configuration WHERE name = 'financial-year-beginning-month'", Integer.class);
|
||||||
|
return month == null || month < 1 || month > 12 ? 1 : month;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the ledger for one account from a date forward, for when a backdated transaction
|
||||||
|
* changes what earlier days should have earned.
|
||||||
|
*
|
||||||
|
* Recomputing in place rather than writing correction rows: the ledger's job is to say what an
|
||||||
|
* account earned, and after a backdated deposit the honest answer is the new figure. The
|
||||||
|
* last_recomputed_on stamp is what records that the row was revisited.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int recomputeAccountFrom(final long accountId, final LocalDate fromDate, final LocalDate toDate) {
|
||||||
|
final int financialYearBeginningMonth = financialYearBeginningMonth();
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id, sa.product_id, sa.activatedon_date,
|
||||||
|
sp.nominal_annual_interest_rate AS rate,
|
||||||
|
sp.interest_posting_period_enum AS posting_period,
|
||||||
|
sp.interest_compounding_period_enum AS compounding_period,
|
||||||
|
sp.interest_calculation_type_enum AS calculation_type,
|
||||||
|
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
|
||||||
|
sa.min_balance_for_interest_calculation AS min_balance
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sa.id = ?
|
||||||
|
""", accountId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final Map<String, Object> account = rows.get(0);
|
||||||
|
int rewritten = 0;
|
||||||
|
for (LocalDate day = fromDate; !day.isAfter(toDate); day = day.plusDays(1)) {
|
||||||
|
accrueOneAccount(account, day, financialYearBeginningMonth);
|
||||||
|
rewritten++;
|
||||||
|
}
|
||||||
|
log.info("Savings daily interest: recomputed {} days for account {} from {}", rewritten, accountId, fromDate);
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills every active savings account from activation up to the given date.
|
||||||
|
*
|
||||||
|
* Existing accounts have months of history the ledger knows nothing about; without this the
|
||||||
|
* table starts blank and the first cycle it sees is a partial one that will not reconcile
|
||||||
|
* against the account's own totals.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int backfillAll(final LocalDate upToDate) {
|
||||||
|
final List<Long> accountIds = jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id FROM m_savings_account sa
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
WHERE sp.deposit_type_enum = ? AND sa.status_enum = ?
|
||||||
|
AND sa.activatedon_date IS NOT NULL
|
||||||
|
ORDER BY sa.id
|
||||||
|
""", Long.class, SAVINGS_DEPOSIT_TYPE, ACTIVE_STATUS);
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
for (final Long accountId : accountIds) {
|
||||||
|
final java.sql.Date activated = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT activatedon_date FROM m_savings_account WHERE id = ?", java.sql.Date.class, accountId);
|
||||||
|
if (activated == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += recomputeAccountFrom(accountId, activated.toLocalDate(), upToDate);
|
||||||
|
}
|
||||||
|
log.info("Savings daily interest: backfilled {} rows across {} accounts", total, accountIds.size());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two checks worth running: that no day's opening lost touch with the day before it, and
|
||||||
|
* that the open cycle still agrees with what the account itself reports as unposted.
|
||||||
|
*/
|
||||||
|
public List<String> reconcile() {
|
||||||
|
final List<String> problems = new ArrayList<>();
|
||||||
|
|
||||||
|
jdbcTemplate.queryForList("""
|
||||||
|
SELECT curr.savings_account_id, curr.balance_date, curr.opening_balance, prev.closing_balance
|
||||||
|
FROM m_savings_daily_interest curr
|
||||||
|
JOIN m_savings_daily_interest prev
|
||||||
|
ON prev.savings_account_id = curr.savings_account_id
|
||||||
|
AND prev.balance_date = curr.balance_date - INTERVAL '1 day'
|
||||||
|
WHERE curr.opening_balance <> prev.closing_balance
|
||||||
|
ORDER BY curr.savings_account_id, curr.balance_date
|
||||||
|
""").forEach(row -> problems.add(String.format(
|
||||||
|
"chain break: account %s on %s opens at %s but previous day closed at %s",
|
||||||
|
row.get("savings_account_id"), row.get("balance_date"), row.get("opening_balance"),
|
||||||
|
row.get("closing_balance"))));
|
||||||
|
|
||||||
|
jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.savings_account_id, d.cycle_cumulative_interest AS ledger,
|
||||||
|
COALESCE(sa.total_interest_earned_derived, 0) - COALESCE(sa.total_interest_posted_derived, 0) AS account
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
WHERE d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = d.savings_account_id)
|
||||||
|
AND ABS(d.cycle_cumulative_interest
|
||||||
|
- (COALESCE(sa.total_interest_earned_derived, 0)
|
||||||
|
- COALESCE(sa.total_interest_posted_derived, 0))) > 0.01
|
||||||
|
""").forEach(row -> problems.add(String.format(
|
||||||
|
"unposted mismatch: account %s ledger says %s, account says %s", row.get("savings_account_id"),
|
||||||
|
row.get("ledger"), row.get("account"))));
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a posting cycle ends, for a given posting period.
|
||||||
|
*
|
||||||
|
* This mirrors SavingsHelper.determineInterestPostingPeriodEndDateFrom, which is private to the
|
||||||
|
* engine and works on the engine's own period objects. The daily ledger needs the same boundaries
|
||||||
|
* to label its rows, so the rule is restated here rather than reached into.
|
||||||
|
*
|
||||||
|
* The boundaries are calendar dates anchored on the financial year start, not anniversaries of the
|
||||||
|
* account: an account opened on 24 August under a quarterly product does not post on 24 November,
|
||||||
|
* it posts at the next quarter end on the grid. The consequence is that the first cycle of any
|
||||||
|
* account is a stub - activation to the first boundary - and every cycle after it is a full period.
|
||||||
|
*
|
||||||
|
* One deliberate difference from the engine. Fineract adds a day to the period end and posts on the
|
||||||
|
* morning after ("interest posting always occurs on next day after the period end date"), unless
|
||||||
|
* the savings-interest-posting-current-period-end configuration is on, in which case it posts on
|
||||||
|
* the period end itself. This ledger always uses the period end date, matching that configuration
|
||||||
|
* being enabled, which is the behaviour this feature was asked for. If the configuration is left
|
||||||
|
* off, the engine's own posting transaction will be dated one day later than the ledger's
|
||||||
|
* posting_date, and the reconciliation will show it - the amounts still agree, only the date moves.
|
||||||
|
*/
|
||||||
|
public final class SavingsInterestCycleCalculator {
|
||||||
|
|
||||||
|
private SavingsInterestCycleCalculator() {}
|
||||||
|
|
||||||
|
// m_savings_product.interest_posting_period_enum / SavingsPostingInterestPeriodType
|
||||||
|
public static final int DAILY = 1;
|
||||||
|
public static final int MONTHLY = 4;
|
||||||
|
public static final int QUARTERLY = 5;
|
||||||
|
public static final int BIANNUAL = 6;
|
||||||
|
public static final int ANNUAL = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last day of the cycle that {@code within} falls in - the day interest is posted.
|
||||||
|
*
|
||||||
|
* @param within
|
||||||
|
* any day inside the cycle
|
||||||
|
* @param postingPeriodEnum
|
||||||
|
* the account's posting period
|
||||||
|
* @param financialYearBeginningMonth
|
||||||
|
* 1-12, from the financial-year-beginning-month configuration
|
||||||
|
*/
|
||||||
|
public static LocalDate cycleEndDate(final LocalDate within, final int postingPeriodEnum,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
switch (postingPeriodEnum) {
|
||||||
|
case DAILY:
|
||||||
|
return within;
|
||||||
|
case MONTHLY:
|
||||||
|
return within.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
case QUARTERLY:
|
||||||
|
return periodEndOnGrid(within, financialYearBeginningMonth, 3);
|
||||||
|
case BIANNUAL:
|
||||||
|
return periodEndOnGrid(within, financialYearBeginningMonth, 6);
|
||||||
|
case ANNUAL:
|
||||||
|
return periodEndOnGrid(within, financialYearBeginningMonth, 12);
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("Unsupported savings interest posting period: " + postingPeriodEnum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first day of the cycle that {@code within} falls in, ignoring activation. Callers hold
|
||||||
|
* the account's start date and take the later of the two, which is what makes cycle 1 a stub.
|
||||||
|
*/
|
||||||
|
public static LocalDate cycleStartDate(final LocalDate within, final int postingPeriodEnum,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
if (postingPeriodEnum == DAILY) {
|
||||||
|
return within;
|
||||||
|
}
|
||||||
|
final LocalDate previousEnd = cycleEndDate(within, postingPeriodEnum, financialYearBeginningMonth);
|
||||||
|
// The cycle containing `within` starts the day after the previous cycle ended.
|
||||||
|
return cycleEndDate(previousEnd.minusMonths(monthsIn(postingPeriodEnum)), postingPeriodEnum,
|
||||||
|
financialYearBeginningMonth).plusDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cycle after the one ending on {@code cycleEnd}. */
|
||||||
|
public static LocalDate nextCycleEndDate(final LocalDate cycleEnd, final int postingPeriodEnum,
|
||||||
|
final int financialYearBeginningMonth) {
|
||||||
|
return cycleEndDate(cycleEnd.plusDays(1), postingPeriodEnum, financialYearBeginningMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int monthsIn(final int postingPeriodEnum) {
|
||||||
|
switch (postingPeriodEnum) {
|
||||||
|
case MONTHLY:
|
||||||
|
return 1;
|
||||||
|
case QUARTERLY:
|
||||||
|
return 3;
|
||||||
|
case BIANNUAL:
|
||||||
|
return 6;
|
||||||
|
case ANNUAL:
|
||||||
|
return 12;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the fixed grid of period ends starting at the financial year month and returns the
|
||||||
|
* first one on or after {@code within}.
|
||||||
|
*
|
||||||
|
* The grid is built from the financial year start rather than from January, so a year
|
||||||
|
* beginning in April gives quarter ends of 30 Jun, 30 Sep, 31 Dec, 31 Mar - the same dates
|
||||||
|
* the engine produces for the same configuration.
|
||||||
|
*/
|
||||||
|
private static LocalDate periodEndOnGrid(final LocalDate within, final int financialYearBeginningMonth,
|
||||||
|
final int monthsPerPeriod) {
|
||||||
|
// Last day of the month before the financial year starts, in the year `within` falls in.
|
||||||
|
LocalDate gridAnchor = within.withDayOfMonth(1).withMonth(financialYearBeginningMonth).minusDays(1);
|
||||||
|
if (gridAnchor.isAfter(within)) {
|
||||||
|
gridAnchor = gridAnchor.minusYears(1);
|
||||||
|
}
|
||||||
|
LocalDate candidate = gridAnchor;
|
||||||
|
// At most 12/monthsPerPeriod steps land on or past `within`; the extra step guards the
|
||||||
|
// case where the anchor itself is the answer.
|
||||||
|
for (int i = 0; i <= 12 / monthsPerPeriod; i++) {
|
||||||
|
if (!candidate.isBefore(within)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
candidate = candidate.plusDays(1).plusMonths(monthsPerPeriod).minusDays(1)
|
||||||
|
.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays a savings product's accrued interest to its members, pooled through two control accounts.
|
||||||
|
*
|
||||||
|
* Stock Fineract posts interest one member at a time - debit Interest on Savings, credit Savings
|
||||||
|
* Control, per account - and its posting job spreads that work across a thread pool, each batch in
|
||||||
|
* its own transaction. That is sound double entry, but it has no seam a failure can be caught on:
|
||||||
|
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
||||||
|
* holds the figure that would say so.
|
||||||
|
*
|
||||||
|
* This posts the product's total first, into Savings Interest Posting Control, moves it to Savings
|
||||||
|
* Interest Posted Control, and only then pays it out to members. The whole product settles inside
|
||||||
|
* one transaction, and both control accounts have to read zero before it is allowed to commit. A
|
||||||
|
* failure rolls the product back entirely - nobody is half paid - and a run that never reached
|
||||||
|
* commit leaves a PENDING row naming the product and date for the next run to pick up.
|
||||||
|
*
|
||||||
|
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
|
||||||
|
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
|
||||||
|
* ends a few paise adrift and that money is simply gone. Here the residual is worked out and given
|
||||||
|
* to the largest holding - the account least distorted by it - so the pool closes exactly and the
|
||||||
|
* amount that moved is written down in the run record.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SavingsPooledInterestPostingService {
|
||||||
|
|
||||||
|
/** Two decimals: what the member is actually credited. */
|
||||||
|
private static final int MONEY_SCALE = 2;
|
||||||
|
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||||
|
|
||||||
|
private static final String GL_POSTING_CONTROL = "200304";
|
||||||
|
private static final String GL_POSTED_CONTROL = "200305";
|
||||||
|
|
||||||
|
/** m_savings_account_transaction.transaction_type_enum */
|
||||||
|
private static final int INTEREST_POSTING = 3;
|
||||||
|
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
|
||||||
|
private static final int ENTITY_TYPE_SAVINGS = 2;
|
||||||
|
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one product's interest for one posting date.
|
||||||
|
*
|
||||||
|
* REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other
|
||||||
|
* product's completed run down with it.
|
||||||
|
*
|
||||||
|
* @return the amount distributed, or zero if there was nothing to do
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
|
||||||
|
final Long completed = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_savings_interest_posting_run
|
||||||
|
WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
|
||||||
|
""", Long.class, productId, postingDate);
|
||||||
|
if (completed != null && completed > 0) {
|
||||||
|
log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The accounts due today, with what they accrued. Anything already carrying a posting
|
||||||
|
// transaction is skipped, so a resumed run cannot pay somebody a second time.
|
||||||
|
// A savings account has no office of its own: it belongs to a client, or to a group when
|
||||||
|
// there is no client, and the office is theirs. The journal entries have to be raised
|
||||||
|
// against that office or they land in the wrong branch's books.
|
||||||
|
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
|
||||||
|
d.cycle_cumulative_interest AS gross_interest,
|
||||||
|
COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
|
||||||
|
sa.currency_digits
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true
|
||||||
|
AND d.savings_transaction_id IS NULL
|
||||||
|
AND d.cycle_cumulative_interest > 0
|
||||||
|
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||||
|
ORDER BY d.savings_account_id
|
||||||
|
""", productId, postingDate);
|
||||||
|
|
||||||
|
if (due.isEmpty()) {
|
||||||
|
log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
final long runId = openRun(productId, postingDate, due.size());
|
||||||
|
|
||||||
|
// Round each member's share, then make the rounded shares add up. The residual is the
|
||||||
|
// difference between what the pool holds and what the members would otherwise receive.
|
||||||
|
BigDecimal grossTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal roundedTotal = BigDecimal.ZERO;
|
||||||
|
for (final Map<String, Object> row : due) {
|
||||||
|
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
|
||||||
|
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
row.put("rounded_interest", rounded);
|
||||||
|
grossTotal = grossTotal.add(gross);
|
||||||
|
roundedTotal = roundedTotal.add(rounded);
|
||||||
|
}
|
||||||
|
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
final BigDecimal residual = pooled.subtract(roundedTotal);
|
||||||
|
|
||||||
|
if (residual.compareTo(BigDecimal.ZERO) != 0) {
|
||||||
|
// To the largest balance: the account where a paisa is proportionally smallest.
|
||||||
|
final Map<String, Object> largest = due.stream()
|
||||||
|
.max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
|
||||||
|
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
|
||||||
|
log.info("Savings interest posting: residual {} assigned to account {}", residual,
|
||||||
|
largest.get("savings_account_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long postingControlGl = glAccountId(GL_POSTING_CONTROL);
|
||||||
|
final Long postedControlGl = glAccountId(GL_POSTED_CONTROL);
|
||||||
|
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
|
||||||
|
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
||||||
|
final String currency = (String) due.get(0).get("currency_code");
|
||||||
|
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
||||||
|
|
||||||
|
// Stage 1: the product's whole interest bill, recognised and pooled.
|
||||||
|
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
|
||||||
|
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
|
||||||
|
|
||||||
|
// Stage 2: the pool moves to the account it is paid out of.
|
||||||
|
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
|
||||||
|
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
|
||||||
|
|
||||||
|
// Stage 3: out to the members, one at a time.
|
||||||
|
BigDecimal distributed = BigDecimal.ZERO;
|
||||||
|
for (final Map<String, Object> row : due) {
|
||||||
|
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final long accountId = ((Number) row.get("savings_account_id")).longValue();
|
||||||
|
final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
|
||||||
|
// The member's own branch, which need not be the branch the pool was raised in.
|
||||||
|
final long accountOfficeId = ((Number) row.get("office_id")).longValue();
|
||||||
|
|
||||||
|
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
||||||
|
newBalance);
|
||||||
|
|
||||||
|
journalEntry(accountOfficeId, currency, postedControlGl, postingDate, amount, true, poolReference,
|
||||||
|
accountId);
|
||||||
|
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||||
|
accountId, transactionId);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_daily_interest
|
||||||
|
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
||||||
|
closing_balance = closing_balance + ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_account
|
||||||
|
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
||||||
|
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", amount, amount, accountId);
|
||||||
|
|
||||||
|
distributed = distributed.add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the two control accounts: if this does not hold, nothing commits.
|
||||||
|
if (pooled.compareTo(distributed) != 0) {
|
||||||
|
throw new IllegalStateException(String.format(
|
||||||
|
"Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
|
||||||
|
productId, postingDate, pooled, distributed));
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_run
|
||||||
|
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
|
||||||
|
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""", pooled, distributed, residual, runId);
|
||||||
|
|
||||||
|
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
|
||||||
|
postingDate, distributed, due.size(), residual);
|
||||||
|
return distributed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_interest_posting_run
|
||||||
|
(savings_product_id, posting_date, account_count, status)
|
||||||
|
VALUES (?, ?, ?, 'PENDING')
|
||||||
|
ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
|
||||||
|
account_count = EXCLUDED.account_count, status = 'PENDING',
|
||||||
|
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
|
||||||
|
""", productId, postingDate, accountCount);
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
SELECT id FROM m_savings_interest_posting_run
|
||||||
|
WHERE savings_product_id = ? AND posting_date = ?
|
||||||
|
""", Long.class, productId, postingDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
|
||||||
|
final BigDecimal amount, final BigDecimal runningBalance) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_account_transaction
|
||||||
|
(savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction,
|
||||||
|
transaction_type_enum, transaction_date, amount, running_balance_derived,
|
||||||
|
submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc)
|
||||||
|
VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
""", accountId, officeId, INTEREST_POSTING, date, amount, runningBalance, date, systemUserId(),
|
||||||
|
systemUserId());
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
SELECT id FROM m_savings_account_transaction
|
||||||
|
WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ?
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""", Long.class, accountId, date, INTEREST_POSTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The member's own leg: the product's Savings Control account, credited per account. */
|
||||||
|
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
|
||||||
|
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
|
||||||
|
final long transactionId) {
|
||||||
|
final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL
|
||||||
|
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||||
|
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
|
||||||
|
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||||
|
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||||
|
final Long transactionId) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO acc_gl_journal_entry
|
||||||
|
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
||||||
|
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
|
||||||
|
savings_transaction_id, reversed, manual_entry, office_running_balance,
|
||||||
|
organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date,
|
||||||
|
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||||
|
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||||
|
""", officeId, glAccountId, currency, reference, date, date, debit ? 1 : 2, amount,
|
||||||
|
debit ? "Savings interest posting" : "Savings interest posted",
|
||||||
|
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||||
|
systemUserId(), systemUserId(), date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user these rows are attributed to. The job runs without anybody logged in, and the audit
|
||||||
|
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
|
||||||
|
*/
|
||||||
|
private Long systemUserId() {
|
||||||
|
final List<Long> ids = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class);
|
||||||
|
return ids.isEmpty() ? 1L : ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long glAccountId(final String glCode) {
|
||||||
|
return jdbcTemplate.queryForObject("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long mappedGlAccount(final long productId, final int financialAccountType) {
|
||||||
|
return jdbcTemplate.queryForObject("""
|
||||||
|
SELECT gl_account_id FROM acc_product_mapping
|
||||||
|
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", Long.class, productId, financialAccountType);
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the savings daily interest ledger, per product and per account.
|
||||||
|
*
|
||||||
|
* The per-product listing is the view this was built for: pick a product, see its accounts and
|
||||||
|
* where each one is in its cycle. The per-account listing is the working itself - a day per row,
|
||||||
|
* the balance it was taken on, and the running total resetting to zero the day interest is paid.
|
||||||
|
*/
|
||||||
|
@Path("/v1/savingsdailyinterest")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class SavingsDailyInterestApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final SavingsDailyInterestService accrualService;
|
||||||
|
private final SavingsDailyInterestOrchestrator orchestrator;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/** The savings products carrying a ledger, with where each one is in its timetable. */
|
||||||
|
@GET
|
||||||
|
@Path("/products")
|
||||||
|
public String products() {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT p.id, p.name, p.short_name, p.nominal_annual_interest_rate AS rate,
|
||||||
|
s.posting_period_enum, s.run_at_time, s.last_posting_date, s.next_posting_date,
|
||||||
|
s.last_run_status, s.is_active,
|
||||||
|
(SELECT COUNT(*) FROM m_savings_account sa
|
||||||
|
WHERE sa.product_id = p.id AND sa.status_enum = 300) AS active_accounts,
|
||||||
|
(SELECT COALESCE(SUM(d.cycle_cumulative_interest), 0)
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
WHERE d.savings_product_id = p.id
|
||||||
|
AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = p.id)) AS accrued_this_cycle
|
||||||
|
FROM m_savings_product p
|
||||||
|
LEFT JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id
|
||||||
|
WHERE p.deposit_type_enum = 100
|
||||||
|
ORDER BY p.id
|
||||||
|
""");
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One product's accounts as at the latest day the ledger holds. */
|
||||||
|
@GET
|
||||||
|
@Path("/products/{productId}/accounts")
|
||||||
|
public String accountsForProduct(@PathParam("productId") final Long productId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.savings_account_id, sa.account_no, c.display_name AS client_name,
|
||||||
|
d.balance_date, d.opening_balance, d.closing_balance, d.daily_interest,
|
||||||
|
d.cycle_cumulative_interest, d.cycle_number, d.cycle_start_date, d.cycle_end_date,
|
||||||
|
d.below_min_balance
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
WHERE d.savings_product_id = ?
|
||||||
|
AND d.balance_date = (SELECT MAX(balance_date) FROM m_savings_daily_interest
|
||||||
|
WHERE savings_product_id = ?)
|
||||||
|
ORDER BY sa.account_no
|
||||||
|
""", productId, productId);
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account's day-by-day working, newest first. */
|
||||||
|
@GET
|
||||||
|
@Path("/accounts/{accountId}")
|
||||||
|
public String accountLedger(@PathParam("accountId") final Long accountId,
|
||||||
|
@QueryParam("limit") final Integer limit) {
|
||||||
|
final int rowLimit = limit == null || limit <= 0 ? 200 : Math.min(limit, 2000);
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT balance_date, opening_balance, closing_balance, daily_interest,
|
||||||
|
cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date,
|
||||||
|
is_posting_day, posted_amount, savings_transaction_id, below_min_balance,
|
||||||
|
interest_rate, days_in_year, min_balance_for_interest, last_recomputed_on
|
||||||
|
FROM m_savings_daily_interest
|
||||||
|
WHERE savings_account_id = ?
|
||||||
|
ORDER BY balance_date DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", accountId, rowLimit);
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each posting run pooled and paid, and whether it finished. */
|
||||||
|
@GET
|
||||||
|
@Path("/runs")
|
||||||
|
public String runs(@QueryParam("productId") final Long productId) {
|
||||||
|
final String sql = """
|
||||||
|
SELECT r.id, r.savings_product_id, p.name AS product_name, r.posting_date, r.account_count,
|
||||||
|
r.pooled_amount, r.distributed_amount, r.rounding_residual, r.status,
|
||||||
|
r.failure_reason, r.started_at, r.completed_at
|
||||||
|
FROM m_savings_interest_posting_run r
|
||||||
|
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||||
|
""" + (productId == null ? "" : " WHERE r.savings_product_id = ? ")
|
||||||
|
+ " ORDER BY r.posting_date DESC, r.savings_product_id";
|
||||||
|
final List<Map<String, Object>> rows = productId == null ? jdbcTemplate.queryForList(sql)
|
||||||
|
: jdbcTemplate.queryForList(sql, productId);
|
||||||
|
return gson.toJson(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chain check and the unposted-total check. An empty list is the answer you want: every
|
||||||
|
* day picks up where the last one left off, and the ledger still agrees with the accounts.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/reconcile")
|
||||||
|
public String reconcile() {
|
||||||
|
final List<String> problems = accrualService.reconcile();
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("clean", problems.isEmpty());
|
||||||
|
result.put("problemCount", problems.size());
|
||||||
|
result.put("problems", problems);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a day by hand, for a first backfill or after fixing a failed night. */
|
||||||
|
@POST
|
||||||
|
@Path("/run")
|
||||||
|
public String run(@QueryParam("date") final String dateParam) {
|
||||||
|
final LocalDate businessDate = dateParam == null || dateParam.isBlank()
|
||||||
|
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
|
||||||
|
: LocalDate.parse(dateParam);
|
||||||
|
orchestrator.runForDate(businessDate);
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("ranFor", businessDate.toString());
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the ledger for every account from activation to the given date. */
|
||||||
|
@POST
|
||||||
|
@Path("/backfill")
|
||||||
|
public String backfill(@QueryParam("upTo") final String upToParam) {
|
||||||
|
final LocalDate upTo = upToParam == null || upToParam.isBlank()
|
||||||
|
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
|
||||||
|
: LocalDate.parse(upToParam);
|
||||||
|
final int rows = accrualService.backfillAll(upTo);
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("rowsWritten", rows);
|
||||||
|
result.put("upTo", upTo.toString());
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-1
@@ -97,6 +97,8 @@ import org.apache.fineract.portfolio.note.domain.Note;
|
|||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||||
@@ -139,6 +141,8 @@ import org.springframework.util.CollectionUtils;
|
|||||||
public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService {
|
public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService {
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
private static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
|
||||||
private final SavingsAccountDataValidator fromApiJsonDeserializer;
|
private final SavingsAccountDataValidator fromApiJsonDeserializer;
|
||||||
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
|
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
|
||||||
private final StaffRepositoryWrapper staffRepository;
|
private final StaffRepositoryWrapper staffRepository;
|
||||||
@@ -344,6 +348,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.noteRepository.save(note);
|
this.noteRepository.save(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Money arriving at the counter: cash is debited and the member's savings control account
|
||||||
|
// is credited.
|
||||||
|
final String depositVoucherNo = raiseSavingsVoucher(account, deposit.getId(), transactionAmount, true);
|
||||||
|
if (depositVoucherNo != null) {
|
||||||
|
changes.put("voucherNo", depositVoucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(deposit.getId()) //
|
.withEntityId(deposit.getId()) //
|
||||||
.withOfficeId(account.officeId()) //
|
.withOfficeId(account.officeId()) //
|
||||||
@@ -420,6 +431,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.noteRepository.save(note);
|
this.noteRepository.save(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Money leaving the member over the counter: their savings control account is debited and
|
||||||
|
// the till that pays it out is credited.
|
||||||
|
final String withdrawalVoucherNo = raiseSavingsVoucher(account, withdrawal.getId(), transactionAmount, false);
|
||||||
|
if (withdrawalVoucherNo != null) {
|
||||||
|
changes.put("voucherNo", withdrawalVoucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(withdrawal.getId()) //
|
.withEntityId(withdrawal.getId()) //
|
||||||
.withOfficeId(account.officeId()) //
|
.withOfficeId(account.officeId()) //
|
||||||
@@ -584,6 +602,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
this.savingAccountRepositoryWrapper.saveAndFlush(account);
|
||||||
|
|
||||||
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
|
// Interest credited to the member, recorded against the transactions this run created.
|
||||||
|
// existingTransactionIds is what the account held before posting, so anything not in it
|
||||||
|
// is new — which is how a re-run over an account that already had interest posted last
|
||||||
|
// quarter raises a voucher for this quarter's credit only.
|
||||||
|
raiseInterestVouchers(account, existingTransactionIds, backdatedTxnsAllowedTill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2008,4 +2032,79 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
|
|||||||
throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory");
|
throw new PlatformDataIntegrityException("Reason For Block is Mandatory", "error.msg.reason.for.block.mandatory");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private void raiseInterestVouchers(final SavingsAccount account, final Set<Long> existingTransactionIds,
|
||||||
|
final boolean backdatedTxnsAllowedTill) {
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<SavingsAccountTransaction> transactions = backdatedTxnsAllowedTill
|
||||||
|
? account.getSavingsAccountTransactionsWithPivotConfig()
|
||||||
|
: account.getTransactions();
|
||||||
|
|
||||||
|
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
|
||||||
|
final Map<String, Object> expense = this.voucherService.savingsInterestExpenseAccount(account.getId());
|
||||||
|
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
|
||||||
|
final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue();
|
||||||
|
|
||||||
|
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
|
||||||
|
final String partyName = party != null ? party : (String) control.get("name");
|
||||||
|
final String accountNo = account.getAccountNumber();
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
|
||||||
|
for (final SavingsAccountTransaction transaction : transactions) {
|
||||||
|
if (transaction.getId() == null || existingTransactionIds.contains(transaction.getId())
|
||||||
|
|| !transaction.isInterestPostingAndNotReversed()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final BigDecimal amount = transaction.getAmount();
|
||||||
|
if (amount == null || amount.signum() <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
|
||||||
|
transaction.getId(), currentUser, VoucherService.PREFIX_CREDIT,
|
||||||
|
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
|
||||||
|
.withDescription("Interest posted to " + accountNo),
|
||||||
|
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
|
||||||
|
.withDescription("Interest credited")));
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise interest vouchers for savings account {}", account.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String raiseSavingsVoucher(final SavingsAccount account, final Long transactionId, final BigDecimal amount,
|
||||||
|
final boolean isDeposit) {
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
|
||||||
|
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
|
||||||
|
|
||||||
|
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
|
||||||
|
final String accountNo = account.getAccountNumber();
|
||||||
|
|
||||||
|
final VoucherLine memberSide = isDeposit
|
||||||
|
? VoucherLine.creditGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount)
|
||||||
|
: VoucherLine.debitGl(controlGlId, party != null ? party : (String) control.get("name"), accountNo, amount);
|
||||||
|
|
||||||
|
final VoucherLine cashSide = isDeposit ? VoucherLine.debitGl(null, "Cash in Hand", null, amount)
|
||||||
|
: VoucherLine.creditGl(null, "Cash in Hand", null, amount);
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = isDeposit ? List.of(cashSide, memberSide) : List.of(memberSide, cashSide);
|
||||||
|
|
||||||
|
return this.voucherService.createVoucherWithLines(
|
||||||
|
isDeposit ? VoucherService.TYPE_DEPOSIT : VoucherService.TYPE_WITHDRAWAL, REF_SAVINGS_TRANSACTION, transactionId,
|
||||||
|
currentUser, isDeposit ? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for savings transaction {}", transactionId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -69,6 +69,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
|
|||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepository;
|
||||||
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
import org.apache.fineract.portfolio.note.domain.NoteRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||||
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
|
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
|
||||||
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
||||||
@@ -463,9 +464,10 @@ public class SavingsConfiguration {
|
|||||||
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
|
||||||
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
|
||||||
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
|
||||||
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
|
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) {
|
||||||
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
|
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer,
|
||||||
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
|
savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler,
|
||||||
|
savingsAccountTransactionDataValidator,
|
||||||
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
|
||||||
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
|
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
|
||||||
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
|
||||||
|
|||||||
+11
-16
@@ -41,7 +41,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
|
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
|
||||||
private static final int FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME = 400;
|
/** Retained profit, where a dividend nobody claimed returns to. */
|
||||||
|
private static final String PROFIT_GL_CODE = "BANKPROFIT01";
|
||||||
|
|
||||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||||
@@ -117,13 +118,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* Dr Dividend Payable — the obligation ends; it was never going to be paid
|
* Dr Dividend Payable — the obligation ends; it was never going to be paid
|
||||||
* Cr Dividend Forfeited Income
|
* Cr Bank Profit
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* Recognised as income rather than credited to equity directly, so the recovery appears in that year's profit and
|
* Credited straight to Bank Profit rather than routed through an income account: the money never left the society,
|
||||||
* loss where it can be seen and reported. The year-end closing job then closes it into Bank Profit along with every
|
* so this is a liability that turned out not to be owed, and it returns directly to the retained profit the
|
||||||
* other income account, which is the same equity account the dividend was declared out of — so the money completes
|
* dividend was declared out of.
|
||||||
* its round trip, by way of the P&L.
|
|
||||||
*
|
*
|
||||||
* Currency comes straight off the lock row (set from the share account when the lock was created), so this never
|
* Currency comes straight off the lock row (set from the share account when the lock was created), so this never
|
||||||
* depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the
|
* depended on a linked savings account existing. Skipped if the GL setup is incomplete, exactly like the
|
||||||
@@ -134,15 +134,10 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
.stream().findFirst().orElse(null);
|
.stream().findFirst().orElse(null);
|
||||||
// Credited to Dividend Forfeited Income, which the year-end closing job then closes into Bank
|
final Long bankProfitAccountId = this.jdbcTemplate
|
||||||
// Profit along with every other income account — so the money still ends up back in the
|
.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst()
|
||||||
// equity account the dividend was declared out of, but by way of the P&L, where a forfeited
|
.orElse(null);
|
||||||
// dividend is visible as that year's income rather than appearing in equity with no trace.
|
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
|
||||||
final Long forfeitedIncomeAccountId = this.jdbcTemplate
|
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
|
||||||
FINANCIAL_ACTIVITY_DIVIDEND_FORFEITED_INCOME)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
if (payableDividendsAccountId == null || forfeitedIncomeAccountId == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +159,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
|
|||||||
// Debit first, then credit, so the entry ids follow the direction the money travelled.
|
// Debit first, then credit, so the entry ids follow the direction the money travelled.
|
||||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
|
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_DEBIT,
|
||||||
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
this.jdbcTemplate.update(insertSql, forfeitedIncomeAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT,
|
this.jdbcTemplate.update(insertSql, bankProfitAccountId, currencyCode, transactionId, sqlEntryDate, JOURNAL_ENTRY_TYPE_CREDIT,
|
||||||
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
amount, description, currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-97
@@ -59,9 +59,13 @@ import org.springframework.stereotype.Component;
|
|||||||
* (7.1 LINK SHARE, 7.2 SHARE) and the per-member allocation runs per product. What is shared is the decision: one
|
* (7.1 LINK SHARE, 7.2 SHARE) and the per-member allocation runs per product. What is shared is the decision: one
|
||||||
* rate, one period, applied to every product that has members holding shares in it.
|
* rate, one period, applied to every product that has members holding shares in it.
|
||||||
*
|
*
|
||||||
* The declarations are created through the existing per-product command, so the whole downstream chain — statutory
|
* The declarations are created through the existing per-product command, so the whole downstream chain — rounding, the
|
||||||
* appropriations, rounding, the residue sweep, the ceiling check against distributable profit — behaves exactly as it
|
* residue sweep, the funding check against available profit — behaves exactly as it does for a single-product
|
||||||
* does for a single-product declaration.
|
* declaration.
|
||||||
|
*
|
||||||
|
* The rate is applied to share capital and to nothing else. Profit does not size the dividend — it only funds it: the
|
||||||
|
* amount that falls out of rate x capital is drawn from the Bank Profit (Non-Distributable) GL, and the only thing
|
||||||
|
* profit decides is whether there is enough there to pay it.
|
||||||
*/
|
*/
|
||||||
@Path("/v1/shareproducts/dividends")
|
@Path("/v1/shareproducts/dividends")
|
||||||
@Component
|
@Component
|
||||||
@@ -75,13 +79,15 @@ public class AllProductsDividendApiResource {
|
|||||||
|
|
||||||
/** The account a dividend is declared against, once year-end closing has landed the profit in it. */
|
/** The account a dividend is declared against, once year-end closing has landed the profit in it. */
|
||||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
||||||
private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage";
|
|
||||||
private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage";
|
|
||||||
|
|
||||||
/** Every member receives the declared rate on their own capital — what an AGM resolution promises. */
|
/**
|
||||||
|
* A dividend is a rate on each member's own paid-up capital, and nothing else.
|
||||||
|
*
|
||||||
|
* The amount is derived entirely from what members invested and for how long — profit does not size it. Profit is
|
||||||
|
* only the source the payout is funded from, checked separately so the society cannot commit money it does not
|
||||||
|
* hold. Kept as a constant because the stored declarations still carry a basis field.
|
||||||
|
*/
|
||||||
private static final String BASIS_RATE_ON_CAPITAL = "RATE_ON_CAPITAL";
|
private static final String BASIS_RATE_ON_CAPITAL = "RATE_ON_CAPITAL";
|
||||||
/** The pool is a share of distributable profit, split across members afterwards. */
|
|
||||||
private static final String BASIS_PERCENT_OF_PROFIT = "PERCENT_OF_PROFIT";
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
@@ -137,8 +143,7 @@ public class AllProductsDividendApiResource {
|
|||||||
@GET
|
@GET
|
||||||
@Path("/preview")
|
@Path("/preview")
|
||||||
public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||||
@QueryParam("ratePercentage") final BigDecimal ratePercentage,
|
@QueryParam("ratePercentage") final BigDecimal ratePercentage) {
|
||||||
@QueryParam("dividendBasis") final String dividendBasis) {
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
|
||||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||||
@@ -149,26 +154,18 @@ public class AllProductsDividendApiResource {
|
|||||||
if (to.isBefore(from)) {
|
if (to.isBefore(from)) {
|
||||||
throw apiError(400, "toDate must not be before fromDate");
|
throw apiError(400, "toDate must not be before fromDate");
|
||||||
}
|
}
|
||||||
final String basis = normaliseBasis(dividendBasis);
|
|
||||||
|
|
||||||
final List<ProductCapital> capitals = capitalPerProduct(from, to);
|
final List<ProductCapital> capitals = capitalPerProduct(from, to);
|
||||||
final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add);
|
final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
// Not what sizes the dividend — only what funds it. Carried so the screen can warn before the
|
||||||
|
// officer commits, rather than letting the command layer reject a declaration after the fact.
|
||||||
final ProfitWaterfall waterfall = profitWaterfall(from, to);
|
final ProfitWaterfall waterfall = profitWaterfall(from, to);
|
||||||
|
|
||||||
// Under PERCENT_OF_PROFIT the pool is sized once from profit and then split between products in
|
|
||||||
// proportion to the capital each carries — a share of profit is a society-wide figure, so it
|
|
||||||
// cannot be computed per product the way a rate on capital can.
|
|
||||||
final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis) && ratePercentage != null
|
|
||||||
? percentageOf(waterfall.distributableProfit, ratePercentage)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final List<Map<String, Object>> products = new ArrayList<>();
|
final List<Map<String, Object>> products = new ArrayList<>();
|
||||||
BigDecimal grandTotal = BigDecimal.ZERO;
|
BigDecimal grandTotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
for (final ProductCapital capital : capitals) {
|
for (final ProductCapital capital : capitals) {
|
||||||
final BigDecimal amount = amountForProduct(basis, ratePercentage, capital.weightedCapital, totalWeightedCapital,
|
final BigDecimal amount = amountForProduct(ratePercentage, capital.weightedCapital);
|
||||||
profitPool);
|
|
||||||
|
|
||||||
final Map<String, Object> entry = new LinkedHashMap<>();
|
final Map<String, Object> entry = new LinkedHashMap<>();
|
||||||
entry.put("productId", capital.productId);
|
entry.put("productId", capital.productId);
|
||||||
@@ -187,13 +184,16 @@ public class AllProductsDividendApiResource {
|
|||||||
out.put("fromDate", fromDate);
|
out.put("fromDate", fromDate);
|
||||||
out.put("toDate", toDate);
|
out.put("toDate", toDate);
|
||||||
out.put("financialYear", financialYearOf(toDate));
|
out.put("financialYear", financialYearOf(toDate));
|
||||||
out.put("dividendBasis", basis);
|
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
|
||||||
out.put("ratePercentage", ratePercentage);
|
out.put("ratePercentage", ratePercentage);
|
||||||
out.put("totalWeightedCapital", totalWeightedCapital);
|
out.put("totalWeightedCapital", totalWeightedCapital);
|
||||||
out.put("profitGlCode", PROFIT_GL_CODE);
|
out.put("profitGlCode", PROFIT_GL_CODE);
|
||||||
// The whole waterfall, so the screen can show where the declarable figure came from rather
|
// The funding position, not the basis. Carried so a rate the profit account cannot cover is
|
||||||
// than presenting it as a number the officer has to take on trust.
|
// visible on screen before submission — the command layer enforces the same ceiling, and an
|
||||||
|
// officer should not discover it only when the declaration is rejected.
|
||||||
out.putAll(waterfall.asMap());
|
out.putAll(waterfall.asMap());
|
||||||
|
out.put("fundingAvailable", waterfall.distributableProfit);
|
||||||
|
out.put("fundingShortfall", grandTotal.subtract(waterfall.distributableProfit).max(BigDecimal.ZERO));
|
||||||
// What is left in the profit account after this declaration — the amount that will be swept
|
// What is left in the profit account after this declaration — the amount that will be swept
|
||||||
// to Bank Profit on approval, and which no later year's dividend can draw on.
|
// to Bank Profit on approval, and which no later year's dividend can draw on.
|
||||||
out.put("undistributedRemainder", waterfall.distributableProfit.subtract(grandTotal).max(BigDecimal.ZERO));
|
out.put("undistributedRemainder", waterfall.distributableProfit.subtract(grandTotal).max(BigDecimal.ZERO));
|
||||||
@@ -225,7 +225,6 @@ public class AllProductsDividendApiResource {
|
|||||||
}
|
}
|
||||||
final LocalDate from = parseDate(fromDate);
|
final LocalDate from = parseDate(fromDate);
|
||||||
final LocalDate to = parseDate(toDate);
|
final LocalDate to = parseDate(toDate);
|
||||||
final String basis = normaliseBasis(getString(json, "dividendBasis"));
|
|
||||||
|
|
||||||
final String rateText = getString(json, "ratePercentage");
|
final String rateText = getString(json, "ratePercentage");
|
||||||
if (rateText == null) {
|
if (rateText == null) {
|
||||||
@@ -237,10 +236,6 @@ public class AllProductsDividendApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<ProductCapital> capitals = capitalPerProduct(from, to);
|
final List<ProductCapital> capitals = capitalPerProduct(from, to);
|
||||||
final BigDecimal totalWeightedCapital = capitals.stream().map(c -> c.weightedCapital).reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
final BigDecimal profitPool = BASIS_PERCENT_OF_PROFIT.equals(basis)
|
|
||||||
? percentageOf(profitWaterfall(from, to).distributableProfit, rate)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final List<Map<String, Object>> created = new ArrayList<>();
|
final List<Map<String, Object>> created = new ArrayList<>();
|
||||||
final List<Map<String, Object>> skipped = new ArrayList<>();
|
final List<Map<String, Object>> skipped = new ArrayList<>();
|
||||||
@@ -260,7 +255,7 @@ public class AllProductsDividendApiResource {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool);
|
final BigDecimal amount = amountForProduct(rate, capital.weightedCapital);
|
||||||
if (amount.signum() <= 0) {
|
if (amount.signum() <= 0) {
|
||||||
skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product."));
|
skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product."));
|
||||||
continue;
|
continue;
|
||||||
@@ -281,6 +276,15 @@ public class AllProductsDividendApiResource {
|
|||||||
.withJson(payload.toString()).build();
|
.withJson(payload.toString()).build();
|
||||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command);
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command);
|
||||||
|
|
||||||
|
// The rate the board actually resolved, recorded as declared. Written here rather
|
||||||
|
// than carried through the command because the per-product command takes an amount,
|
||||||
|
// not a rate — and re-deriving the rate later (amount / weighted capital) drifts the
|
||||||
|
// moment any share movement lands in a closed period. The Dividend Distribution
|
||||||
|
// Report and the AGM minutes both quote this figure, so it has to be the one
|
||||||
|
// resolved, not one recomputed.
|
||||||
|
jdbcTemplate.update("UPDATE m_share_product_dividend_pay_out SET rate_percentage = ? WHERE id = ?", rate,
|
||||||
|
result.getSubResourceId());
|
||||||
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("productId", productId);
|
row.put("productId", productId);
|
||||||
row.put("productName", productName);
|
row.put("productName", productName);
|
||||||
@@ -341,27 +345,16 @@ public class AllProductsDividendApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What one product's declaration comes to, under whichever basis the officer chose.
|
* What one product's declaration comes to: the declared rate on the capital its members hold.
|
||||||
*
|
*
|
||||||
* RATE_ON_CAPITAL is what a rate means in an AGM resolution: every member receives that rate on their own capital,
|
* This is what a rate means in an AGM resolution — every member receives that rate on their own paid-up capital,
|
||||||
* so each product's amount follows directly from the capital its members hold.
|
* pro-rated for how long they held it. The society's profit plays no part in the arithmetic; it only has to be
|
||||||
*
|
* large enough to fund the result, which is checked separately at the command layer.
|
||||||
* PERCENT_OF_PROFIT sizes a single pool from the society's distributable profit, which is not a per-product figure
|
|
||||||
* at all. Splitting it in proportion to each product's weighted capital is what keeps a rupee of capital worth the
|
|
||||||
* same across products — any other split would pay two members with identical holdings different amounts purely
|
|
||||||
* because they hold different share classes.
|
|
||||||
*/
|
*/
|
||||||
private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital,
|
private BigDecimal amountForProduct(final BigDecimal ratePercentage, final BigDecimal weightedCapital) {
|
||||||
final BigDecimal totalWeightedCapital, final BigDecimal profitPool) {
|
|
||||||
if (ratePercentage == null || weightedCapital.signum() <= 0) {
|
if (ratePercentage == null || weightedCapital.signum() <= 0) {
|
||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
}
|
}
|
||||||
if (BASIS_PERCENT_OF_PROFIT.equals(basis)) {
|
|
||||||
if (profitPool == null || totalWeightedCapital.signum() <= 0) {
|
|
||||||
return BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
return profitPool.multiply(weightedCapital).divide(totalWeightedCapital, 2, RoundingMode.HALF_UP);
|
|
||||||
}
|
|
||||||
return percentageOf(weightedCapital, ratePercentage);
|
return percentageOf(weightedCapital, ratePercentage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,78 +362,47 @@ public class AllProductsDividendApiResource {
|
|||||||
return base.multiply(percentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
|
return base.multiply(percentage).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normaliseBasis(final String basis) {
|
|
||||||
return BASIS_PERCENT_OF_PROFIT.equalsIgnoreCase(basis) ? BASIS_PERCENT_OF_PROFIT : BASIS_RATE_ON_CAPITAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The appropriation waterfall for a period: net profit, what the statutory reserve and education fund take out of
|
* The profit available to fund a dividend for a period.
|
||||||
* it, and what is left for a dividend.
|
|
||||||
*
|
*
|
||||||
* Carried as a whole rather than collapsed to the final figure because the officer is approving a division of the
|
* Just the one figure, because that is all profit decides now: the dividend amount comes from members' share
|
||||||
* society's profit, not a single number. Seeing ₹2,22,000 alone gives no way to tell a small profit apart from a
|
* capital, and profit only has to be able to pay it. The statutory reserve and education fund are not deducted
|
||||||
* large one that has already been heavily appropriated — and the reserve and education percentages are
|
* here — they are appropriated on the profit-distribution screen before a dividend is declared, so the balance
|
||||||
* configurable, so the amounts they take cannot be inferred from the result either.
|
* this reads has already had them taken out.
|
||||||
*/
|
*/
|
||||||
private static final class ProfitWaterfall {
|
private static final class ProfitWaterfall {
|
||||||
|
|
||||||
private final BigDecimal netProfit;
|
private final BigDecimal netProfit;
|
||||||
private final BigDecimal statutoryReservePercentage;
|
|
||||||
private final BigDecimal statutoryReserveAmount;
|
|
||||||
private final BigDecimal educationFundPercentage;
|
|
||||||
private final BigDecimal educationFundAmount;
|
|
||||||
private final BigDecimal distributableProfit;
|
private final BigDecimal distributableProfit;
|
||||||
|
|
||||||
private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal statutoryReservePercentage,
|
private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal distributableProfit) {
|
||||||
final BigDecimal statutoryReserveAmount, final BigDecimal educationFundPercentage,
|
|
||||||
final BigDecimal educationFundAmount, final BigDecimal distributableProfit) {
|
|
||||||
this.netProfit = netProfit;
|
this.netProfit = netProfit;
|
||||||
this.statutoryReservePercentage = statutoryReservePercentage;
|
|
||||||
this.statutoryReserveAmount = statutoryReserveAmount;
|
|
||||||
this.educationFundPercentage = educationFundPercentage;
|
|
||||||
this.educationFundAmount = educationFundAmount;
|
|
||||||
this.distributableProfit = distributableProfit;
|
this.distributableProfit = distributableProfit;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> asMap() {
|
private Map<String, Object> asMap() {
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
out.put("netProfit", netProfit);
|
out.put("netProfit", netProfit);
|
||||||
out.put("statutoryReservePercentage", statutoryReservePercentage);
|
|
||||||
out.put("statutoryReserveAmount", statutoryReserveAmount);
|
|
||||||
out.put("educationFundPercentage", educationFundPercentage);
|
|
||||||
out.put("educationFundAmount", educationFundAmount);
|
|
||||||
out.put("distributableProfit", distributableProfit);
|
out.put("distributableProfit", distributableProfit);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The profit a dividend may be declared out of: the year's net profit less the statutory reserve and education
|
* What the profit account holds for the period, which is what a dividend can be funded from.
|
||||||
* fund, which are appropriated before any dividend.
|
|
||||||
*
|
*
|
||||||
* Read from the Bank Profit (Non-Distributable) GL once the year has been closed, so the dividend can never
|
* Read from the Bank Profit (Non-Distributable) GL once the year has been closed, so the dividend can never
|
||||||
* disagree with the closed books. An unclosed year has nothing declarable, which is reported as zero rather than
|
* disagree with the closed books. An unclosed year has nothing declarable, which is reported as zero rather than
|
||||||
* guessed at from a P&L that is still moving.
|
* guessed at from a P&L that is still moving. A loss-making year funds nothing, so it collapses to zero rather
|
||||||
|
* than a negative figure that would read as money owed back to the society.
|
||||||
*/
|
*/
|
||||||
private ProfitWaterfall profitWaterfall(final LocalDate from, final LocalDate to) {
|
private ProfitWaterfall profitWaterfall(final LocalDate from, final LocalDate to) {
|
||||||
final BigDecimal netProfit = netProfitForPeriod(from, to);
|
final BigDecimal netProfit = netProfitForPeriod(from, to);
|
||||||
|
|
||||||
final BigDecimal reservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE);
|
|
||||||
final BigDecimal educationPercentage = configuredPercentage(CONFIG_EDUCATION_FUND);
|
|
||||||
|
|
||||||
// Appropriations only ever reduce a profit. A loss-making year has nothing to appropriate and
|
|
||||||
// nothing to distribute, so the whole waterfall collapses to zero rather than producing a
|
|
||||||
// negative "reserve" that would read as money owed back to the society.
|
|
||||||
if (netProfit == null || netProfit.signum() <= 0) {
|
if (netProfit == null || netProfit.signum() <= 0) {
|
||||||
final BigDecimal zero = BigDecimal.ZERO.setScale(2);
|
final BigDecimal zero = BigDecimal.ZERO.setScale(2);
|
||||||
return new ProfitWaterfall(netProfit == null ? zero : netProfit, reservePercentage, zero, educationPercentage, zero,
|
return new ProfitWaterfall(netProfit == null ? zero : netProfit, zero);
|
||||||
zero);
|
|
||||||
}
|
}
|
||||||
|
return new ProfitWaterfall(netProfit, netProfit);
|
||||||
final BigDecimal reserve = percentageOf(netProfit, reservePercentage);
|
|
||||||
final BigDecimal educationFund = percentageOf(netProfit, educationPercentage);
|
|
||||||
return new ProfitWaterfall(netProfit, reservePercentage, reserve, educationPercentage, educationFund,
|
|
||||||
netProfit.subtract(reserve).subtract(educationFund).max(BigDecimal.ZERO));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -489,30 +451,27 @@ public class AllProductsDividendApiResource {
|
|||||||
return ((BigDecimal) row.get("total_income")).subtract((BigDecimal) row.get("total_expense"));
|
return ((BigDecimal) row.get("total_income")).subtract((BigDecimal) row.get("total_expense"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A configured appropriation rate, or zero when it has not been set. */
|
|
||||||
private BigDecimal configuredPercentage(final String configurationName) {
|
|
||||||
final List<BigDecimal> values = jdbcTemplate.queryForList(
|
|
||||||
"SELECT value FROM c_configuration WHERE name = ? AND enabled = true", BigDecimal.class, configurationName);
|
|
||||||
return values.isEmpty() || values.get(0) == null ? BigDecimal.ZERO : values.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paid-up capital x days held, across a product's eligible accounts.
|
* Paid-up capital x days held, across a product's eligible accounts.
|
||||||
*
|
*
|
||||||
* Mirrors {@code ShareProductDividendAssembler}: only approved purchases count, charge payments are excluded, a
|
* Mirrors {@code ShareProductDividendAssembler}: only approved purchases count, charge payments are excluded, a
|
||||||
* holding already in force at the period start is treated as starting there, and a purchase that has not cleared
|
* holding already in force at the period start is treated as starting there, and a purchase that has not cleared
|
||||||
* the product's minimum active period is skipped.
|
* the product's minimum active period is skipped.
|
||||||
|
*
|
||||||
|
* The {@code + 1} matches the assembler's: the period end is itself a day the shares were held, so a holding is
|
||||||
|
* measured to the day after it. Without it a full financial year counts as 364 days and a product asking for the
|
||||||
|
* obvious 365 would qualify nobody — and this preview would disagree with the declaration it is previewing.
|
||||||
*/
|
*/
|
||||||
private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to,
|
private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to,
|
||||||
final int minimumActivePeriod) {
|
final int minimumActivePeriod) {
|
||||||
final BigDecimal total = jdbcTemplate.queryForObject("""
|
final BigDecimal total = jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(t.amount * (? - GREATEST(t.transaction_date, ?))), 0)
|
SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0)
|
||||||
FROM m_share_account sa
|
FROM m_share_account sa
|
||||||
JOIN m_share_account_transactions t ON t.account_id = sa.id
|
JOIN m_share_account_transactions t ON t.account_id = sa.id
|
||||||
WHERE sa.product_id = ? AND sa.status_enum = 300
|
WHERE sa.product_id = ? AND sa.status_enum = 300
|
||||||
AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true
|
AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true
|
||||||
AND t.transaction_date <= ?
|
AND t.transaction_date <= ?
|
||||||
AND (? - GREATEST(t.transaction_date, ?)) >= ?
|
AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ?
|
||||||
""", BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to),
|
""", BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to),
|
||||||
java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod);
|
java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod);
|
||||||
return total == null ? BigDecimal.ZERO : total;
|
return total == null ? BigDecimal.ZERO : total;
|
||||||
|
|||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.shareproducts.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return on Members & Share Capital — the membership and share-capital position a co-operative bank reports to the
|
||||||
|
* RBI, per share type, with the previous year alongside for comparison.
|
||||||
|
*
|
||||||
|
* A supervisor reading this is asking three things of every share type the society offers: how many members hold it,
|
||||||
|
* how much capital they have put in, and how much of the authorised issue is still unsold. Movement matters as much as
|
||||||
|
* position — a society whose membership fell while its capital rose has done something quite different from one where
|
||||||
|
* both grew — so each figure is reported as opening, movement during the year, and closing, against the same three for
|
||||||
|
* the year before.
|
||||||
|
*
|
||||||
|
* Every figure is derived from share accounts and their transactions rather than from any stored summary, so the return
|
||||||
|
* cannot drift from the register it is meant to describe.
|
||||||
|
*/
|
||||||
|
@Path("/v1/shareproducts/capital-return")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Share Capital Return", description = "Members and share capital per share type, current year against previous, for RBI reporting.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class ShareCapitalReturnApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "SHAREPRODUCT";
|
||||||
|
/** Fineract share account/transaction status: 300 = ACTIVE / APPROVED. */
|
||||||
|
private static final int STATUS_ACTIVE = 300;
|
||||||
|
/** Share transaction type: 500 = purchase, 600 = redemption. */
|
||||||
|
private static final int TYPE_PURCHASE = 500;
|
||||||
|
private static final int TYPE_REDEEM = 600;
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return for one financial year, per share type, with the prior year beside it.
|
||||||
|
*
|
||||||
|
* The year is given as its start year — 2025 means FY 2025-26 — because a financial year is named by the year it
|
||||||
|
* opens in, and asking a caller to send two dates invites a period that is not a financial year at all.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
public Response report(@QueryParam("financialYear") final Integer financialYearStart) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
|
||||||
|
final int startYear = financialYearStart != null ? financialYearStart : currentFinancialYearStart();
|
||||||
|
final LocalDate from = LocalDate.of(startYear, 4, 1);
|
||||||
|
final LocalDate to = LocalDate.of(startYear + 1, 3, 31);
|
||||||
|
final LocalDate priorFrom = from.minusYears(1);
|
||||||
|
final LocalDate priorTo = to.minusYears(1);
|
||||||
|
|
||||||
|
final JsonArray products = new JsonArray();
|
||||||
|
for (final Map<String, Object> product : jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, name, short_name, currency_code, total_shares, issued_shares, unit_price, capital_amount
|
||||||
|
FROM m_share_product ORDER BY id
|
||||||
|
""")) {
|
||||||
|
final Long productId = ((Number) product.get("id")).longValue();
|
||||||
|
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("productId", productId);
|
||||||
|
entry.addProperty("shareType", (String) product.get("name"));
|
||||||
|
entry.addProperty("shortName", (String) product.get("short_name"));
|
||||||
|
entry.addProperty("currency", (String) product.get("currency_code"));
|
||||||
|
entry.addProperty("unitPrice", (BigDecimal) product.get("unit_price"));
|
||||||
|
|
||||||
|
// What the society is permitted to issue, against what it actually has. The gap is the
|
||||||
|
// headroom a supervisor checks a fresh issue against.
|
||||||
|
final long authorisedShares = asLong(product.get("total_shares"));
|
||||||
|
entry.addProperty("authorisedShares", authorisedShares);
|
||||||
|
entry.addProperty("authorisedCapital", (BigDecimal) product.get("capital_amount"));
|
||||||
|
|
||||||
|
entry.add("currentYear", positionFor(productId, from, to));
|
||||||
|
entry.add("previousYear", positionFor(productId, priorFrom, priorTo));
|
||||||
|
|
||||||
|
// Allotted out of authorised, as at the year end being reported — not today, or the
|
||||||
|
// return would answer a different question than the one it was asked.
|
||||||
|
final long allottedShares = sharesHeldAsAt(productId, to);
|
||||||
|
entry.addProperty("allottedShares", allottedShares);
|
||||||
|
entry.addProperty("unissuedShares", Math.max(0, authorisedShares - allottedShares));
|
||||||
|
entry.addProperty("utilisationPercentage", authorisedShares == 0 ? null
|
||||||
|
: BigDecimal.valueOf(allottedShares).multiply(BigDecimal.valueOf(100))
|
||||||
|
.divide(BigDecimal.valueOf(authorisedShares), 2, java.math.RoundingMode.HALF_UP));
|
||||||
|
products.add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("financialYear", startYear + "-" + String.format("%02d", (startYear + 1) % 100));
|
||||||
|
out.addProperty("previousFinancialYear",
|
||||||
|
(startYear - 1) + "-" + String.format("%02d", startYear % 100));
|
||||||
|
out.addProperty("periodStartDate", from.toString());
|
||||||
|
out.addProperty("periodEndDate", to.toString());
|
||||||
|
out.add("shareTypes", products);
|
||||||
|
return Response.ok(gson.toJson(out)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One share type's position over one financial year: members and capital at the start, what moved during it, and
|
||||||
|
* where both ended.
|
||||||
|
*
|
||||||
|
* Members are counted by distinct client rather than by account, because one member holding three share accounts is
|
||||||
|
* still one member — counting accounts would overstate the membership a supervisor is being told.
|
||||||
|
*/
|
||||||
|
private JsonObject positionFor(final Long productId, final LocalDate from, final LocalDate to) {
|
||||||
|
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date < ?) AS members_opening,
|
||||||
|
COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date BETWEEN ? AND ?) AS members_admitted,
|
||||||
|
COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.closed_date BETWEEN ? AND ?) AS members_ceased,
|
||||||
|
COUNT(DISTINCT sa.client_id) FILTER (WHERE sa.activated_date <= ?
|
||||||
|
AND (sa.closed_date IS NULL OR sa.closed_date > ?)) AS members_closing
|
||||||
|
FROM m_share_account sa
|
||||||
|
WHERE sa.product_id = ? AND sa.status_enum = ?
|
||||||
|
""", java.sql.Date.valueOf(from), java.sql.Date.valueOf(from), java.sql.Date.valueOf(to),
|
||||||
|
java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), java.sql.Date.valueOf(to), java.sql.Date.valueOf(to),
|
||||||
|
productId, STATUS_ACTIVE);
|
||||||
|
|
||||||
|
final Map<String, Object> capital = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN t.type_enum = ? THEN t.amount ELSE -t.amount END)
|
||||||
|
FILTER (WHERE t.transaction_date < ?), 0) AS capital_opening,
|
||||||
|
COALESCE(SUM(t.amount) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0)
|
||||||
|
AS capital_issued,
|
||||||
|
COALESCE(SUM(t.amount) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0)
|
||||||
|
AS capital_redeemed,
|
||||||
|
COALESCE(SUM(t.total_shares) FILTER (WHERE t.type_enum = ? AND t.transaction_date BETWEEN ? AND ?), 0)
|
||||||
|
AS shares_issued
|
||||||
|
FROM m_share_account sa
|
||||||
|
JOIN m_share_account_transactions t ON t.account_id = sa.id
|
||||||
|
WHERE sa.product_id = ? AND sa.status_enum = ?
|
||||||
|
AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?)
|
||||||
|
""", TYPE_PURCHASE, java.sql.Date.valueOf(from), TYPE_PURCHASE, java.sql.Date.valueOf(from),
|
||||||
|
java.sql.Date.valueOf(to), TYPE_REDEEM, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), TYPE_PURCHASE,
|
||||||
|
java.sql.Date.valueOf(from), java.sql.Date.valueOf(to), productId, STATUS_ACTIVE, STATUS_ACTIVE, TYPE_PURCHASE,
|
||||||
|
TYPE_REDEEM);
|
||||||
|
|
||||||
|
final BigDecimal opening = (BigDecimal) capital.get("capital_opening");
|
||||||
|
final BigDecimal issued = (BigDecimal) capital.get("capital_issued");
|
||||||
|
final BigDecimal redeemed = (BigDecimal) capital.get("capital_redeemed");
|
||||||
|
|
||||||
|
final JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("membersOpening", asLong(row.get("members_opening")));
|
||||||
|
out.addProperty("membersAdmitted", asLong(row.get("members_admitted")));
|
||||||
|
out.addProperty("membersCeased", asLong(row.get("members_ceased")));
|
||||||
|
out.addProperty("membersClosing", asLong(row.get("members_closing")));
|
||||||
|
out.addProperty("capitalOpening", opening);
|
||||||
|
out.addProperty("capitalIssued", issued);
|
||||||
|
out.addProperty("capitalRedeemed", redeemed);
|
||||||
|
// Closing is opening plus what moved, not a separate query: the two must agree, and deriving
|
||||||
|
// it is what guarantees they do.
|
||||||
|
out.addProperty("capitalClosing", opening.add(issued).subtract(redeemed));
|
||||||
|
out.addProperty("sharesIssued", asLong(capital.get("shares_issued")));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shares still held in this product as at a date — purchases less redemptions. */
|
||||||
|
private long sharesHeldAsAt(final Long productId, final LocalDate asAt) {
|
||||||
|
final Long held = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN t.total_shares ELSE -t.total_shares END), 0)
|
||||||
|
FROM m_share_account sa
|
||||||
|
JOIN m_share_account_transactions t ON t.account_id = sa.id
|
||||||
|
WHERE sa.product_id = ? AND sa.status_enum = ?
|
||||||
|
AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?)
|
||||||
|
AND t.transaction_date <= ?
|
||||||
|
""", Long.class, TYPE_PURCHASE, productId, STATUS_ACTIVE, STATUS_ACTIVE, TYPE_PURCHASE, TYPE_REDEEM,
|
||||||
|
java.sql.Date.valueOf(asAt));
|
||||||
|
return held == null ? 0L : held;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** April opens the Indian financial year, so anything before it still belongs to the prior one. */
|
||||||
|
private int currentFinancialYearStart() {
|
||||||
|
final LocalDate today = LocalDate.now(java.time.ZoneId.systemDefault());
|
||||||
|
return today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long asLong(final Object value) {
|
||||||
|
return value == null ? 0L : ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The financial years that actually have share activity, newest first, for the year picker. */
|
||||||
|
@GET
|
||||||
|
@Path("/financial-years")
|
||||||
|
public Response financialYears() {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
|
||||||
|
final List<Integer> years = jdbcTemplate.queryForList("""
|
||||||
|
SELECT DISTINCT CASE WHEN EXTRACT(MONTH FROM t.transaction_date) >= 4
|
||||||
|
THEN EXTRACT(YEAR FROM t.transaction_date)
|
||||||
|
ELSE EXTRACT(YEAR FROM t.transaction_date) - 1 END AS fy_start
|
||||||
|
FROM m_share_account_transactions t
|
||||||
|
WHERE t.is_active = true AND t.transaction_date IS NOT NULL
|
||||||
|
ORDER BY fy_start DESC
|
||||||
|
""", Integer.class);
|
||||||
|
|
||||||
|
final JsonArray out = new JsonArray();
|
||||||
|
// The current year is always offered even with no activity yet — a return is filed for the
|
||||||
|
// year that is running, not only for years that already have movement in them.
|
||||||
|
final int current = currentFinancialYearStart();
|
||||||
|
if (!years.contains(current)) {
|
||||||
|
out.add(yearEntry(current));
|
||||||
|
}
|
||||||
|
for (final Integer year : years) {
|
||||||
|
if (year != null) {
|
||||||
|
out.add(yearEntry(year));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.ok(gson.toJson(out)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject yearEntry(final int startYear) {
|
||||||
|
final JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("startYear", startYear);
|
||||||
|
entry.addProperty("label", startYear + "-" + String.format("%02d", (startYear + 1) % 100));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("defaultUserMessage", message);
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -200,7 +200,12 @@ public class ShareDividendCapitalPreviewApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final boolean isPurchase = ((Number) row.get("type_enum")).intValue() == 500;
|
final boolean isPurchase = ((Number) row.get("type_enum")).intValue() == 500;
|
||||||
if (isPurchase && java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) < minimumActivePeriod) {
|
// The + 1 matches ShareProductDividendAssembler: the period end is itself a day the
|
||||||
|
// shares were held, so the holding runs to the day after it. Without it a full financial
|
||||||
|
// year measures 364 days, a product asking for the obvious 365 qualifies nobody, and this
|
||||||
|
// preview disagrees with the declaration it is previewing.
|
||||||
|
final long daysHeld = java.time.temporal.ChronoUnit.DAYS.between(transactionDate, periodEnd) + 1;
|
||||||
|
if (isPurchase && daysHeld < minimumActivePeriod) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -154,7 +154,12 @@ public class ShareProductDividendAssembler {
|
|||||||
if (DateUtils.isBefore(shareStartDate, lastDividendPostDate)) {
|
if (DateUtils.isBefore(shareStartDate, lastDividendPostDate)) {
|
||||||
shareStartDate = lastDividendPostDate;
|
shareStartDate = lastDividendPostDate;
|
||||||
}
|
}
|
||||||
int numberOfPurchseDays = DateUtils.getExactDifferenceInDays(shareStartDate, postingDate);
|
// The period end is itself a day the member held the shares, so the holding is
|
||||||
|
// measured to the day AFTER it. Without the +1 a dividend period is one day
|
||||||
|
// short of the year it represents — 01-Apr-2025 to 31-Mar-2026 subtracts to 364 —
|
||||||
|
// and a product whose minimum active period is the obvious 365 could never pay a
|
||||||
|
// dividend for any single financial year, however long the member had held.
|
||||||
|
int numberOfPurchseDays = DateUtils.getExactDifferenceInDays(shareStartDate, postingDate) + 1;
|
||||||
if (type.isPurchased() && numberOfPurchseDays < minimumActivePeriod) {
|
if (type.isPurchased() && numberOfPurchseDays < minimumActivePeriod) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-99
@@ -91,6 +91,15 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
|
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
|
||||||
// accounting module into this constructor just for two int constants.
|
// accounting module into this constructor just for two int constants.
|
||||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many financial years of dividend a member may still come and claim.
|
||||||
|
*
|
||||||
|
* A society keeps an unclaimed dividend available for three years. Declaring a fourth year is the point the oldest
|
||||||
|
* one falls out of that window: the member had three full years of the society asking them to collect it, and the
|
||||||
|
* money is recognised as income rather than sitting as a liability nobody will ever call on.
|
||||||
|
*/
|
||||||
|
private static final int CLAIMABLE_FINANCIAL_YEARS = 3;
|
||||||
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
|
private static final int FINANCIAL_ACTIVITY_RESERVE_FOR_DIVIDEND = 301;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,11 +114,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
*/
|
*/
|
||||||
private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01";
|
private static final String PROFIT_SETTLED_GL_CODE = "BANKPROFIT01";
|
||||||
|
|
||||||
// Statutory appropriation funds, seeded by changelog 0478. Resolved by GL code rather than a
|
|
||||||
// financial activity because they are society-specific funds with no native Fineract activity.
|
|
||||||
private static final String STATUTORY_RESERVE_GL_CODE = "STATRES01";
|
|
||||||
private static final String EDUCATION_FUND_GL_CODE = "EDUFUND01";
|
|
||||||
|
|
||||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||||
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
|
||||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||||
@@ -122,9 +126,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String CONFIG_STATUTORY_RESERVE_PERCENTAGE = "dividend-statutory-reserve-percentage";
|
|
||||||
private static final String CONFIG_EDUCATION_FUND_PERCENTAGE = "dividend-education-fund-percentage";
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
|
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
|
||||||
try {
|
try {
|
||||||
@@ -203,8 +204,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
|
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
|
||||||
|
|
||||||
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
|
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
|
||||||
postStatutoryAppropriations(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendPeriodStartDate,
|
// The statutory reserve and education fund are NOT appropriated here. They are a division
|
||||||
dividendPeriodEndDate, financialYear);
|
// of the year's profit that happens on its own screen before any dividend is declared, so
|
||||||
|
// posting them again on declaration would move the same money twice.
|
||||||
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount,
|
postReserveForDividendJournalEntry(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendAmount,
|
||||||
financialYear);
|
financialYear);
|
||||||
recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount,
|
recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount,
|
||||||
@@ -282,8 +284,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
if (netProfit.signum() <= 0) {
|
if (netProfit.signum() <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final BigDecimal distributable = netProfit.subtract(percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE))
|
// The balance in the profit GL is already net of the statutory reserve and education fund —
|
||||||
.subtract(percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE));
|
// those were appropriated on the profit-distribution screen before this dividend was
|
||||||
|
// declared, so deducting them again would sweep away money that is no longer there.
|
||||||
|
final BigDecimal distributable = netProfit;
|
||||||
|
|
||||||
// Every declaration against an overlapping period, this one included — the pool is
|
// Every declaration against an overlapping period, this one included — the pool is
|
||||||
// society-wide, so a second product's dividend has already consumed part of it.
|
// society-wide, so a second product's dividend has already consumed part of it.
|
||||||
@@ -436,19 +440,22 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A co-operative society may only distribute what is left of net profit after the statutory appropriations the
|
* A dividend must not commit more money than the profit account holds.
|
||||||
* Co-operative Societies Act requires first — a Statutory Reserve Fund transfer and a state-federation
|
|
||||||
* education-fund contribution — and after every dividend already declared against an overlapping period for ANY
|
|
||||||
* share product, since the profit pool is society-wide rather than per-product. Without this, two products could
|
|
||||||
* each be declared 60% of profit and the society would have committed 120% of money it never had.
|
|
||||||
*
|
*
|
||||||
* The declaration screen shows the same waterfall (see {@code DistributableProfitApiResource}), but the ceiling is
|
* The amount itself is settled before this runs — it is the declared rate on members' paid-up capital, and profit
|
||||||
* enforced here because that screen is not the only way a declaration can be created, and a stale screen must not
|
* plays no part in working it out. This is purely a funding check: whatever the rate produced, the society has to
|
||||||
* be able to commit an over-declaration.
|
* actually have it.
|
||||||
*
|
*
|
||||||
* Appropriation rates come from c_configuration (changelog 0478); a missing or disabled row means the society has
|
* What it is checked against is the profit remaining in the Bank Profit GL, which is already net of the statutory
|
||||||
* an exemption from that appropriation, which is 0% rather than an error. If all rates are 0 and no other
|
* reserve and education fund — those are appropriated on the profit-distribution screen before any dividend is
|
||||||
* declaration overlaps, this reduces to "must not exceed net profit", which is still the right floor.
|
* declared, so deducting them again here would hold back money that has already left the account.
|
||||||
|
*
|
||||||
|
* Every dividend already declared against an overlapping period for ANY share product is subtracted too, since the
|
||||||
|
* profit is society-wide rather than per-product. Without that, two products could each be declared the whole
|
||||||
|
* balance and the society would have committed twice the money it had.
|
||||||
|
*
|
||||||
|
* The ceiling is enforced here rather than on the screen because that screen is not the only way a declaration can
|
||||||
|
* be created, and a stale screen must not be able to commit an over-declaration.
|
||||||
*/
|
*/
|
||||||
private void validateWithinDistributableProfit(final Long productId, final LocalDate dividendPeriodStartDate,
|
private void validateWithinDistributableProfit(final Long productId, final LocalDate dividendPeriodStartDate,
|
||||||
final LocalDate dividendPeriodEndDate, final BigDecimal dividendAmount) {
|
final LocalDate dividendPeriodEndDate, final BigDecimal dividendAmount) {
|
||||||
@@ -462,25 +469,20 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
+ " and " + dividendPeriodEndDate + ", so no dividend can be declared for this period.");
|
+ " and " + dividendPeriodEndDate + ", so no dividend can be declared for this period.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final BigDecimal statutoryReserve = percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE);
|
// Every product draws on the same profit, so this deliberately does NOT filter by product_id
|
||||||
final BigDecimal educationFund = percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE);
|
// — unlike validateNoOverlappingDividendPeriod, which is about paying the same product's
|
||||||
final BigDecimal distributableProfit = netProfit.subtract(statutoryReserve).subtract(educationFund);
|
// members twice for the same days.
|
||||||
|
|
||||||
// Every product competes for the same distributable profit, so this deliberately does NOT
|
|
||||||
// filter by product_id — unlike validateNoOverlappingDividendPeriod, which is about paying
|
|
||||||
// the same product's members twice for the same days.
|
|
||||||
final BigDecimal alreadyDeclared = jdbcTemplate.queryForObject("""
|
final BigDecimal alreadyDeclared = jdbcTemplate.queryForObject("""
|
||||||
SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out
|
SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out
|
||||||
WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ?
|
WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ?
|
||||||
""", BigDecimal.class, to, from);
|
""", BigDecimal.class, to, from);
|
||||||
|
|
||||||
final BigDecimal available = distributableProfit.subtract(alreadyDeclared);
|
final BigDecimal available = netProfit.subtract(alreadyDeclared);
|
||||||
if (dividendAmount.compareTo(available) > 0) {
|
if (dividendAmount.compareTo(available) > 0) {
|
||||||
throw new DividentProcessingException("exceeds.distributable.profit",
|
throw new DividentProcessingException("exceeds.distributable.profit",
|
||||||
"This dividend of " + dividendAmount + " exceeds what may be distributed for this period. Net profit " + netProfit
|
"This dividend of " + dividendAmount + " is more than the profit available to fund it for this period. Bank profit "
|
||||||
+ " less statutory reserve " + statutoryReserve + " and education fund " + educationFund + " leaves "
|
+ netProfit + ", of which " + alreadyDeclared + " is already declared — only " + available.max(BigDecimal.ZERO)
|
||||||
+ distributableProfit + ", of which " + alreadyDeclared + " is already declared — only "
|
+ " remains available.");
|
||||||
+ available.max(BigDecimal.ZERO) + " remains available.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,70 +542,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal percentageOfNetProfit(final BigDecimal netProfit, final String configurationName) {
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate
|
|
||||||
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
|
|
||||||
if (rows.isEmpty() || rows.get(0).get("value") == null) {
|
|
||||||
return BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
final BigDecimal percentage = new BigDecimal(String.valueOf(rows.get(0).get("value")));
|
|
||||||
return netProfit.multiply(percentage).divide(BigDecimal.valueOf(100), 6, java.math.RoundingMode.HALF_UP);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Posts the statutory appropriations that must come out of profit before a dividend may be declared.
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* Dr Bank Profit Cr Statutory Reserve Fund (25% of net profit, equity — retained permanently)
|
|
||||||
* Dr Bank Profit Cr Education Fund Payable (1%, a liability owed to the state federation)
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* Until now these only ever reduced the ceiling the declaration screen enforced — the money was never actually
|
|
||||||
* moved, so the Statutory Reserve Fund and Education Fund GLs stayed empty and the balance sheet did not show the
|
|
||||||
* transfers the Co-operative Societies Act requires. Posting them here makes the appropriation real.
|
|
||||||
*
|
|
||||||
* The rates come from c_configuration (changelog 0478), so a society on a different state Act, or one whose rates
|
|
||||||
* are revised, changes the setting rather than the code. A disabled or missing row means that appropriation does not
|
|
||||||
* apply and nothing is posted for it.
|
|
||||||
*
|
|
||||||
* Posted once per declaration and only for the appropriations that resolve — a society whose GL setup is incomplete
|
|
||||||
* still gets its dividend declared rather than being blocked.
|
|
||||||
*/
|
|
||||||
private void postStatutoryAppropriations(final Long dividendPayOutId, final String currencyCode, final LocalDate periodStartDate,
|
|
||||||
final LocalDate periodEndDate, final String financialYear) {
|
|
||||||
|
|
||||||
final java.sql.Date from = java.sql.Date.valueOf(periodStartDate);
|
|
||||||
final java.sql.Date to = java.sql.Date.valueOf(periodEndDate);
|
|
||||||
final BigDecimal netProfit = profitForPeriod(periodStartDate, periodEndDate, from, to);
|
|
||||||
if (netProfit.signum() <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_GL_CODE);
|
|
||||||
if (bankProfitAccountId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(STATUTORY_RESERVE_GL_CODE),
|
|
||||||
percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE), currencyCode, financialYear,
|
|
||||||
"statutory reserve", "DIVSTATRES");
|
|
||||||
postAppropriation(dividendPayOutId, bankProfitAccountId, glAccountIdByCode(EDUCATION_FUND_GL_CODE),
|
|
||||||
percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE), currencyCode, financialYear,
|
|
||||||
"education fund", "DIVEDUFUND");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One appropriation out of profit: debit Bank Profit, credit the fund it is set aside into. */
|
|
||||||
private void postAppropriation(final Long dividendPayOutId, final Long bankProfitAccountId, final Long fundAccountId,
|
|
||||||
final BigDecimal amount, final String currencyCode, final String financialYear, final String label,
|
|
||||||
final String transactionIdPrefix) {
|
|
||||||
|
|
||||||
if (fundAccountId == null || amount.signum() <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
|
||||||
postJournalEntryPair(bankProfitAccountId, fundAccountId, amount, currencyCode, transactionIdPrefix + dividendPayOutId, entryDate,
|
|
||||||
"FY " + financialYear + " " + label + " appropriated from profit — declaration #" + dividendPayOutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long glAccountIdByCode(final String glCode) {
|
private Long glAccountIdByCode(final String glCode) {
|
||||||
return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst()
|
return jdbcTemplate.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, glCode).stream().findFirst()
|
||||||
@@ -742,6 +680,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
releaseReserveToDividendPayable(dividendPayOutDetails);
|
releaseReserveToDividendPayable(dividendPayOutDetails);
|
||||||
sweepRoundingResidueOnApproval(PayOutDetailId);
|
sweepRoundingResidueOnApproval(PayOutDetailId);
|
||||||
makeDividendsClaimable(PayOutDetailId);
|
makeDividendsClaimable(PayOutDetailId);
|
||||||
|
forfeitDividendsBeyondClaimWindow(dividendPayOutDetails);
|
||||||
closeOffUndistributedProfit(dividendPayOutDetails);
|
closeOffUndistributedProfit(dividendPayOutDetails);
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withEntityId(PayOutDetailId) //
|
.withEntityId(PayOutDetailId) //
|
||||||
@@ -762,6 +701,82 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
|||||||
* unclaimed share is forfeited to the bank's profit. This is cheap enough (no per-member external command calls,
|
* unclaimed share is forfeited to the bank's profit. This is cheap enough (no per-member external command calls,
|
||||||
* just two bulk statements) to run synchronously in the approve request itself -- no background job needed.
|
* just two bulk statements) to run synchronously in the approve request itself -- no background job needed.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Forfeits any dividend that has fallen outside the three-year claim window, now that a newer year has been
|
||||||
|
* approved.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Dividend Payable — the obligation ends; nobody is coming to claim it
|
||||||
|
* Cr Bank Profit
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* A society keeps three financial years of dividend claimable. Approving a fourth is what pushes the oldest out:
|
||||||
|
* counted by financial year rather than by elapsed days, so "three years of dividend" means what a member and an
|
||||||
|
* auditor both take it to mean, and a declaration made late does not buy the member extra time.
|
||||||
|
*
|
||||||
|
* Only UNCLAIMED locks are touched. A claimed one has already been paid out by draft and its money left Dividend
|
||||||
|
* Payable long ago.
|
||||||
|
*
|
||||||
|
* Credited straight to Bank Profit rather than routed through an income account: the money never left the society,
|
||||||
|
* so this is a liability that turned out not to be owed, and it returns directly to the retained profit it was
|
||||||
|
* declared out of.
|
||||||
|
*
|
||||||
|
* Skipped when the GL accounts are not mapped, exactly like every other entry here: incomplete GL setup must never
|
||||||
|
* block an approval.
|
||||||
|
*/
|
||||||
|
private void forfeitDividendsBeyondClaimWindow(final ShareProductDividendPayOutDetails declaration) {
|
||||||
|
final LocalDate periodEndDate = declaration.getDividendPeriodEndDateAsLocalDate();
|
||||||
|
if (periodEndDate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything ending more than three financial years before the one just approved. Compared on
|
||||||
|
// the period end date, which is what identifies a declaration's financial year.
|
||||||
|
final LocalDate oldestClaimablePeriodEnd = periodEndDate.minusYears(CLAIMABLE_FINANCIAL_YEARS);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> expired = jdbcTemplate.queryForList("""
|
||||||
|
SELECT l.id, l.amount, l.currency_code, d.dividend_period_end_date
|
||||||
|
FROM m_share_dividend_lock l
|
||||||
|
JOIN m_share_account_dividend_details sad ON sad.id = l.dividend_detail_id
|
||||||
|
JOIN m_share_product_dividend_pay_out d ON d.id = sad.dividend_pay_out_id
|
||||||
|
WHERE l.status = 'UNCLAIMED' AND d.dividend_period_end_date <= ?
|
||||||
|
""", java.sql.Date.valueOf(oldestClaimablePeriodEnd));
|
||||||
|
if (expired.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long payableDividendsAccountId = glAccountIdByFinancialActivity(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS);
|
||||||
|
final Long bankProfitAccountId = glAccountIdByCode(PROFIT_SETTLED_GL_CODE);
|
||||||
|
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final java.sql.Date entryDate = java.sql.Date.valueOf(DateUtils.getBusinessLocalDate());
|
||||||
|
for (final Map<String, Object> lock : expired) {
|
||||||
|
final Long lockId = ((Number) lock.get("id")).longValue();
|
||||||
|
final BigDecimal amount = (BigDecimal) lock.get("amount");
|
||||||
|
if (amount == null || amount.signum() <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String forfeitedYear = financialYearOf(((java.sql.Date) lock.get("dividend_period_end_date")).toLocalDate());
|
||||||
|
|
||||||
|
postJournalEntryPair(payableDividendsAccountId, bankProfitAccountId, amount, (String) lock.get("currency_code"),
|
||||||
|
"DIVFORF" + lockId, entryDate,
|
||||||
|
"FY " + forfeitedYear + " dividend unclaimed for " + CLAIMABLE_FINANCIAL_YEARS
|
||||||
|
+ " years — forfeited to profit on approval of FY " + financialYearOf(periodEndDate));
|
||||||
|
|
||||||
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'FORFEITED', last_modified_date = now() WHERE id = ?", lockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A GL account mapped to a financial activity, or null when the society has not configured it. */
|
||||||
|
private Long glAccountIdByFinancialActivity(final int financialActivityType) {
|
||||||
|
return jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
financialActivityType)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
private void makeDividendsClaimable(final Long payOutDetailId) {
|
private void makeDividendsClaimable(final Long payOutDetailId) {
|
||||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
|
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
|
||||||
|
|||||||
+197
-3
@@ -23,10 +23,12 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -37,6 +39,8 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
||||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -47,6 +51,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDIssueRequestWritePlatformService {
|
public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDIssueRequestWritePlatformService {
|
||||||
|
|
||||||
private static final String STATUS_PENDING = "PENDING";
|
private static final String STATUS_PENDING = "PENDING";
|
||||||
@@ -56,6 +61,22 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
private static final String METHOD_CHEQUE = "CHEQUE";
|
private static final String METHOD_CHEQUE = "CHEQUE";
|
||||||
private static final String METHOD_CASH = "CASH";
|
private static final String METHOD_CASH = "CASH";
|
||||||
|
|
||||||
|
/** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */
|
||||||
|
private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft the society raises to pay a member their own dividend.
|
||||||
|
*
|
||||||
|
* Unlike every other method there is no purchaser: the society already owes the money, so nothing is collected and
|
||||||
|
* the draft is funded out of the Dividend Payable liability instead. Raised by the dividend claim flow, approved
|
||||||
|
* here alongside every other draft.
|
||||||
|
*/
|
||||||
|
private static final String METHOD_DIVIDEND = "DIVIDEND";
|
||||||
|
|
||||||
|
/** Financial activity ids for the dividend liability accounts (see the dividend claim flow). */
|
||||||
|
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||||
|
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
|
||||||
|
|
||||||
// Strips everything but letters/digits (spaces, periods, hyphens, commas...) before uppercasing,
|
// Strips everything but letters/digits (spaces, periods, hyphens, commas...) before uppercasing,
|
||||||
// so "KK", "K K", "K.K.", "K-K" etc. are all recognised as the same beneficiary for the DD-limit
|
// so "KK", "K K", "K.K.", "K-K" etc. are all recognised as the same beneficiary for the DD-limit
|
||||||
// aggregate check below - a bare UPPER(TRIM(...)) only normalises case and outer whitespace, which
|
// aggregate check below - a bare UPPER(TRIM(...)) only normalises case and outer whitespace, which
|
||||||
@@ -67,6 +88,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final VoucherService voucherService;
|
||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final SponsorDDWritePlatformService sponsorDDWritePlatformService;
|
private final SponsorDDWritePlatformService sponsorDDWritePlatformService;
|
||||||
|
|
||||||
@@ -471,6 +493,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
}
|
}
|
||||||
} else if (METHOD_CHEQUE.equals(paymentMethod)) {
|
} else if (METHOD_CHEQUE.equals(paymentMethod)) {
|
||||||
drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue();
|
drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue();
|
||||||
|
} else if (METHOD_DIVIDEND.equals(paymentMethod)) {
|
||||||
|
// Nothing is collected from the member — the society already owes them this money — so
|
||||||
|
// the draft is funded by settling the Dividend Payable liability instead of debiting a
|
||||||
|
// purchaser. Routed through Dividend Paid so the society can read what has been claimed
|
||||||
|
// and what is still unclaimed as two separate balances rather than only the net.
|
||||||
|
drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber);
|
||||||
} else {
|
} else {
|
||||||
// Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the
|
// Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the
|
||||||
// request was submitted, so approval clears the suspense rather than debiting cash again.
|
// request was submitted, so approval clears the suspense rather than debiting cash again.
|
||||||
@@ -495,10 +523,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId,
|
STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId,
|
||||||
resultingJournalEntryId, issueRequestId);
|
resultingJournalEntryId, issueRequestId);
|
||||||
|
|
||||||
|
if (METHOD_DIVIDEND.equals(paymentMethod)) {
|
||||||
|
settleDividendClaim(issueRequestId, ddNumber, faceValue, currentUser.getId(), currentUser.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseIssueVoucher(issueRequestId, ddNumber, drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, chargesGlAccountId,
|
||||||
|
gstAmount, gstGlAccountId, currentUser);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withEntityId(issueRequestId)
|
return new CommandProcessingResultBuilder().withEntityId(issueRequestId)
|
||||||
.withSubEntityId(issueResult.getResourceId()).build();
|
.withSubEntityId(issueResult.getResourceId()).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes off the dividend claim this draft was raised for, now that the draft actually exists.
|
||||||
|
*
|
||||||
|
* Approving the draft IS approving the claim — they are the same decision — so the claim is marked APPROVED here
|
||||||
|
* rather than waiting on a second sign-off in another screen.
|
||||||
|
*
|
||||||
|
* A claim for less than the whole lock leaves the remainder UNCLAIMED for a later visit; a claim for all of it
|
||||||
|
* closes the lock. Both are what the member is actually owed afterwards, so the lock has to move either way.
|
||||||
|
*
|
||||||
|
* Silent when no claim is attached: a DIVIDEND draft raised any other way is still a valid draft, and refusing to
|
||||||
|
* approve it would strand money the society owes.
|
||||||
|
*/
|
||||||
|
private void settleDividendClaim(final Long issueRequestId, final String ddNumber, final BigDecimal faceValue, final Long checkerId,
|
||||||
|
final String checkerName) {
|
||||||
|
|
||||||
|
final Map<String, Object> claim = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT id, lock_id FROM m_share_dividend_claim_request WHERE dd_issue_request_id = ? AND status = 'PENDING'",
|
||||||
|
issueRequestId)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
if (claim == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Long claimId = ((Number) claim.get("id")).longValue();
|
||||||
|
final Long lockId = ((Number) claim.get("lock_id")).longValue();
|
||||||
|
|
||||||
|
final BigDecimal lockAmount = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT amount FROM m_share_dividend_lock WHERE id = ?", BigDecimal.class, lockId).stream().findFirst()
|
||||||
|
.orElse(BigDecimal.ZERO);
|
||||||
|
final BigDecimal remainder = lockAmount.subtract(faceValue);
|
||||||
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET amount = ?, last_modified_date = now() WHERE id = ?", remainder,
|
||||||
|
lockId);
|
||||||
|
} else {
|
||||||
|
this.jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?",
|
||||||
|
lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_dividend_claim_request
|
||||||
|
SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(), dd_number = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", checkerId, checkerName, ddNumber, claimId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public CommandProcessingResult rejectIssueRequest(final Long issueRequestId, final String rejectionReason) {
|
public CommandProcessingResult rejectIssueRequest(final Long issueRequestId, final String rejectionReason) {
|
||||||
@@ -540,6 +619,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
+ "cash_refund_journal_entry_id = ? WHERE id = ?",
|
+ "cash_refund_journal_entry_id = ? WHERE id = ?",
|
||||||
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, cashRefundJournalEntryId, issueRequestId);
|
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, cashRefundJournalEntryId, issueRequestId);
|
||||||
|
|
||||||
|
if (METHOD_DIVIDEND.equals(paymentMethod)) {
|
||||||
|
// Rejecting the draft rejects the claim it was raised for — nothing was posted and the
|
||||||
|
// lock was never touched, so the dividend simply stays UNCLAIMED and can be claimed
|
||||||
|
// again. Leaving the claim PENDING would block that with a request nobody will action.
|
||||||
|
this.jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_dividend_claim_request
|
||||||
|
SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ?
|
||||||
|
WHERE dd_issue_request_id = ? AND status = 'PENDING'
|
||||||
|
""", currentUser.getId(), currentUser.getDisplayName(), rejectionReason, issueRequestId);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withEntityId(issueRequestId).build();
|
return new CommandProcessingResultBuilder().withEntityId(issueRequestId).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,7 +1158,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
// The DD register raises its own voucher, naming the draft and its number; the inner
|
||||||
|
// posting must not raise a second one over the same money.
|
||||||
|
final CommandProcessingResult result = this.voucherService
|
||||||
|
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
return result.getResourceId().toString();
|
return result.getResourceId().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,7 +1180,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
// The DD register raises its own voucher, naming the draft and its number; the inner
|
||||||
|
// posting must not raise a second one over the same money.
|
||||||
|
final CommandProcessingResult result = this.voucherService
|
||||||
|
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
return result.getResourceId().toString();
|
return result.getResourceId().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,6 +1217,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
currentAccountId);
|
currentAccountId);
|
||||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Records an approved Demand Draft issue in the voucher register.
|
||||||
|
*
|
||||||
|
* The slip mirrors the posting: the funding side is debited for everything the purchaser parts
|
||||||
|
* with, and the proceeds are split the way the ledger splits them — the draft's face value to
|
||||||
|
* the DD Payable liability, and the charges and GST to their own income and tax accounts rather
|
||||||
|
* than being folded into the liability.
|
||||||
|
*
|
||||||
|
* The debit is the total, not the face value: a purchaser handing over face value plus charges
|
||||||
|
* has parted with the total, and a slip showing only the face value would not match what they
|
||||||
|
* paid.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the issue. The draft is already raised, the leaflet already
|
||||||
|
* consumed and the ledger already posted; withdrawing that over a slip would take back a
|
||||||
|
* completed instrument for a paperwork reason. The problem is logged and the issue stands.
|
||||||
|
*/
|
||||||
|
private void raiseIssueVoucher(final Long issueRequestId, final String ddNumber, final Long fundingGlAccountId,
|
||||||
|
final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId,
|
||||||
|
final BigDecimal gstAmount, final Long gstGlAccountId, final AppUser currentUser) {
|
||||||
|
try {
|
||||||
|
final BigDecimal charges = chargesAmount == null ? BigDecimal.ZERO : chargesAmount;
|
||||||
|
final BigDecimal gst = gstAmount == null ? BigDecimal.ZERO : gstAmount;
|
||||||
|
final BigDecimal total = faceValue.add(charges).add(gst);
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
|
||||||
|
final Map<String, Object> funding = this.voucherService.glAccount(fundingGlAccountId);
|
||||||
|
lines.add(VoucherLine
|
||||||
|
.debitGl(fundingGlAccountId, (String) funding.get("name"), (String) funding.get("gl_code"), total)
|
||||||
|
.withDescription("Sponsor DD " + ddNumber));
|
||||||
|
|
||||||
|
final Map<String, Object> ddPayable = this.voucherService.glAccount(ddPayableGlAccountId);
|
||||||
|
lines.add(VoucherLine
|
||||||
|
.creditGl(ddPayableGlAccountId, (String) ddPayable.get("name"), (String) ddPayable.get("gl_code"), faceValue)
|
||||||
|
.withDescription("Draft " + ddNumber + " face value"));
|
||||||
|
|
||||||
|
if (charges.signum() > 0 && chargesGlAccountId != null) {
|
||||||
|
final Map<String, Object> chargesGl = this.voucherService.glAccount(chargesGlAccountId);
|
||||||
|
lines.add(VoucherLine
|
||||||
|
.creditGl(chargesGlAccountId, (String) chargesGl.get("name"), (String) chargesGl.get("gl_code"), charges)
|
||||||
|
.withDescription("DD issuance charges"));
|
||||||
|
}
|
||||||
|
if (gst.signum() > 0 && gstGlAccountId != null) {
|
||||||
|
final Map<String, Object> gstGl = this.voucherService.glAccount(gstGlAccountId);
|
||||||
|
lines.add(VoucherLine.creditGl(gstGlAccountId, (String) gstGl.get("name"), (String) gstGl.get("gl_code"), gst)
|
||||||
|
.withDescription("GST on DD charges"));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
|
||||||
|
currentUser, VoucherService.PREFIX_DEBIT, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts a direct GL journal entry through Fineract's real accounting command: Dr drGlAccountId for
|
* Posts a direct GL journal entry through Fineract's real accounting command: Dr drGlAccountId for
|
||||||
@@ -1140,6 +1291,46 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
txnDate, comments, linkedAccountId);
|
txnDate, comments, linkedAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles the member's dividend entitlement and returns the account the draft is then drawn against.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Dividend Payable Cr Dividend Paid (here — the member's entitlement is discharged)
|
||||||
|
* Dr Dividend Paid Cr Sponsor DD Payable (by the caller — the draft is raised)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* Routed through Dividend Paid rather than posted straight across so the society can read what has been claimed
|
||||||
|
* and what is still unclaimed as two separate balances instead of only the net of Dividend Payable. Dividend Paid
|
||||||
|
* nets to nil, leaving its credit total as the cumulative dividend claimed.
|
||||||
|
*
|
||||||
|
* A society that has not mapped Dividend Paid still gets a correct payout — Dividend Payable is returned and the
|
||||||
|
* caller posts straight across to DD Payable — just without that separate measure.
|
||||||
|
*/
|
||||||
|
private Long resolveDividendFundingGlAccount(final BigDecimal faceValue, final Long officeId, final LocalDate issuedDate,
|
||||||
|
final String ddNumber) {
|
||||||
|
|
||||||
|
final Long payableDividendsAccountId = financialActivityGlAccount(FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS);
|
||||||
|
if (payableDividendsAccountId == null) {
|
||||||
|
throw new IllegalStateException("No Payable Dividends GL account is mapped. Ask an administrator to configure the "
|
||||||
|
+ "dividend financial activity accounts before approving a dividend Demand Draft.");
|
||||||
|
}
|
||||||
|
final Long dividendPaidAccountId = financialActivityGlAccount(FINANCIAL_ACTIVITY_DIVIDEND_PAID);
|
||||||
|
if (dividendPaidAccountId == null) {
|
||||||
|
return payableDividendsAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
postGlOffsetEntry(payableDividendsAccountId, dividendPaidAccountId, faceValue, null, null, officeId, issuedDate,
|
||||||
|
"Dividend paid by Demand Draft " + ddNumber + " — member's entitlement settled", null);
|
||||||
|
return dividendPaidAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long financialActivityGlAccount(final int financialActivityType) {
|
||||||
|
return this.jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
financialActivityType)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
|
/** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */
|
||||||
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
|
||||||
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
|
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
|
||||||
@@ -1192,7 +1383,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
// The DD register raises its own voucher, naming the draft and its number; the inner
|
||||||
|
// posting must not raise a second one over the same money.
|
||||||
|
final CommandProcessingResult result = this.voucherService
|
||||||
|
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
final String transactionId = result != null ? result.getTransactionId() : null;
|
final String transactionId = result != null ? result.getTransactionId() : null;
|
||||||
if (transactionId == null) return null;
|
if (transactionId == null) return null;
|
||||||
|
|
||||||
|
|||||||
+222
-1
@@ -33,13 +33,16 @@ import jakarta.ws.rs.core.Response;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -68,11 +71,12 @@ public class VoucherVerificationApiResource {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final VoucherService voucherService;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
// ── POST /v1/vouchers/{voucherNo}/verify ──────────────────────────────────
|
// ── POST /v1/vouchers/{voucherNo}/verify ──────────────────────────────────
|
||||||
// The voucher number travels in the body, not the path: numbers are formatted
|
// The voucher number travels in the body, not the path: numbers are formatted
|
||||||
// CR/001/2108/0001 and Spring Security's strict firewall rejects the encoded slashes.
|
// CR/2108/0001 and Spring Security's strict firewall rejects the encoded slashes.
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("verify")
|
@Path("verify")
|
||||||
@@ -412,6 +416,223 @@ public class VoucherVerificationApiResource {
|
|||||||
return gson.toJson(Map.of("resourceId", id, "deleted", true));
|
return gson.toJson(Map.of("resourceId", id, "deleted", true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/vouchers/report?from=2026-08-01&to=2026-08-31&username=rajesh ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vouchers raised over a period, one row each, with the accounts they moved money between.
|
||||||
|
*
|
||||||
|
* Who sees whose work is decided here rather than by the caller: a clerk, head cashier or
|
||||||
|
* manager sees every user's vouchers and may filter to one; anyone else sees only their own,
|
||||||
|
* whatever they ask for. A cashier cannot read a colleague's day by passing their username.
|
||||||
|
*
|
||||||
|
* Each row carries the debit and credit sides already joined, so the report reads as a register
|
||||||
|
* — voucher number, what was debited, what was credited — without a second call per row. A
|
||||||
|
* voucher with more than two sides (a DD with charges, a compound journal) is summarised by its
|
||||||
|
* first debit and first credit with a count of the rest, because a register is a list of
|
||||||
|
* movements and the full breakdown belongs on the voucher itself.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("report")
|
||||||
|
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||||
|
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType) {
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
final boolean seesEveryone = isCheckerRole(currentUser);
|
||||||
|
|
||||||
|
// Defaults to today rather than to everything: an unbounded register over a year of
|
||||||
|
// vouchers is a report nobody asked for and a query that scans the whole table.
|
||||||
|
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final LocalDate fromDate = parseReportDate(from, today);
|
||||||
|
final LocalDate toDate = parseReportDate(to, today);
|
||||||
|
if (toDate.isBefore(fromDate)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.voucher.report.range.invalid",
|
||||||
|
"The end of the period cannot fall before its start.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user who may only see their own work is pinned to their own username regardless of what
|
||||||
|
// they asked for; a checker's blank filter means everyone.
|
||||||
|
final String scopedUsername = seesEveryone ? (username == null || username.isBlank() ? null : username.trim())
|
||||||
|
: currentUser.getUsername();
|
||||||
|
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
|
||||||
|
v.created_by, v.created_by_username, v.branch_office_id, o.name AS branch_name,
|
||||||
|
ver.status AS verification_status,
|
||||||
|
dr.party_name AS debit_party, dr.account_no AS debit_account,
|
||||||
|
cr.party_name AS credit_party, cr.account_no AS credit_account,
|
||||||
|
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count
|
||||||
|
FROM m_voucher v
|
||||||
|
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
||||||
|
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
|
WHERE voucher_no = v.voucher_no AND entry_type = 'DR'
|
||||||
|
ORDER BY line_no LIMIT 1) dr ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT party_name, account_no FROM m_voucher_line
|
||||||
|
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
|
||||||
|
ORDER BY line_no LIMIT 1) cr ON TRUE
|
||||||
|
WHERE v.created_at >= ? AND v.created_at < ?
|
||||||
|
""");
|
||||||
|
|
||||||
|
final List<Object> args = new java.util.ArrayList<>();
|
||||||
|
args.add(Timestamp.valueOf(fromDate.atStartOfDay()));
|
||||||
|
// Exclusive upper bound on the day after, so a voucher raised at 23:59 on the last day is
|
||||||
|
// still inside the period — a BETWEEN on dates would drop it.
|
||||||
|
args.add(Timestamp.valueOf(toDate.plusDays(1).atStartOfDay()));
|
||||||
|
|
||||||
|
if (scopedUsername != null) {
|
||||||
|
sql.append(" AND v.created_by_username = ?");
|
||||||
|
args.add(scopedUsername);
|
||||||
|
}
|
||||||
|
if (voucherType != null && !voucherType.isBlank()) {
|
||||||
|
sql.append(" AND v.voucher_type = ?");
|
||||||
|
args.add(voucherType.trim());
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY v.created_at DESC, v.voucher_no DESC");
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||||
|
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
final List<Map<String, Object>> out = new java.util.ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : rows) {
|
||||||
|
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||||
|
if (amount != null) {
|
||||||
|
total = total.add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("voucherNo", r.get("voucher_no"));
|
||||||
|
m.put("voucherType", r.get("voucher_type"));
|
||||||
|
m.put("amount", amount);
|
||||||
|
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
|
||||||
|
m.put("userId", r.get("created_by"));
|
||||||
|
m.put("username", r.get("created_by_username"));
|
||||||
|
m.put("branchName", r.get("branch_name"));
|
||||||
|
m.put("verificationStatus", r.get("verification_status"));
|
||||||
|
m.put("debitParty", r.get("debit_party"));
|
||||||
|
m.put("debitAccount", r.get("debit_account"));
|
||||||
|
m.put("creditParty", r.get("credit_party"));
|
||||||
|
m.put("creditAccount", r.get("credit_account"));
|
||||||
|
// Anything beyond one debit and one credit is extra detail the register does not show,
|
||||||
|
// so the row says how much it is hiding rather than misrepresenting the voucher.
|
||||||
|
final int lineCount = ((Number) r.get("line_count")).intValue();
|
||||||
|
m.put("extraLines", Math.max(0, lineCount - 2));
|
||||||
|
out.add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> res = new LinkedHashMap<>();
|
||||||
|
res.put("from", fromDate.toString());
|
||||||
|
res.put("to", toDate.toString());
|
||||||
|
res.put("username", scopedUsername);
|
||||||
|
// Tells the screen whether to offer the user filter at all, rather than having it guess
|
||||||
|
// from the caller's roles a second time.
|
||||||
|
res.put("canSeeAllUsers", seesEveryone);
|
||||||
|
res.put("vouchers", out);
|
||||||
|
res.put("count", out.size());
|
||||||
|
res.put("totalAmount", total);
|
||||||
|
return gson.toJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A yyyy-MM-dd date from the query string, falling back to the given default when absent. */
|
||||||
|
private static LocalDate parseReportDate(final String value, final LocalDate fallback) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(value.trim());
|
||||||
|
} catch (final java.time.format.DateTimeParseException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.voucher.report.date.invalid",
|
||||||
|
"Dates must be written as yyyy-MM-dd; got '" + value + "'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ────────────────────────
|
||||||
|
// A query parameter for the same reason as verify: a formatted voucher number contains
|
||||||
|
// slashes, which the strict firewall rejects even encoded, so it cannot be a path segment.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
|
||||||
|
*
|
||||||
|
* The sides come back in print order, with each line's running side total, so the caller can
|
||||||
|
* lay out a slip without re-deriving anything. A voucher raised before lines were recorded
|
||||||
|
* returns an empty list rather than an error: the voucher is real, its posting simply was not
|
||||||
|
* captured at the time, and reporting it as missing would be wrong.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("detail")
|
||||||
|
public String detail(@QueryParam("voucherNo") final String voucherNo) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
if (voucherNo == null || voucherNo.isBlank()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.voucher.no.required", "A voucher number is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> header = jdbcTemplate.queryForList("""
|
||||||
|
SELECT v.voucher_no, v.voucher_type, v.reference_type, v.reference_id, v.amount,
|
||||||
|
v.branch_office_id, o.name AS branch_name,
|
||||||
|
v.created_by_username, v.created_at,
|
||||||
|
ver.status AS verification_status
|
||||||
|
FROM m_voucher v
|
||||||
|
LEFT JOIN m_office o ON o.id = v.branch_office_id
|
||||||
|
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
|
||||||
|
WHERE v.voucher_no = ?
|
||||||
|
""", voucherNo);
|
||||||
|
|
||||||
|
if (header.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.voucher.not.found",
|
||||||
|
"Voucher " + voucherNo + " does not exist in the register.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> h = header.get(0);
|
||||||
|
final List<Map<String, Object>> lineRows = voucherService.findLines(voucherNo);
|
||||||
|
|
||||||
|
BigDecimal totalDebit = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalCredit = BigDecimal.ZERO;
|
||||||
|
final List<Map<String, Object>> lines = new java.util.ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : lineRows) {
|
||||||
|
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||||
|
final String entryType = (String) r.get("entry_type");
|
||||||
|
if ("DR".equals(entryType)) {
|
||||||
|
totalDebit = totalDebit.add(amount);
|
||||||
|
} else {
|
||||||
|
totalCredit = totalCredit.add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("lineNo", r.get("line_no"));
|
||||||
|
m.put("entryType", entryType);
|
||||||
|
m.put("glAccountId", r.get("gl_account_id"));
|
||||||
|
m.put("savingsAccountId", r.get("savings_account_id"));
|
||||||
|
m.put("loanAccountId", r.get("loan_account_id"));
|
||||||
|
m.put("partyName", r.get("party_name"));
|
||||||
|
m.put("accountNo", r.get("account_no"));
|
||||||
|
m.put("amount", amount);
|
||||||
|
m.put("description", r.get("description"));
|
||||||
|
lines.add(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("voucherNo", h.get("voucher_no"));
|
||||||
|
out.put("voucherType", h.get("voucher_type"));
|
||||||
|
out.put("referenceType", h.get("reference_type"));
|
||||||
|
out.put("referenceId", h.get("reference_id"));
|
||||||
|
out.put("amount", h.get("amount"));
|
||||||
|
out.put("branchOfficeId", h.get("branch_office_id"));
|
||||||
|
out.put("branchName", h.get("branch_name"));
|
||||||
|
out.put("createdBy", h.get("created_by_username"));
|
||||||
|
out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString());
|
||||||
|
out.put("verificationStatus", h.get("verification_status"));
|
||||||
|
out.put("lines", lines);
|
||||||
|
out.put("totalDebit", totalDebit);
|
||||||
|
out.put("totalCredit", totalCredit);
|
||||||
|
// Stated rather than assumed: a voucher whose sides were written before the balance check
|
||||||
|
// existed, or repaired by hand in the database, should show as unbalanced rather than be
|
||||||
|
// presented as a valid slip.
|
||||||
|
out.put("balanced", totalDebit.compareTo(totalCredit) == 0);
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The amount the system holds for a voucher, looked up across all three registers. Returns
|
* The amount the system holds for a voucher, looked up across all three registers. Returns
|
||||||
* null when no register knows the number, which is how callers detect a bad voucher.
|
* null when no register knows the number, which is how callers detect a bad voucher.
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.voucher.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One side of one voucher: a debit or a credit against a single account.
|
||||||
|
*
|
||||||
|
* The account is named by whichever of the three ids is set — GL, savings or loan. A voucher is
|
||||||
|
* built by pairing these up, so a GL-to-GL journal, a GL-to-account dividend payout and an
|
||||||
|
* account-to-account transfer are the same structure with different sides filled in, rather than
|
||||||
|
* three separate shapes.
|
||||||
|
*
|
||||||
|
* partyName and accountNo are carried on the line rather than looked up when the voucher is
|
||||||
|
* printed: a voucher has to keep showing what was actually posted, even after a member is renamed
|
||||||
|
* or an account is renumbered.
|
||||||
|
*
|
||||||
|
* @param entryType
|
||||||
|
* {@link VoucherService#ENTRY_DEBIT} or {@link VoucherService#ENTRY_CREDIT}
|
||||||
|
* @param glAccountId
|
||||||
|
* set when this side posts against a GL account
|
||||||
|
* @param savingsAccountId
|
||||||
|
* set when this side posts against a member's savings account
|
||||||
|
* @param loanAccountId
|
||||||
|
* set when this side posts against a loan account
|
||||||
|
* @param partyName
|
||||||
|
* what the voucher prints for this side — "Cash in Hand", or the member's name
|
||||||
|
* @param accountNo
|
||||||
|
* the GL code or account number as it appears on the slip
|
||||||
|
* @param amount
|
||||||
|
* always positive; the side is carried by entryType, not by the sign
|
||||||
|
* @param description
|
||||||
|
* optional narration for this line alone
|
||||||
|
*/
|
||||||
|
public record VoucherLine(String entryType, Long glAccountId, Long savingsAccountId, Long loanAccountId, String partyName,
|
||||||
|
String accountNo, BigDecimal amount, String description) {
|
||||||
|
|
||||||
|
/** A debit against a GL account. */
|
||||||
|
public static VoucherLine debitGl(final Long glAccountId, final String name, final String code, final BigDecimal amount) {
|
||||||
|
return new VoucherLine(VoucherService.ENTRY_DEBIT, glAccountId, null, null, name, code, amount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A credit against a GL account. */
|
||||||
|
public static VoucherLine creditGl(final Long glAccountId, final String name, final String code, final BigDecimal amount) {
|
||||||
|
return new VoucherLine(VoucherService.ENTRY_CREDIT, glAccountId, null, null, name, code, amount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A debit against a member's savings account — money leaving the member. */
|
||||||
|
public static VoucherLine debitSavings(final Long savingsId, final String party, final String accountNo,
|
||||||
|
final BigDecimal amount) {
|
||||||
|
return new VoucherLine(VoucherService.ENTRY_DEBIT, null, savingsId, null, party, accountNo, amount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A credit against a member's savings account — money reaching the member. */
|
||||||
|
public static VoucherLine creditSavings(final Long savingsId, final String party, final String accountNo,
|
||||||
|
final BigDecimal amount) {
|
||||||
|
return new VoucherLine(VoucherService.ENTRY_CREDIT, null, savingsId, null, party, accountNo, amount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A debit against a loan account. */
|
||||||
|
public static VoucherLine debitLoan(final Long loanId, final String party, final String accountNo, final BigDecimal amount) {
|
||||||
|
return new VoucherLine(VoucherService.ENTRY_DEBIT, null, null, loanId, party, accountNo, amount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A credit against a loan account. */
|
||||||
|
public static VoucherLine creditLoan(final Long loanId, final String party, final String accountNo, final BigDecimal amount) {
|
||||||
|
return new VoucherLine(VoucherService.ENTRY_CREDIT, null, null, loanId, party, accountNo, amount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same line carrying a narration, for the cases where one side needs its own reason. */
|
||||||
|
public VoucherLine withDescription(final String text) {
|
||||||
|
return new VoucherLine(entryType, glAccountId, savingsAccountId, loanAccountId, partyName, accountNo, amount, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDebit() {
|
||||||
|
return VoucherService.ENTRY_DEBIT.equals(entryType);
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
-29
@@ -35,15 +35,19 @@ import org.springframework.stereotype.Service;
|
|||||||
/**
|
/**
|
||||||
* Central voucher register (m_voucher). Every maker-checker cash transaction
|
* Central voucher register (m_voucher). Every maker-checker cash transaction
|
||||||
* (clerk withdrawal request, cashier deposit request) gets a voucher number in
|
* (clerk withdrawal request, cashier deposit request) gets a voucher number in
|
||||||
* the form PREFIX/BRANCH/DDMM/NNNN — for example CR/001/2108/0001 for a deposit
|
* the form PREFIX/DDMM/NNNN — for example CR/2108/0001 for a deposit and
|
||||||
* and DR/001/2108/0001 for a withdrawal on 21 August at branch 001.
|
* DR/2108/0001 for a withdrawal on 21 August.
|
||||||
*
|
*
|
||||||
* The trailing counter restarts at 0001 every day, independently for each
|
* The trailing counter restarts at 0001 every day, independently for each
|
||||||
* (prefix, branch) pair, so a deposit and a withdrawal at the same branch on the
|
* prefix, so a deposit and a withdrawal on the same day never share a number.
|
||||||
* same day never share a number, and neither does the same direction at another
|
* The series is bank-wide rather than per-branch: the number names the slip
|
||||||
* branch. Counters live in m_voucher_sequence and are bumped under a row lock,
|
* side and the day, and nothing else. The office that raised the voucher is
|
||||||
* because a daily-reset counter cannot lean on the m_voucher autoincrement PK
|
* still recorded, in m_voucher.branch_office_id, so a per-branch register
|
||||||
* for uniqueness the way the old flat "000001" scheme did.
|
* remains reportable without the branch appearing in the printed number.
|
||||||
|
*
|
||||||
|
* Counters live in m_voucher_sequence and are bumped under a row lock, because
|
||||||
|
* a daily-reset counter cannot lean on the m_voucher autoincrement PK for
|
||||||
|
* uniqueness the way the old flat "000001" scheme did.
|
||||||
*
|
*
|
||||||
* The voucher is also stamped onto the resulting savings transaction
|
* The voucher is also stamped onto the resulting savings transaction
|
||||||
* (m_savings_account_transaction.voucher_no) once the manager approves and the
|
* (m_savings_account_transaction.voucher_no) once the manager approves and the
|
||||||
@@ -56,20 +60,84 @@ public class VoucherService {
|
|||||||
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
|
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
|
||||||
public static final String TYPE_DEPOSIT = "DEPOSIT";
|
public static final String TYPE_DEPOSIT = "DEPOSIT";
|
||||||
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT";
|
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT";
|
||||||
|
public static final String TYPE_REPAYMENT = "REPAYMENT";
|
||||||
|
public static final String TYPE_INTEREST_POSTING = "INTEREST_POSTING";
|
||||||
public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST";
|
public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST";
|
||||||
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
|
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
|
||||||
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
|
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
|
||||||
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
|
public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG";
|
||||||
|
|
||||||
|
/** A voucher raised directly as a journal, rather than by one of the cash registers. */
|
||||||
|
public static final String TYPE_JOURNAL = "JOURNAL";
|
||||||
|
|
||||||
|
/** The debit side of a voucher line. */
|
||||||
|
public static final String ENTRY_DEBIT = "DR";
|
||||||
|
/** The credit side of a voucher line. */
|
||||||
|
public static final String ENTRY_CREDIT = "CR";
|
||||||
|
|
||||||
/** Money into an account — the CR side of the slip. */
|
/** Money into an account — the CR side of the slip. */
|
||||||
public static final String PREFIX_CREDIT = "CR";
|
public static final String PREFIX_CREDIT = "CR";
|
||||||
/** Money out of an account — the DR side of the slip. */
|
/** Money out of an account — the DR side of the slip. */
|
||||||
public static final String PREFIX_DEBIT = "DR";
|
public static final String PREFIX_DEBIT = "DR";
|
||||||
|
/**
|
||||||
|
* A journal voucher, which has no single side to name it by: a transfer between two GLs is
|
||||||
|
* neither a receipt nor a payment, and calling it CR or DR would pick one arbitrarily. It
|
||||||
|
* carries its own daily series for the same reason.
|
||||||
|
*/
|
||||||
|
public static final String PREFIX_JOURNAL = "JV";
|
||||||
|
|
||||||
|
/** A loan disbursement or repayment, keyed by its m_loan_transaction id. */
|
||||||
|
public static final String REF_LOAN_TRANSACTION = "LOAN_TRANSACTION";
|
||||||
|
|
||||||
|
/** A manual journal, keyed by its acc_gl_journal_entry.transaction_id. */
|
||||||
|
public static final String REF_JOURNAL_ENTRY = "JOURNAL_ENTRY";
|
||||||
|
|
||||||
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
|
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set while a register is posting the accounting behind a voucher it has already raised.
|
||||||
|
*
|
||||||
|
* Several registers do not write their own ledger entries: a cashier GL deposit, a clerk
|
||||||
|
* disbursement and a Sponsor DD issue all post theirs by invoking the ordinary journal-entry
|
||||||
|
* command, and a DD funded from a member's account does it by invoking the ordinary savings
|
||||||
|
* withdrawal. Each of those inner operations raises a voucher of its own, which here would be a
|
||||||
|
* second voucher over the same money — and the inner one is the worse of the two, naming a bare
|
||||||
|
* journal or a plain withdrawal where the outer register names the member and the instrument.
|
||||||
|
*
|
||||||
|
* A register wraps its posting in {@link #withOwnVoucher} to say the voucher is already its
|
||||||
|
* responsibility. A ThreadLocal rather than a request attribute, because the same holds for a
|
||||||
|
* batch job, which has no request to hang the flag on.
|
||||||
|
*/
|
||||||
|
private static final ThreadLocal<Boolean> OUTER_REGISTER_OWNS_VOUCHER = ThreadLocal.withInitial(() -> Boolean.FALSE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs {@code posting} with any voucher the inner operation would raise suppressed, for a
|
||||||
|
* register that has already raised its own.
|
||||||
|
*
|
||||||
|
* Restores the previous value rather than clearing it, so nesting — a register posting twice,
|
||||||
|
* or one posting routine calling another — cannot switch the suppression off early for the
|
||||||
|
* outer caller.
|
||||||
|
*/
|
||||||
|
public <T> T withOwnVoucher(final java.util.function.Supplier<T> posting) {
|
||||||
|
final boolean previous = OUTER_REGISTER_OWNS_VOUCHER.get();
|
||||||
|
OUTER_REGISTER_OWNS_VOUCHER.set(Boolean.TRUE);
|
||||||
|
try {
|
||||||
|
return posting.get();
|
||||||
|
} finally {
|
||||||
|
OUTER_REGISTER_OWNS_VOUCHER.set(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the operation now in progress should raise its own voucher, or leave it to a register
|
||||||
|
* further out that has already raised one.
|
||||||
|
*/
|
||||||
|
public boolean shouldRaiseVoucher() {
|
||||||
|
return !OUTER_REGISTER_OWNS_VOUCHER.get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a voucher type to its slip-side prefix. A deposit credits the member's
|
* Maps a voucher type to its slip-side prefix. A deposit credits the member's
|
||||||
* account (CR); a withdrawal and a disbursement both take money out (DR).
|
* account (CR); a withdrawal and a disbursement both take money out (DR).
|
||||||
@@ -78,36 +146,35 @@ public class VoucherService {
|
|||||||
return TYPE_DEPOSIT.equals(voucherType) ? PREFIX_CREDIT : PREFIX_DEBIT;
|
return TYPE_DEPOSIT.equals(voucherType) ? PREFIX_CREDIT : PREFIX_DEBIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Branch code as it appears in the UI's BRANCH dropdown: office id zero-padded to 3 digits. */
|
|
||||||
private static String branchCode(final Long officeId) {
|
|
||||||
return String.format("%03d", officeId != null ? officeId : 1L);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserves the next counter value for one (prefix, branch, day) series.
|
* Reserves the next counter value for one (prefix, day) series.
|
||||||
*
|
*
|
||||||
* The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use
|
* The INSERT ... ON CONFLICT DO UPDATE both creates the series on its first use
|
||||||
* of the day and increments it thereafter. Being a single statement, it takes
|
* of the day and increments it thereafter. Being a single statement, it takes
|
||||||
* and releases the row lock atomically, so two cashiers submitting at the same
|
* and releases the row lock atomically, so two cashiers submitting at the same
|
||||||
* moment are serialised by the database and can never be handed the same
|
* moment are serialised by the database and can never be handed the same
|
||||||
* number — no explicit SELECT ... FOR UPDATE needed.
|
* number — no explicit SELECT ... FOR UPDATE needed.
|
||||||
|
*
|
||||||
|
* branch_code is written as the empty string rather than dropped: the column is
|
||||||
|
* NOT NULL from the original schema, and one bank-wide series per (prefix, day)
|
||||||
|
* means there is no longer a branch to put there.
|
||||||
*/
|
*/
|
||||||
private int nextSeriesNumber(final String prefix, final String branch, final LocalDate day) {
|
private int nextSeriesNumber(final String prefix, final LocalDate day) {
|
||||||
return jdbcTemplate.queryForObject("""
|
return jdbcTemplate.queryForObject("""
|
||||||
INSERT INTO m_voucher_sequence (prefix, branch_code, series_date, last_number)
|
INSERT INTO m_voucher_sequence (prefix, branch_code, series_date, last_number)
|
||||||
VALUES (?,?,?,1)
|
VALUES (?,'',?,1)
|
||||||
ON CONFLICT (prefix, branch_code, series_date)
|
ON CONFLICT (prefix, series_date)
|
||||||
DO UPDATE SET last_number = m_voucher_sequence.last_number + 1
|
DO UPDATE SET last_number = m_voucher_sequence.last_number + 1
|
||||||
RETURNING last_number
|
RETURNING last_number
|
||||||
""",
|
""",
|
||||||
Integer.class, prefix, branch, Date.valueOf(day));
|
Integer.class, prefix, Date.valueOf(day));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a voucher row and returns the formatted voucher number, e.g.
|
* Creates a voucher row and returns the formatted voucher number, e.g.
|
||||||
* "CR/001/2108/0001". The branch is taken from the acting user's own office
|
* "CR/2108/0001". The office that handled the cash is taken from the acting
|
||||||
* rather than from any branch the screen happens to have selected, so the
|
* user rather than from any branch the screen happens to have selected, and
|
||||||
* voucher always names the branch that actually handled the cash.
|
* is stored alongside the voucher instead of being printed into its number.
|
||||||
*/
|
*/
|
||||||
public String createVoucher(final String voucherType, final String referenceType,
|
public String createVoucher(final String voucherType, final String referenceType,
|
||||||
final Long referenceId, final BigDecimal amount, final AppUser user) {
|
final Long referenceId, final BigDecimal amount, final AppUser user) {
|
||||||
@@ -124,28 +191,236 @@ public class VoucherService {
|
|||||||
public String createVoucher(final String voucherType, final String referenceType,
|
public String createVoucher(final String voucherType, final String referenceType,
|
||||||
final Long referenceId, final BigDecimal amount, final AppUser user,
|
final Long referenceId, final BigDecimal amount, final AppUser user,
|
||||||
final String prefix) {
|
final String prefix) {
|
||||||
|
return createVoucher(voucherType, referenceType, referenceId, null, amount, user, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but for a source identified by text rather than by a number — a manual journal,
|
||||||
|
* which is known by its transaction id. Exactly one of referenceId and referenceKey is
|
||||||
|
* expected to be set; a source uses whichever identifier it actually has.
|
||||||
|
*/
|
||||||
|
public String createVoucher(final String voucherType, final String referenceType,
|
||||||
|
final Long referenceId, final String referenceKey, final BigDecimal amount,
|
||||||
|
final AppUser user, final String prefix) {
|
||||||
|
|
||||||
// Tenant timezone, not the server's — the day the counter resets on has to be the
|
// Tenant timezone, not the server's — the day the counter resets on has to be the
|
||||||
// branch's business day, otherwise a late-evening voucher lands in the wrong series.
|
// branch's business day, otherwise a late-evening voucher lands in the wrong series.
|
||||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
final String branch = branchCode(user.getOffice() != null ? user.getOffice().getId() : null);
|
final int serial = nextSeriesNumber(prefix, today);
|
||||||
final int serial = nextSeriesNumber(prefix, branch, today);
|
final String voucherNo = String.format("%s/%s/%04d",
|
||||||
final String voucherNo = String.format("%s/%s/%s/%04d",
|
prefix, today.format(DAY_MONTH), serial);
|
||||||
prefix, branch, today.format(DAY_MONTH), serial);
|
|
||||||
|
final Long branchOfficeId = user.getOffice() != null ? user.getOffice().getId() : null;
|
||||||
|
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_voucher
|
INSERT INTO m_voucher
|
||||||
(voucher_no, voucher_type, reference_type, reference_id, amount,
|
(voucher_no, voucher_type, reference_type, reference_id, reference_key, amount,
|
||||||
created_by, created_by_username, created_at)
|
branch_office_id, created_by, created_by_username, created_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
""",
|
""",
|
||||||
voucherNo, voucherType, referenceType, referenceId, amount,
|
voucherNo, voucherType, referenceType, referenceId, referenceKey, amount,
|
||||||
user.getId(), user.getUsername(), Timestamp.from(Instant.now())
|
branchOfficeId, user.getId(), user.getUsername(), Timestamp.from(Instant.now())
|
||||||
);
|
);
|
||||||
|
|
||||||
return voucherNo;
|
return voucherNo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a voucher together with the debit and credit sides that make it up, and returns the
|
||||||
|
* formatted voucher number.
|
||||||
|
*
|
||||||
|
* This is the form to reach for whenever the posting is known at the point the voucher is
|
||||||
|
* raised. The amount stored on the voucher is the total of one side, which — the two sides
|
||||||
|
* being equal — is the value of the voucher as a whole.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException
|
||||||
|
* if the lines do not balance, or do not include both a debit and a credit. A
|
||||||
|
* voucher that does not balance is not a voucher, and letting one reach the
|
||||||
|
* register would put the trial balance out by exactly the difference.
|
||||||
|
*/
|
||||||
|
public String createVoucherWithLines(final String voucherType, final String referenceType, final Long referenceId,
|
||||||
|
final AppUser user, final String prefix, final List<VoucherLine> lines) {
|
||||||
|
return createVoucherWithLines(voucherType, referenceType, referenceId, null, user, prefix, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As above, for a source identified by text rather than by a number. */
|
||||||
|
public String createVoucherWithLines(final String voucherType, final String referenceType, final Long referenceId,
|
||||||
|
final String referenceKey, final AppUser user, final String prefix, final List<VoucherLine> lines) {
|
||||||
|
|
||||||
|
final BigDecimal total = validateBalanced(lines);
|
||||||
|
final String voucherNo = createVoucher(voucherType, referenceType, referenceId, referenceKey, total, user, prefix);
|
||||||
|
writeLines(voucherNo, lines);
|
||||||
|
return voucherNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that the lines form a voucher, and returns the value of one side.
|
||||||
|
*
|
||||||
|
* Debits and credits are compared by {@link BigDecimal#compareTo}, not equals, so that two
|
||||||
|
* figures differing only in scale — 5000 and 5000.00 — count as the same amount rather than
|
||||||
|
* failing a voucher that is in fact balanced.
|
||||||
|
*/
|
||||||
|
private static BigDecimal validateBalanced(final List<VoucherLine> lines) {
|
||||||
|
if (lines == null || lines.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("A voucher needs at least one debit and one credit line.");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal debits = BigDecimal.ZERO;
|
||||||
|
BigDecimal credits = BigDecimal.ZERO;
|
||||||
|
for (final VoucherLine line : lines) {
|
||||||
|
if (line.amount() == null || line.amount().signum() <= 0) {
|
||||||
|
throw new IllegalArgumentException("Every voucher line needs a positive amount; the side is carried by DR/CR.");
|
||||||
|
}
|
||||||
|
if (line.isDebit()) {
|
||||||
|
debits = debits.add(line.amount());
|
||||||
|
} else if (ENTRY_CREDIT.equals(line.entryType())) {
|
||||||
|
credits = credits.add(line.amount());
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("A voucher line must be DR or CR, not '" + line.entryType() + "'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debits.signum() == 0 || credits.signum() == 0) {
|
||||||
|
throw new IllegalArgumentException("A voucher needs both a debit and a credit side.");
|
||||||
|
}
|
||||||
|
if (debits.compareTo(credits) != 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Voucher does not balance: debits " + debits.toPlainString() + " against credits " + credits.toPlainString() + ".");
|
||||||
|
}
|
||||||
|
return debits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the sides of an already-created voucher, numbered in the order given. */
|
||||||
|
private void writeLines(final String voucherNo, final List<VoucherLine> lines) {
|
||||||
|
int lineNo = 1;
|
||||||
|
for (final VoucherLine line : lines) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_voucher_line
|
||||||
|
(voucher_no, line_no, entry_type, gl_account_id, savings_account_id, loan_account_id,
|
||||||
|
party_name, account_no, amount, description)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
voucherNo, lineNo++, line.entryType(), line.glAccountId(), line.savingsAccountId(),
|
||||||
|
line.loanAccountId(), line.partyName(), line.accountNo(), line.amount(), line.description());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches sides to a voucher that was already raised without them.
|
||||||
|
*
|
||||||
|
* The three cash registers create their voucher when the request is made, but only know the
|
||||||
|
* accounts involved once it is approved. Rather than hold the voucher number back until then,
|
||||||
|
* they raise it up front and post the sides here.
|
||||||
|
*/
|
||||||
|
public void addLines(final String voucherNo, final List<VoucherLine> lines) {
|
||||||
|
if (voucherNo == null || voucherNo.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validateBalanced(lines);
|
||||||
|
|
||||||
|
// Re-posting the sides of a voucher replaces them rather than stacking a second set
|
||||||
|
// beside the first, so an approval that runs twice cannot double the printed voucher.
|
||||||
|
jdbcTemplate.update("DELETE FROM m_voucher_line WHERE voucher_no = ?", voucherNo);
|
||||||
|
writeLines(voucherNo, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account a savings account's balance is controlled by, as name and code.
|
||||||
|
*
|
||||||
|
* A member's deposit does not post against the member directly — it posts against the Savings
|
||||||
|
* Control account their product maps to. Lives here rather than in each caller because every
|
||||||
|
* register that touches a savings account needs the same lookup to print its slip.
|
||||||
|
*
|
||||||
|
* Returns an empty map when the product has no mapping, so a voucher can still name the side
|
||||||
|
* without one instead of failing over a configuration gap.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> savingsControlAccount(final Long savingsAccountId) {
|
||||||
|
if (savingsAccountId == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
// product_type 2 = savings, financial_account_type 2 = Savings Control.
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT gl.id, gl.name, gl.gl_code
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
||||||
|
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
|
||||||
|
WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2
|
||||||
|
LIMIT 1
|
||||||
|
""", savingsAccountId).stream().findFirst().orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account a savings product charges its interest expense to, as name and code.
|
||||||
|
*
|
||||||
|
* The society's side of an interest posting: the member's balance is credited, and this is what
|
||||||
|
* the credit costs the society.
|
||||||
|
*
|
||||||
|
* Returns an empty map when the product has no mapping, so a voucher can still name the side
|
||||||
|
* without one instead of failing over a configuration gap.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> savingsInterestExpenseAccount(final Long savingsAccountId) {
|
||||||
|
if (savingsAccountId == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
// product_type 2 = savings, financial_account_type 3 = Interest on Savings.
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT gl.id, gl.name, gl.gl_code
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
||||||
|
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
|
||||||
|
WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 3
|
||||||
|
LIMIT 1
|
||||||
|
""", savingsAccountId).stream().findFirst().orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account a loan's outstanding balance is carried in, as name and code.
|
||||||
|
*
|
||||||
|
* The loan counterpart of {@link #savingsControlAccount}: a disbursement or repayment posts
|
||||||
|
* against the product's Loan Portfolio account, not against the borrower directly.
|
||||||
|
*
|
||||||
|
* Returns an empty map when the product has no mapping, so a voucher can still name the side
|
||||||
|
* without one instead of failing over a configuration gap.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> loanPortfolioAccount(final Long loanId) {
|
||||||
|
if (loanId == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
// product_type 1 = loan, financial_account_type 2 = Loan Portfolio.
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT gl.id, gl.name, gl.gl_code
|
||||||
|
FROM m_loan l
|
||||||
|
JOIN acc_product_mapping apm ON apm.product_id = l.product_id
|
||||||
|
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
|
||||||
|
WHERE l.id = ? AND apm.product_type = 1 AND apm.financial_account_type = 2
|
||||||
|
LIMIT 1
|
||||||
|
""", loanId).stream().findFirst().orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
||||||
|
public Map<String, Object> glAccount(final Long glAccountId) {
|
||||||
|
if (glAccountId == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return jdbcTemplate.queryForList("SELECT id, name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||||
|
.orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or
|
||||||
|
* one whose accounts were never recorded — callers show what they have rather than treating an
|
||||||
|
* older voucher as broken.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findLines(final String voucherNo) {
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT line_no, entry_type, gl_account_id, savings_account_id, loan_account_id,
|
||||||
|
party_name, account_no, amount, description
|
||||||
|
FROM m_voucher_line
|
||||||
|
WHERE voucher_no = ?
|
||||||
|
ORDER BY line_no
|
||||||
|
""", voucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up the voucher for the given request and returns its voucher_no,
|
* Looks up the voucher for the given request and returns its voucher_no,
|
||||||
* or null if none exists (e.g. records created before vouchers existed).
|
* or null if none exists (e.g. records created before vouchers existed).
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
|||||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||||
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
|||||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!--
|
<!--
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
@@ -455,7 +455,7 @@
|
|||||||
<include file="parts/0431_add_original_json_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0431_add_original_json_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0432_add_entity_kyc_document_type_codes.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0432_add_entity_kyc_document_type_codes.xml" relativeToChangelogFile="true"/>
|
||||||
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
|
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
|
||||||
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
|
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
|
||||||
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
|
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
|
||||||
prefix, so this causes no functional collision; the prefixes are just no longer strictly sequential
|
prefix, so this causes no functional collision; the prefixes are just no longer strictly sequential
|
||||||
across the whole file. Left as-is rather than renumbering, since renumbering an already-created
|
across the whole file. Left as-is rather than renumbering, since renumbering an already-created
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
||||||
<!-- Two branches independently used 0456-0462 for unrelated migrations (share dividend vs
|
<!-- Two branches independently used 0456-0462 for unrelated migrations (share dividend vs
|
||||||
KYC/AML). Both sets are already applied against real local databases under these exact
|
KYC/AML). Both sets are already applied against real local databases under these exact
|
||||||
filenames, so neither is renumbered here — Liquibase identifies a changeSet by its
|
filenames, so neither is renumbered here — Liquibase identifies a changeSet by its
|
||||||
(id, author, filename) triplet, not by the numeric prefix, so the duplicate numbering is
|
(id, author, filename) triplet, not by the numeric prefix, so the duplicate numbering is
|
||||||
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
|
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
|
||||||
<include file="parts/0456_create_share_dividend_lock.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0456_create_share_dividend_lock.xml" relativeToChangelogFile="true"/>
|
||||||
@@ -574,4 +574,9 @@
|
|||||||
<include file="parts/0497_backfill_lost_roles.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0497_backfill_lost_roles.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0498_backfill_required_charges.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0498_backfill_required_charges.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0499_add_facility_charge_mapping_permissions.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0499_add_facility_charge_mapping_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0494_drop_dividend_forfeited_income_gl.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0495_voucher_no_drop_branch.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0496_create_voucher_line.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0497_voucher_reference_key.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0498_add_rate_percentage_to_dividend_payout.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
@@ -42,4 +42,8 @@
|
|||||||
<include file="parts/0516_savings_account_clearing_hold.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0516_savings_account_clearing_hold.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0517_grant_checker_roles_hold_release_amount.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0517_grant_checker_roles_hold_release_amount.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0518_grant_teller_roles_read_institution_branch_profile.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0518_grant_teller_roles_read_institution_branch_profile.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0516_seed_savings_interest_posting_control_gls.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0517_create_savings_daily_interest_ledger.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0518_create_savings_interest_posting_schedule.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0519_post_savings_interest_at_period_end.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
<?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-latest.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A dividend nobody claims now returns straight to Bank Profit (BANKPROFIT01) rather than
|
||||||
|
being recognised through a Dividend Forfeited Income account first.
|
||||||
|
|
||||||
|
The money never left the society, so a forfeiture is a liability that turned out not to be
|
||||||
|
owed, not income the society earned — it belongs back in the retained profit the dividend
|
||||||
|
was declared out of, in one entry rather than two.
|
||||||
|
|
||||||
|
That leaves DIVFORF01 (seeded by changelog 0458) with nothing posting to it. Dropped so it
|
||||||
|
cannot be picked in a GL dropdown and quietly accumulate balances nothing reads.
|
||||||
|
|
||||||
|
Guarded on the account still existing and carrying no journal entries: an installation that
|
||||||
|
posted forfeitures under the old rule keeps the account, because deleting it would strip the
|
||||||
|
GL name off historical entries an auditor still needs to read.
|
||||||
|
-->
|
||||||
|
<changeSet id="0494_drop_dividend_forfeited_income_activity" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<sqlCheck expectedResult="1">
|
||||||
|
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = 'DIVFORF01'
|
||||||
|
</sqlCheck>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM acc_gl_journal_entry je
|
||||||
|
JOIN acc_gl_account a ON a.id = je.account_id
|
||||||
|
WHERE a.gl_code = 'DIVFORF01'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<!-- The mapping references the account, so it goes first. -->
|
||||||
|
<sql>
|
||||||
|
DELETE FROM acc_gl_financial_activity_account
|
||||||
|
WHERE gl_account_id = (SELECT id FROM acc_gl_account WHERE gl_code = 'DIVFORF01')
|
||||||
|
</sql>
|
||||||
|
<sql>
|
||||||
|
DELETE FROM acc_gl_account WHERE gl_code = 'DIVFORF01'
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<!-- Voucher numbers drop the branch segment: CR/001/2708/0001 becomes CR/2708/0001.
|
||||||
|
What is left is the slip side, the day and month, and a counter that restarts at
|
||||||
|
0001 every day — the shape a paper voucher book has always had.
|
||||||
|
|
||||||
|
The branch is not lost, only taken out of the printed number: m_voucher still
|
||||||
|
records the office that raised each voucher (see the branch_office_id column added
|
||||||
|
below), so a per-branch register is still reportable. The consequence of the change
|
||||||
|
is that the daily counter is now bank-wide rather than per-branch, so two branches
|
||||||
|
issuing deposits on the same day draw from one shared series and never collide. -->
|
||||||
|
|
||||||
|
<!-- The office that actually handled the transaction. Previously this lived only inside
|
||||||
|
the formatted voucher number; now that the number no longer carries it, it needs a
|
||||||
|
column of its own or the information disappears entirely. -->
|
||||||
|
<changeSet id="0495_add_branch_office_to_voucher" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_voucher" columnName="branch_office_id"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_voucher">
|
||||||
|
<column name="branch_office_id" type="BIGINT"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Backfill the new column from the branch segment of existing voucher numbers, so
|
||||||
|
history keeps its branch. Only the old five-part shape PREFIX/BRANCH/DDMM/NNNN is
|
||||||
|
touched; anything else is left alone, which keeps this changeset safe to re-run. -->
|
||||||
|
<changeSet id="0495_backfill_branch_office_from_voucher_no" author="CBS">
|
||||||
|
<sql>
|
||||||
|
UPDATE m_voucher
|
||||||
|
SET branch_office_id = CAST(SPLIT_PART(voucher_no, '/', 2) AS BIGINT)
|
||||||
|
WHERE branch_office_id IS NULL
|
||||||
|
AND voucher_no ~ '^[A-Z]{2,4}/[0-9]{3}/[0-9]{4}/[0-9]{4}$'
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- The daily counter is no longer scoped by branch. The old unique constraint spanned
|
||||||
|
(prefix, branch_code, series_date) and has to go before a (prefix, series_date) one
|
||||||
|
can take its place, otherwise the first two branches to post on a day would each
|
||||||
|
hold a row for the same series and the upsert would have two candidates to match. -->
|
||||||
|
<changeSet id="0495_drop_voucher_sequence_branch_constraint" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<indexExists indexName="uq_voucher_sequence_series"/>
|
||||||
|
</preConditions>
|
||||||
|
<dropUniqueConstraint tableName="m_voucher_sequence"
|
||||||
|
constraintName="uq_voucher_sequence_series"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Collapse the existing per-branch rows for a day into one. Whichever branch had
|
||||||
|
reached the highest number for a series wins, because the counter must never hand
|
||||||
|
back a number that has already been printed on a voucher. -->
|
||||||
|
<changeSet id="0495_collapse_voucher_sequence_rows" author="CBS">
|
||||||
|
<sql>
|
||||||
|
DELETE FROM m_voucher_sequence s
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM m_voucher_sequence t
|
||||||
|
WHERE t.prefix = s.prefix
|
||||||
|
AND t.series_date = s.series_date
|
||||||
|
AND (t.last_number > s.last_number
|
||||||
|
OR (t.last_number = s.last_number AND t.id < s.id))
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
<sql>UPDATE m_voucher_sequence SET branch_code = '' WHERE branch_code IS NOT NULL</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0495_add_voucher_sequence_dateonly_constraint" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><indexExists indexName="uq_voucher_sequence_prefix_date"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addUniqueConstraint tableName="m_voucher_sequence"
|
||||||
|
columnNames="prefix, series_date"
|
||||||
|
constraintName="uq_voucher_sequence_prefix_date"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The two sides of a voucher.
|
||||||
|
|
||||||
|
m_voucher records that a voucher exists and what it is worth, but a single amount cannot
|
||||||
|
say where the money came from and where it went. A voucher that reads "5,000" is not a
|
||||||
|
voucher; one that reads "Dr Cash in Hand 5,000 / Cr Ramesh Kumar SB-00123 5,000" is. These
|
||||||
|
rows are that second half, and they are what a printed voucher and an audit are read from.
|
||||||
|
|
||||||
|
A line names exactly one side — DR or CR — of exactly one account. The account may be a
|
||||||
|
GL account, a savings account or a loan account, so three nullable id columns stand in for
|
||||||
|
what would otherwise be a polymorphic FK; the one that is set says which kind of account
|
||||||
|
this line is against. GL-to-GL, GL-to-account and account-to-account vouchers are all
|
||||||
|
just different combinations of the same two lines, which is why there is no voucher "kind"
|
||||||
|
column here.
|
||||||
|
|
||||||
|
party_name and account_no are copied in rather than joined to at read time. A voucher is a
|
||||||
|
record of what was posted on the day it was posted: if a member is later renamed or an
|
||||||
|
account renumbered, the voucher must still print what the counter actually handed over.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet id="0496_create_voucher_line" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_voucher_line"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_voucher_line">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- Joined on the formatted number, matching m_voucher_verification and
|
||||||
|
m_voucher_attachment, which key on it for the same reason: it is the only
|
||||||
|
identifier every register that raises a voucher shares. -->
|
||||||
|
<column name="voucher_no" type="VARCHAR(30)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- Print order. Lines are shown in the order the poster wrote them — conventionally
|
||||||
|
debits first — which insertion id alone would not survive a re-save. -->
|
||||||
|
<column name="line_no" type="INT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- DR | CR -->
|
||||||
|
<column name="entry_type" type="VARCHAR(2)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- Exactly one of the three is set, naming the account this side posts against. -->
|
||||||
|
<column name="gl_account_id" type="BIGINT"/>
|
||||||
|
<column name="savings_account_id" type="BIGINT"/>
|
||||||
|
<column name="loan_account_id" type="BIGINT"/>
|
||||||
|
|
||||||
|
<!-- What the voucher prints: "Cash in Hand", or the member's name. -->
|
||||||
|
<column name="party_name" type="VARCHAR(200)"/>
|
||||||
|
<!-- The GL code or the account number, as shown on the slip. -->
|
||||||
|
<column name="account_no" type="VARCHAR(50)"/>
|
||||||
|
|
||||||
|
<column name="amount" type="DECIMAL(19,6)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- Per-line narration, where one line needs a reason the voucher as a whole does not. -->
|
||||||
|
<column name="description" type="VARCHAR(500)"/>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<!-- Every read of this table is "give me the lines of this voucher", so the lookup is by
|
||||||
|
voucher_no and returned in print order. -->
|
||||||
|
<createIndex tableName="m_voucher_line" indexName="idx_voucher_line_voucher">
|
||||||
|
<column name="voucher_no"/>
|
||||||
|
<column name="line_no"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<!-- A voucher is posted once and never renumbered in place, so two lines sharing a
|
||||||
|
position mean a double insert rather than a legitimate edit. -->
|
||||||
|
<addUniqueConstraint tableName="m_voucher_line"
|
||||||
|
columnNames="voucher_no, line_no"
|
||||||
|
constraintName="uq_voucher_line_position"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Ledger reports group by side, so the side is worth its own filter rather than a scan. -->
|
||||||
|
<changeSet id="0496_index_voucher_line_gl" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><indexExists indexName="idx_voucher_line_gl_account"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createIndex tableName="m_voucher_line" indexName="idx_voucher_line_gl_account">
|
||||||
|
<column name="gl_account_id"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0496_index_voucher_line_savings" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><indexExists indexName="idx_voucher_line_savings_account"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createIndex tableName="m_voucher_line" indexName="idx_voucher_line_savings_account">
|
||||||
|
<column name="savings_account_id"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Lets a voucher point at something that is not identified by a number.
|
||||||
|
|
||||||
|
m_voucher.reference_id assumes every source has a numeric primary key, which held while
|
||||||
|
vouchers came only from the three cash registers. A manual journal is identified by its
|
||||||
|
acc_gl_journal_entry.transaction_id — a string — so it has nothing to put there. Rather
|
||||||
|
than invent a surrogate number for it, the reference gains a textual form alongside the
|
||||||
|
numeric one; a source uses whichever it actually has.
|
||||||
|
|
||||||
|
reference_id becomes nullable for the same reason: requiring it would force a placeholder
|
||||||
|
that means nothing and that later readers would have to know to disbelieve.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet id="0497_add_reference_key_to_voucher" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_voucher" columnName="reference_key"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_voucher">
|
||||||
|
<column name="reference_key" type="VARCHAR(100)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0497_relax_voucher_reference_id" author="CBS">
|
||||||
|
<dropNotNullConstraint tableName="m_voucher" columnName="reference_id" columnDataType="BIGINT"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Vouchers are looked up from their source in both directions, so the textual reference is
|
||||||
|
indexed the same way the numeric one already is. -->
|
||||||
|
<changeSet id="0497_index_voucher_reference_key" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><indexExists indexName="idx_voucher_reference_key"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createIndex tableName="m_voucher" indexName="idx_voucher_reference_key">
|
||||||
|
<column name="reference_type"/>
|
||||||
|
<column name="reference_key"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The rate a dividend was declared at, kept alongside the amount it produced.
|
||||||
|
|
||||||
|
Until now only the amount was stored, and the rate had to be re-derived as
|
||||||
|
amount / weighted share capital. That works the day it is declared and then drifts: a share
|
||||||
|
purchase back-dated into a closed period, or an account reopened, changes the capital the
|
||||||
|
division is against, and a rate the board resolved at 10% starts reporting as something else.
|
||||||
|
|
||||||
|
A rate is a board resolution, not a derived figure — the Dividend Distribution Report and the
|
||||||
|
AGM minutes both quote it — so it is recorded as declared and never recomputed.
|
||||||
|
-->
|
||||||
|
<changeSet id="0498_add_rate_percentage_to_dividend_payout" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not>
|
||||||
|
<columnExists tableName="m_share_product_dividend_pay_out" columnName="rate_percentage"/>
|
||||||
|
</not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_share_product_dividend_pay_out">
|
||||||
|
<!-- Nullable: declarations made before this column existed have no recorded rate, and a
|
||||||
|
blank is honest where a back-filled guess would not be. -->
|
||||||
|
<column name="rate_percentage" type="DECIMAL(9,4)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Back-fills the rate for declarations made before the column existed.
|
||||||
|
|
||||||
|
Derived once, here, from the same weighted-capital calculation the declaration itself used —
|
||||||
|
so the figure is the one that was actually applied, captured before any later share movement
|
||||||
|
can distort it. Only fills rows where the arithmetic is unambiguous (capital greater than
|
||||||
|
zero); anything else stays null rather than recording a rate nobody resolved.
|
||||||
|
-->
|
||||||
|
<changeSet id="0498_backfill_rate_percentage" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_share_product_dividend_pay_out WHERE rate_percentage IS NOT NULL
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
UPDATE m_share_product_dividend_pay_out d
|
||||||
|
SET rate_percentage = ROUND(d.amount / c.weighted_capital * 100, 4)
|
||||||
|
FROM (
|
||||||
|
SELECT p.id,
|
||||||
|
COALESCE(SUM(t.amount * ((p.dividend_period_end_date
|
||||||
|
- GREATEST(t.transaction_date, p.dividend_period_start_date)) + 1)), 0) / 365.0
|
||||||
|
AS weighted_capital
|
||||||
|
FROM m_share_product_dividend_pay_out p
|
||||||
|
LEFT JOIN m_share_account sa ON sa.product_id = p.product_id AND sa.status_enum = 300
|
||||||
|
LEFT JOIN m_share_account_transactions t ON t.account_id = sa.id
|
||||||
|
AND t.type_enum = 500 AND t.is_active = true AND t.status_enum = 300
|
||||||
|
AND t.transaction_date <= p.dividend_period_end_date
|
||||||
|
AND ((p.dividend_period_end_date
|
||||||
|
- GREATEST(t.transaction_date, p.dividend_period_start_date)) + 1) >= 365
|
||||||
|
GROUP BY p.id
|
||||||
|
) c
|
||||||
|
WHERE c.id = d.id AND c.weighted_capital > 0 AND d.rate_percentage IS NULL
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two control accounts for savings interest posting.
|
||||||
|
|
||||||
|
200304 Savings Interest Posting Control the day's interest for a whole product, pooled
|
||||||
|
200305 Savings Interest Posted Control the same money on its way out to members
|
||||||
|
|
||||||
|
Stock Fineract posts interest one account at a time: debit Interest on Savings, credit
|
||||||
|
Savings Control, per member, and nothing ever holds the product's total. That is correct
|
||||||
|
double entry but it leaves no figure to check the run against — if a quarter-end run dies
|
||||||
|
halfway, some members are paid and some are not, and no balance anywhere says so.
|
||||||
|
|
||||||
|
These two accounts give the run a spine. The product's total lands in Posting Control,
|
||||||
|
moves to Posted Control, and drains to the members. Both accounts must read zero when the
|
||||||
|
run finishes; a non-zero balance is a run that did not complete, and it is visible in the
|
||||||
|
trial balance rather than buried in a log.
|
||||||
|
|
||||||
|
They sit under 200300 Interest Payable on Deposits with the existing per-product payables.
|
||||||
|
Both are liabilities: between pooling and distribution the money is owed to depositors.
|
||||||
|
Manual entries are disallowed — only the posting job may move them, or the zero-balance
|
||||||
|
check stops meaning anything.
|
||||||
|
-->
|
||||||
|
<changeSet id="0516_seed_savings_interest_posting_control_gl" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200304'
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="acc_gl_account">
|
||||||
|
<column name="name" value="Savings Interest Posting Control"/>
|
||||||
|
<column name="gl_code" value="200304"/>
|
||||||
|
<column name="parent_id" valueComputed="(SELECT id FROM acc_gl_account WHERE gl_code = '200300')"/>
|
||||||
|
<column name="hierarchy" valueComputed="(SELECT COALESCE(hierarchy, '.') FROM acc_gl_account WHERE gl_code = '200300')"/>
|
||||||
|
<column name="disabled" valueBoolean="false"/>
|
||||||
|
<column name="manual_journal_entries_allowed" valueBoolean="false"/>
|
||||||
|
<column name="account_usage" valueNumeric="1"/>
|
||||||
|
<column name="classification_enum" valueNumeric="2"/>
|
||||||
|
<column name="description" value="Holds one savings product's pooled interest for a posting day. Credited with the product total, then relieved as the money moves to Savings Interest Posted Control. Must be zero once a posting run completes."/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0516_seed_savings_interest_posted_control_gl" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200305'
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="acc_gl_account">
|
||||||
|
<column name="name" value="Savings Interest Posted Control"/>
|
||||||
|
<column name="gl_code" value="200305"/>
|
||||||
|
<column name="parent_id" valueComputed="(SELECT id FROM acc_gl_account WHERE gl_code = '200300')"/>
|
||||||
|
<column name="hierarchy" valueComputed="(SELECT COALESCE(hierarchy, '.') FROM acc_gl_account WHERE gl_code = '200300')"/>
|
||||||
|
<column name="disabled" valueBoolean="false"/>
|
||||||
|
<column name="manual_journal_entries_allowed" valueBoolean="false"/>
|
||||||
|
<column name="account_usage" valueNumeric="1"/>
|
||||||
|
<column name="classification_enum" valueNumeric="2"/>
|
||||||
|
<column name="description" value="Receives the pooled interest from Savings Interest Posting Control and is relieved as each member account is credited. Must be zero once a posting run completes; a residue is an incomplete distribution."/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
hierarchy above is copied from the parent and has to be completed with this row's own id,
|
||||||
|
which Liquibase cannot know at insert time. Same shape as the existing rows: '.50.61.'
|
||||||
|
-->
|
||||||
|
<changeSet id="0516_fix_control_gl_hierarchy" author="CBS">
|
||||||
|
<sql>
|
||||||
|
UPDATE acc_gl_account
|
||||||
|
SET hierarchy = COALESCE((SELECT p.hierarchy FROM acc_gl_account p WHERE p.gl_code = '200300'), '.') || id || '.'
|
||||||
|
WHERE gl_code IN ('200304', '200305')
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A day-by-day record of how each savings account arrived at its interest.
|
||||||
|
|
||||||
|
Until now the figure existed only as m_savings_account.total_interest_earned_derived, a
|
||||||
|
single number the engine recomputes from the whole transaction history every time anyone
|
||||||
|
looks at it. It is correct, but it cannot be questioned: there is no way to ask what an
|
||||||
|
account earned on a particular day, to show a member how their interest built up, or to
|
||||||
|
notice that a day earned nothing because the balance had dipped under the product minimum.
|
||||||
|
This table is that working, written down.
|
||||||
|
|
||||||
|
One row per account per day. Interest is taken on the day CLOSING balance, matching
|
||||||
|
EndOfDayBalance in the engine - opening is kept because it chains: each day opening is
|
||||||
|
the previous day closing, unbroken across posting days and across cycles. Any break in
|
||||||
|
that chain is a day the job did not run, and it is cheap to look for.
|
||||||
|
|
||||||
|
cycle_cumulative_interest runs within the current posting cycle, not for the life of the
|
||||||
|
account. On the posting day it is credited to the member and reset to zero, and the next
|
||||||
|
cycle starts from nothing. A past cycle total is still recoverable by summing its rows.
|
||||||
|
|
||||||
|
The interest configuration is copied onto every row rather than read from the product.
|
||||||
|
Accounts carry their own rate and posting period (seeded from the product at creation, and
|
||||||
|
able to diverge from it afterwards), and a product edited next year must not silently
|
||||||
|
restate what was earned last quarter. The row is what happened.
|
||||||
|
|
||||||
|
Fineract also serves fixed and recurring deposits out of m_savings_account; those are
|
||||||
|
deliberately not covered here. Only deposit_type_enum = 100 accounts are written.
|
||||||
|
-->
|
||||||
|
<changeSet id="0517_create_savings_daily_interest_ledger" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_savings_daily_interest"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_savings_daily_interest">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- The product owns the ledger; the account is a member of it. -->
|
||||||
|
<column name="savings_product_id" type="BIGINT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="savings_account_id" type="BIGINT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="balance_date" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- The chain. opening_balance is the previous row closing_balance. -->
|
||||||
|
<column name="opening_balance" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="closing_balance" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- What the day earned, and the running total for the cycle it belongs to. -->
|
||||||
|
<column name="daily_interest" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="cycle_cumulative_interest" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- Which cycle, and when it opened, closes and pays out. Cycle 1 is a stub:
|
||||||
|
it runs from activation to the first calendar boundary, not a full period. -->
|
||||||
|
<column name="cycle_number" type="INT" defaultValueNumeric="1">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="cycle_start_date" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="cycle_end_date" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="posting_date" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- Set only on the posting day. savings_transaction_id links to the real credit in
|
||||||
|
m_savings_account_transaction, so a row can be followed through to accounting. -->
|
||||||
|
<column name="is_posting_day" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="posted_amount" type="DECIMAL(19,6)"/>
|
||||||
|
<column name="savings_transaction_id" type="BIGINT"/>
|
||||||
|
<column name="posting_run_id" type="BIGINT"/>
|
||||||
|
|
||||||
|
<!-- Why a day earned what it did, without re-reading the product a year later. -->
|
||||||
|
<column name="interest_rate" type="DECIMAL(19,6)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="days_in_year" type="INT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="compounding_period_enum" type="SMALLINT"/>
|
||||||
|
<column name="posting_period_enum" type="SMALLINT"/>
|
||||||
|
<column name="calculation_type_enum" type="SMALLINT"/>
|
||||||
|
<column name="min_balance_for_interest" type="DECIMAL(19,6)"/>
|
||||||
|
|
||||||
|
<!-- A day that closed under the product minimum earns nothing at all, not less.
|
||||||
|
Without this flag a zero row reads as a bug. -->
|
||||||
|
<column name="below_min_balance" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<column name="created_on" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- Stamped when a backdated transaction forced this day to be worked out again. -->
|
||||||
|
<column name="last_recomputed_on" type="TIMESTAMP"/>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<!-- One row per account per day, enforced rather than hoped for: a job that runs twice
|
||||||
|
must not be able to double up a day interest. -->
|
||||||
|
<addUniqueConstraint tableName="m_savings_daily_interest"
|
||||||
|
columnNames="savings_account_id, balance_date"
|
||||||
|
constraintName="uq_savings_daily_interest_account_date"/>
|
||||||
|
|
||||||
|
<createIndex tableName="m_savings_daily_interest" indexName="idx_sdi_product_date">
|
||||||
|
<column name="savings_product_id"/>
|
||||||
|
<column name="balance_date"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<!-- The posting run reads a product whole cycle; reconciliation reads one account. -->
|
||||||
|
<createIndex tableName="m_savings_daily_interest" indexName="idx_sdi_account_cycle">
|
||||||
|
<column name="savings_account_id"/>
|
||||||
|
<column name="cycle_number"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<createIndex tableName="m_savings_daily_interest" indexName="idx_sdi_posting_day">
|
||||||
|
<column name="posting_date"/>
|
||||||
|
<column name="is_posting_day"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint baseTableName="m_savings_daily_interest" baseColumnNames="savings_account_id"
|
||||||
|
constraintName="fk_sdi_savings_account"
|
||||||
|
referencedTableName="m_savings_account" referencedColumnNames="id"/>
|
||||||
|
<addForeignKeyConstraint baseTableName="m_savings_daily_interest" baseColumnNames="savings_product_id"
|
||||||
|
constraintName="fk_sdi_savings_product"
|
||||||
|
referencedTableName="m_savings_product" referencedColumnNames="id"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One row per product per posting day: the pooled total, and how far the run got.
|
||||||
|
|
||||||
|
This is what makes a failed run recoverable. The posting job writes PENDING before it
|
||||||
|
moves any money, and COMPLETED only once the control accounts are back at zero. A run
|
||||||
|
that dies leaves a PENDING row naming the product and date, and the next run resumes on
|
||||||
|
it rather than starting again and paying somebody twice.
|
||||||
|
|
||||||
|
rounding_residual is the paise that per-account rounding does not account for. Each
|
||||||
|
member interest is rounded to two decimals, and the rounded amounts rarely add up to
|
||||||
|
the rounded total; the difference is assigned to the largest account so the pool closes
|
||||||
|
exactly. Recorded here because a paisa that moves silently is a paisa nobody can audit.
|
||||||
|
-->
|
||||||
|
<changeSet id="0517_create_savings_interest_posting_run" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_savings_interest_posting_run"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_savings_interest_posting_run">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="savings_product_id" type="BIGINT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="posting_date" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<column name="account_count" type="INT" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="pooled_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="distributed_amount" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="rounding_residual" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- PENDING | COMPLETED | FAILED -->
|
||||||
|
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="failure_reason" type="VARCHAR(1000)"/>
|
||||||
|
|
||||||
|
<column name="started_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
|
||||||
|
<column name="completed_at" type="TIMESTAMP"/>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<!-- A product posting day happens once. Re-running finds this row instead of a
|
||||||
|
second pool. -->
|
||||||
|
<addUniqueConstraint tableName="m_savings_interest_posting_run"
|
||||||
|
columnNames="savings_product_id, posting_date"
|
||||||
|
constraintName="uq_savings_posting_run_product_date"/>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint baseTableName="m_savings_interest_posting_run" baseColumnNames="savings_product_id"
|
||||||
|
constraintName="fk_sipr_savings_product"
|
||||||
|
referencedTableName="m_savings_product" referencedColumnNames="id"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0517_link_daily_interest_to_posting_run" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><foreignKeyConstraintExists foreignKeyName="fk_sdi_posting_run" foreignKeyTableName="m_savings_daily_interest"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addForeignKeyConstraint baseTableName="m_savings_daily_interest" baseColumnNames="posting_run_id"
|
||||||
|
constraintName="fk_sdi_posting_run"
|
||||||
|
referencedTableName="m_savings_interest_posting_run" referencedColumnNames="id"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
When each savings product posts its interest, and whether it has.
|
||||||
|
|
||||||
|
The scheduler cannot answer this. The job table carries one cron for Post Interest For
|
||||||
|
Savings covering every product at once, and no column for a product; it says the run
|
||||||
|
starts at midnight, not that SmartSave posts on the last day of each month while SD
|
||||||
|
Product posts at quarter end. Those dates come from the posting period on each account,
|
||||||
|
worked out inside the engine and never written down anywhere a person can read.
|
||||||
|
|
||||||
|
So this table holds the timetable per product: what time of day its run starts, when it
|
||||||
|
last posted, and when it is next due. The posting job reads next_posting_date to decide
|
||||||
|
whether today is the day, and writes back after a completed run.
|
||||||
|
|
||||||
|
Kept separate from the per-run record in m_savings_interest_posting_run: that is the
|
||||||
|
history of what happened on each posting date, this is the forward schedule. One row per
|
||||||
|
product, updated in place.
|
||||||
|
|
||||||
|
run_at_time is the clock time the run starts on a posting date, not which dates those are
|
||||||
|
- the dates follow from the posting period and the financial year start, on the calendar
|
||||||
|
grid, not from anything configured here. Default is 00:30, half an hour after the
|
||||||
|
existing midnight jobs, so the day transactions have settled before interest is pooled.
|
||||||
|
-->
|
||||||
|
<changeSet id="0518_create_savings_interest_posting_schedule" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_savings_interest_posting_schedule"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_savings_interest_posting_schedule">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="savings_product_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" unique="true"
|
||||||
|
uniqueConstraintName="uq_savings_posting_schedule_product"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- Copied from the product so the timetable can be read without joining, and so a
|
||||||
|
product edited later does not silently restate a schedule already acted on. -->
|
||||||
|
<column name="posting_period_enum" type="SMALLINT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- Clock time the run starts on a posting date. -->
|
||||||
|
<column name="run_at_time" type="TIME" defaultValue="00:30:00">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<!-- Where the product is in its timetable. next_posting_date is what the job tests
|
||||||
|
against the business date; last_posting_date is the last one that completed. -->
|
||||||
|
<column name="last_posting_date" type="DATE"/>
|
||||||
|
<column name="next_posting_date" type="DATE"/>
|
||||||
|
<column name="last_run_status" type="VARCHAR(20)"/>
|
||||||
|
<column name="last_run_at" type="TIMESTAMP"/>
|
||||||
|
|
||||||
|
<!-- A product can be taken out of the automatic run without disabling the product. -->
|
||||||
|
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
|
||||||
|
<column name="created_on" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="updated_on" type="TIMESTAMP"/>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<createIndex tableName="m_savings_interest_posting_schedule" indexName="idx_sips_next_posting">
|
||||||
|
<column name="next_posting_date"/>
|
||||||
|
<column name="is_active"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint baseTableName="m_savings_interest_posting_schedule"
|
||||||
|
baseColumnNames="savings_product_id"
|
||||||
|
constraintName="fk_sips_savings_product"
|
||||||
|
referencedTableName="m_savings_product" referencedColumnNames="id"/>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Seed a row for each existing savings product, carrying its current posting period.
|
||||||
|
|
||||||
|
Restricted to deposit_type_enum = 100. Fixed and recurring deposits live in the same
|
||||||
|
product table and post interest through the same engine, but they are out of scope here
|
||||||
|
and must not be picked up by this run.
|
||||||
|
|
||||||
|
next_posting_date is deliberately left null: the first run works it out from the posting
|
||||||
|
period and the financial year start, and fills it in. Seeding a guess here would put a
|
||||||
|
wrong date in front of somebody before the job has ever run.
|
||||||
|
-->
|
||||||
|
<changeSet id="0518_seed_savings_posting_schedule_rows" author="CBS">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_savings_interest_posting_schedule
|
||||||
|
(savings_product_id, posting_period_enum, run_at_time, is_active)
|
||||||
|
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
|
||||||
|
FROM m_savings_product sp
|
||||||
|
WHERE sp.deposit_type_enum = 100
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
|
||||||
|
WHERE s.savings_product_id = sp.id)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Pay savings interest on the last day of the period rather than the first day of the next.
|
||||||
|
|
||||||
|
Fineract defaults to posting the morning after a period closes: a quarter ending 30
|
||||||
|
September is credited on 1 October. That is defensible - the period has to finish before
|
||||||
|
its interest is known - but it puts the credit in the following quarter, and in the
|
||||||
|
following month, half-year and financial year whenever the boundary is one of those. A
|
||||||
|
depositor reading a 30 September statement does not see the interest that 30 September
|
||||||
|
earned, and a quarter-end balance excludes the interest of the quarter it ends.
|
||||||
|
|
||||||
|
The platform already supports the other convention; it is off by default. Enabling it
|
||||||
|
makes the posting date equal to the period end date, which is what the daily interest
|
||||||
|
ledger records as posting_date and what this bank wants its members to see.
|
||||||
|
|
||||||
|
This is a global switch: it applies to fixed and recurring deposits as well as savings,
|
||||||
|
which is unavoidable - there is no per-product form of it. Only future postings move.
|
||||||
|
Interest already credited keeps the date it was credited on, because the change alters
|
||||||
|
which date a period ends on, not what has already been paid. Three deposit accounts in
|
||||||
|
this tenant carry earlier interest transactions and none of them are touched.
|
||||||
|
|
||||||
|
The value column is what the platform reads; enabled makes the row live. Both are set,
|
||||||
|
because a row that is enabled with a null value reads as false and the switch would look
|
||||||
|
on while behaving as off.
|
||||||
|
-->
|
||||||
|
<changeSet id="0519_post_savings_interest_at_period_end" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="1">
|
||||||
|
SELECT COUNT(*) FROM c_configuration
|
||||||
|
WHERE name = 'savings-interest-posting-current-period-end' AND enabled = false
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<update tableName="c_configuration">
|
||||||
|
<column name="value" valueNumeric="1"/>
|
||||||
|
<column name="enabled" valueBoolean="true"/>
|
||||||
|
<where>name = 'savings-interest-posting-current-period-end'</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user