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

This commit is contained in:
2026-08-30 11:10:42 +05:30
parent 6ffb0dd9c0
commit 29677e610c
44 changed files with 4265 additions and 319 deletions
@@ -24,6 +24,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
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.paymentdetail.domain.PaymentDetail;
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.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.NonTransientDataAccessException;
@@ -136,6 +139,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
private final LoanTransactionRepository loanTransactionRepository;
private final VoucherService voucherService;
@Transactional
@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)
.withTransactionId(transactionId).build();
.withTransactionId(transactionId).with(changes).build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
final Throwable throwable = dve.getMostSpecificCause();
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,
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
/**
@@ -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.LoanTransactionRepository;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
ExternalAssetOwnerRepository externalAssetOwnerRepository,
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
LoanTransactionRepository loanTransactionRepository) {
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
voucherService);
}
}
@@ -41,19 +41,21 @@ import org.springframework.jdbc.core.JdbcTemplate;
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
* Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared
* for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one
* place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
* This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has
* to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared
* for another share product over the same period, since they all draw on the same balance.
*
* Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration
* row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error.
* The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution
* 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")
@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
public class DistributableProfitApiResource {
@@ -66,8 +68,6 @@ public class DistributableProfitApiResource {
*/
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 final JdbcTemplate jdbcTemplate;
@@ -103,19 +103,14 @@ public class DistributableProfitApiResource {
final boolean yearClosed = closedProfit != null;
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);
// Appropriations only ever reduce a profit. A loss-making period 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.
final boolean profitable = netProfit.signum() > 0;
final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO;
final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO;
final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount)
: BigDecimal.ZERO;
// The statutory reserve and education fund are NOT deducted here. They are appropriated on
// the profit-distribution screen before any dividend is declared, so the profit this reads
// has already had them taken out — subtracting them again would hold back money that is no
// longer in the account. A loss-making period funds nothing, so it collapses to zero rather
// than a negative figure a caller could read as a credit.
final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO;
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
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.
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
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("alreadyDeclared", alreadyDeclared);
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
* appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable
* profit as the first.
* Every dividend already declared against a period overlapping this one, across ALL share products — the profit is
* society-wide, so a second product's declaration competes for the same balance as the first.
*
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
* declaration is separately blocked outright.
@@ -267,10 +257,6 @@ public class DistributableProfitApiResource {
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) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
@@ -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.serialization.FromJsonHelper;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -70,6 +73,10 @@ import com.google.common.base.Splitter;
@lombok.extern.slf4j.Slf4j
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 FromJsonHelper fromJsonHelper;
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(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
);
@@ -555,6 +566,62 @@ public class CashierDepositRequestApiResource {
// ── 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) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, account_no, account_type, savings_account_id,
@@ -748,7 +815,10 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString())
.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;
}
@@ -206,7 +206,7 @@ public class ShareAdvanceCollectionApiResource {
// ══════════════════════════════════════════════════════════════════════
// 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
// 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.
@@ -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.exception.GeneralPlatformDomainRuleException;
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.office.domain.Office;
import org.apache.fineract.portfolio.charge.domain.Charge;
@@ -89,6 +92,12 @@ public class ChequeBookIssuanceChargeService {
private final FineractRequestContextHolder fineractRequestContextHolder;
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
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." */
private FacilityChargeMapping resolveMapping() {
@@ -244,6 +253,7 @@ public class ChequeBookIssuanceChargeService {
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
accountId);
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
chequeBookId);
}
@@ -278,6 +288,47 @@ public class ChequeBookIssuanceChargeService {
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. */
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 "
@@ -320,7 +371,10 @@ public class ChequeBookIssuanceChargeService {
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
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) {
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
return;
@@ -24,6 +24,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.infrastructure.core.data.CommandProcessingResult;
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.stereotype.Service;
@@ -49,6 +53,11 @@ public class ClearingJournalService {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
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
@@ -57,6 +66,19 @@ public class ClearingJournalService {
*/
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) {
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) {
log.warn("Skipping clearing journal entry - missing office/GL/amount (office={}, dr={}, cr={}, amt={}).", officeId, debitGlId,
creditGlId, amount);
@@ -86,7 +108,10 @@ public class ClearingJournalService {
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
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) {
log.warn("Clearing journal entry did not return a result ({}).", comments);
return null;
@@ -95,9 +120,77 @@ public class ClearingJournalService {
if (glTxnId != null && linkAccountId != null) {
linkDebitEntryToAccount(glTxnId, linkAccountId);
}
raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType);
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) {
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,
@@ -46,6 +46,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
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.serialization.FromJsonHelper;
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.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.");
}
// 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 updatedLog = buildActivityLog(
(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
* 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) {
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) {
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,
@@ -1226,7 +1324,8 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString())
.build();
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 ─────────────────────────────────────────────
@@ -1663,7 +1762,10 @@ public class ClerkDisbursementRequestApiResource {
.withJson(payload.toString())
.build();
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;
}
@@ -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.serialization.FromJsonHelper;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -69,6 +72,10 @@ import com.google.common.base.Splitter;
@RequiredArgsConstructor
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 FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
@@ -457,6 +464,10 @@ public class ClerkWithdrawalRequestApiResource {
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(
(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) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -225,7 +225,7 @@ public class CtsClearingWritePlatformServiceImpl implements CtsClearingWritePlat
controlGlId, suspenseGlId);
} else {
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);
@@ -44,6 +44,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
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.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.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -81,15 +84,20 @@ import org.springframework.transaction.annotation.Transactional;
@Component
@Tag(name = "Dividend Claim Requests", description = "Maker-checker workflow for a member to claim an unclaimed dividend, collected as a Demand Draft.")
@RequiredArgsConstructor
@Slf4j
public class DividendClaimRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
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 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. */
private static final int FINANCIAL_ACTIVITY_DIVIDEND_PAID = 202;
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 ddPayableAtBranch = trimToNull(fromJsonHelper.extractStringNamed("ddPayableAtBranch", 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) {
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");
}
// 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(
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
if (existingPending != null && existingPending > 0) {
@@ -170,19 +172,63 @@ public class DividendClaimRequestApiResource {
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
// columns remain for claims settled through them historically.
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_share_dividend_claim_request
(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,
status, clerk_id, clerk_name, submitted_at)
VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
status, clerk_id, clerk_name, submitted_at, dd_number)
VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
RETURNING id
""", 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.
final String description = "FY " + financialYearOfLock(lockId) + " dividend paid by Demand Draft " + ddNumber + " — claim #"
+ claimId + " (lock #" + lockId + ")";
if (dividendPaidAccountId == null) {
// 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
// separate "how much has been claimed" measure.
postJournalEntryPair(payableDividendsAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate, description,
currentUser);
raiseDividendVoucher(claimId, payableDividendsAccountId, ddPayableAccountId, amount, description, currentUser);
return;
}
@@ -495,6 +541,42 @@ public class DividendClaimRequestApiResource {
// Dividend Paid nets to nil, leaving its credit total as the cumulative dividend claimed.
postJournalEntryPair(dividendPaidAccountId, ddPayableAccountId, amount, currencyCode, transactionId, entryDate,
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());
}
/**
@@ -26,6 +26,7 @@ import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@@ -127,6 +128,64 @@ public class DividendDashboardApiResource {
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 ────────────────────────────
/**
@@ -176,6 +235,76 @@ public class DividendDashboardApiResource {
// ── 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
* 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, "
+ "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, "
+ "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 " //
+ "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_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 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 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 = ? " : "") //
// 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.
@@ -214,6 +355,9 @@ public class DividendDashboardApiResource {
entry.addProperty("lockId", ((Number) row.get("lock_id")).longValue());
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("claimStatus", (String) row.get("claim_status"));
entry.addProperty("claimDdNumber", (String) row.get("claim_dd_number"));
final Object savingsAccountId = row.get("savings_account_id");
entry.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
entry.addProperty("savingsAccountNo", (String) row.get("savings_account_no"));
@@ -247,9 +391,14 @@ public class DividendDashboardApiResource {
public String list() {
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,
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
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'
AND EXISTS (
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("clientId", clientId);
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");
row.addProperty("savingsAccountId", savingsAccountId == null ? null : ((Number) savingsAccountId).longValue());
row.addProperty("lockedAmount", (BigDecimal) lock.get("locked_amount"));
@@ -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.paymentdetail.domain.PaymentDetail;
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.PostDatedChecksRepository;
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.service.RepaymentWithPostDatedChecksAssembler;
@@ -232,6 +234,7 @@ import org.springframework.transaction.annotation.Transactional;
public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatformService {
private final PlatformSecurityContext context;
private final VoucherService voucherService;
private final LoanTransactionValidator loanTransactionValidator;
private final LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer;
private final LoanRepositoryWrapper loanRepositoryWrapper;
@@ -521,6 +524,17 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
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() //
.withCommandId(command.commandId()) //
.withEntityId(loan.getId()) //
@@ -1073,6 +1087,56 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
.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) {
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.loanTransactionNote(loan, loanTransaction, noteText);
@@ -1118,6 +1182,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
loan = loanTransaction.getLoan();
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()) //
.withLoanId(loan.getId()) //
.withEntityId(loanTransaction.getId()) //
@@ -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.note.domain.NoteRepository;
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.rate.service.RateAssembler;
import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository;
@@ -413,7 +414,7 @@ public class LoanAccountConfiguration {
@Bean
@ConditionalOnMissingBean(LoanWritePlatformService.class)
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context,
public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, VoucherService voucherService,
LoanTransactionValidator loanTransactionValidator,
LoanUpdateCommandFromApiJsonDeserializer loanUpdateCommandFromApiJsonDeserializer, LoanRepositoryWrapper loanRepositoryWrapper,
LoanAccountDomainService loanAccountDomainService, NoteRepository noteRepository,
@@ -446,7 +447,8 @@ public class LoanAccountConfiguration {
LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanMapper loanMapper,
LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService,
LoanTransactionService loanTransactionService) {
return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer,
return new LoanWritePlatformServiceJpaRepositoryImpl(context, voucherService, loanTransactionValidator,
loanUpdateCommandFromApiJsonDeserializer,
loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository,
loanTransactionRelationRepository, loanAssembler, calendarInstanceRepository, paymentDetailWritePlatformService,
holidayRepository, configurationDomainService, workingDaysRepository, accountTransfersWritePlatformService,
@@ -338,10 +338,10 @@ public class NachWritePlatformServiceImpl implements NachWritePlatformService {
}
if (txn.isCredit()) {
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 {
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());
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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.paymentdetail.domain.PaymentDetail;
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.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -139,6 +141,8 @@ import org.springframework.util.CollectionUtils;
public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements SavingsAccountWritePlatformService {
private final PlatformSecurityContext context;
private final VoucherService voucherService;
private static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
private final SavingsAccountDataValidator fromApiJsonDeserializer;
private final SavingsAccountRepositoryWrapper savingAccountRepositoryWrapper;
private final StaffRepositoryWrapper staffRepository;
@@ -344,6 +348,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
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() //
.withEntityId(deposit.getId()) //
.withOfficeId(account.officeId()) //
@@ -420,6 +431,13 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
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() //
.withEntityId(withdrawal.getId()) //
.withOfficeId(account.officeId()) //
@@ -584,6 +602,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.savingAccountRepositoryWrapper.saveAndFlush(account);
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");
}
}
}
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;
}
}
}
@@ -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.note.domain.NoteRepository;
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.savings.data.DepositAccountDataValidator;
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
@@ -463,9 +464,10 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer,
savingAccountRepositoryWrapper, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler,
savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
@@ -41,7 +41,8 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
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.
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
@@ -117,13 +118,12 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
*
* <pre>
* Dr Dividend Payable — the obligation ends; it was never going to be paid
* Cr Dividend Forfeited Income
* Cr Bank Profit
* </pre>
*
* Recognised as income rather than credited to equity directly, so the recovery appears in that year's profit and
* loss where it can be seen and reported. The year-end closing job then closes it into Bank Profit along with every
* other income account, which is the same equity account the dividend was declared out of — so the money completes
* its round trip, by way of the P&L.
* 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 the
* dividend was declared out of.
*
* 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
@@ -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,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
// Credited to Dividend Forfeited Income, which the year-end closing job then closes into Bank
// Profit along with every other income account — so the money still ends up back in the
// equity account the dividend was declared out of, but by way of the P&L, where a forfeited
// dividend is visible as that year's income rather than appearing in equity with no trace.
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) {
final Long bankProfitAccountId = this.jdbcTemplate
.queryForList("SELECT id FROM acc_gl_account WHERE gl_code = ?", Long.class, PROFIT_GL_CODE).stream().findFirst()
.orElse(null);
if (payableDividendsAccountId == null || bankProfitAccountId == null) {
return;
}
@@ -164,7 +159,7 @@ public class ShareAccountSchedularServiceImpl implements ShareAccountSchedularSe
// 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,
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);
}
@@ -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
* 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
* appropriations, rounding, the residue sweep, the ceiling check against distributable profit — behaves exactly as it
* does for a single-product declaration.
* The declarations are created through the existing per-product command, so the whole downstream chain — rounding, the
* residue sweep, the funding check against available profit — behaves exactly as it does for a single-product
* 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")
@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. */
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";
/** 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 JdbcTemplate jdbcTemplate;
@@ -137,8 +143,7 @@ public class AllProductsDividendApiResource {
@GET
@Path("/preview")
public Response preview(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("ratePercentage") final BigDecimal ratePercentage,
@QueryParam("dividendBasis") final String dividendBasis) {
@QueryParam("ratePercentage") final BigDecimal ratePercentage) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
@@ -149,26 +154,18 @@ public class AllProductsDividendApiResource {
if (to.isBefore(from)) {
throw apiError(400, "toDate must not be before fromDate");
}
final String basis = normaliseBasis(dividendBasis);
final List<ProductCapital> capitals = capitalPerProduct(from, to);
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);
// 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<>();
BigDecimal grandTotal = BigDecimal.ZERO;
for (final ProductCapital capital : capitals) {
final BigDecimal amount = amountForProduct(basis, ratePercentage, capital.weightedCapital, totalWeightedCapital,
profitPool);
final BigDecimal amount = amountForProduct(ratePercentage, capital.weightedCapital);
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("productId", capital.productId);
@@ -187,13 +184,16 @@ public class AllProductsDividendApiResource {
out.put("fromDate", fromDate);
out.put("toDate", toDate);
out.put("financialYear", financialYearOf(toDate));
out.put("dividendBasis", basis);
out.put("dividendBasis", BASIS_RATE_ON_CAPITAL);
out.put("ratePercentage", ratePercentage);
out.put("totalWeightedCapital", totalWeightedCapital);
out.put("profitGlCode", PROFIT_GL_CODE);
// The whole waterfall, so the screen can show where the declarable figure came from rather
// than presenting it as a number the officer has to take on trust.
// The funding position, not the basis. Carried so a rate the profit account cannot cover is
// 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.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
// 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));
@@ -225,7 +225,6 @@ public class AllProductsDividendApiResource {
}
final LocalDate from = parseDate(fromDate);
final LocalDate to = parseDate(toDate);
final String basis = normaliseBasis(getString(json, "dividendBasis"));
final String rateText = getString(json, "ratePercentage");
if (rateText == null) {
@@ -237,10 +236,6 @@ public class AllProductsDividendApiResource {
}
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>> skipped = new ArrayList<>();
@@ -260,7 +255,7 @@ public class AllProductsDividendApiResource {
continue;
}
final BigDecimal amount = amountForProduct(basis, rate, capital.weightedCapital, totalWeightedCapital, profitPool);
final BigDecimal amount = amountForProduct(rate, capital.weightedCapital);
if (amount.signum() <= 0) {
skipped.add(skipRow(productId, productName, "The rate works out to nothing for this product."));
continue;
@@ -281,6 +276,15 @@ public class AllProductsDividendApiResource {
.withJson(payload.toString()).build();
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<>();
row.put("productId", productId);
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,
* so each product's amount follows directly from the capital its members hold.
*
* 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.
* This is what a rate means in an AGM resolution — every member receives that rate on their own paid-up capital,
* 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.
*/
private BigDecimal amountForProduct(final String basis, final BigDecimal ratePercentage, final BigDecimal weightedCapital,
final BigDecimal totalWeightedCapital, final BigDecimal profitPool) {
private BigDecimal amountForProduct(final BigDecimal ratePercentage, final BigDecimal weightedCapital) {
if (ratePercentage == null || weightedCapital.signum() <= 0) {
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);
}
@@ -369,78 +362,47 @@ public class AllProductsDividendApiResource {
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
* it, and what is left for a dividend.
* The profit available to fund a dividend for a period.
*
* Carried as a whole rather than collapsed to the final figure because the officer is approving a division of the
* society's profit, not a single number. Seeing ₹2,22,000 alone gives no way to tell a small profit apart from a
* large one that has already been heavily appropriated — and the reserve and education percentages are
* configurable, so the amounts they take cannot be inferred from the result either.
* Just the one figure, because that is all profit decides now: the dividend amount comes from members' share
* capital, and profit only has to be able to pay it. The statutory reserve and education fund are not deducted
* here — they are appropriated on the profit-distribution screen before a dividend is declared, so the balance
* this reads has already had them taken out.
*/
private static final class ProfitWaterfall {
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 ProfitWaterfall(final BigDecimal netProfit, final BigDecimal statutoryReservePercentage,
final BigDecimal statutoryReserveAmount, final BigDecimal educationFundPercentage,
final BigDecimal educationFundAmount, final BigDecimal distributableProfit) {
private ProfitWaterfall(final BigDecimal netProfit, final BigDecimal distributableProfit) {
this.netProfit = netProfit;
this.statutoryReservePercentage = statutoryReservePercentage;
this.statutoryReserveAmount = statutoryReserveAmount;
this.educationFundPercentage = educationFundPercentage;
this.educationFundAmount = educationFundAmount;
this.distributableProfit = distributableProfit;
}
private Map<String, Object> asMap() {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("netProfit", netProfit);
out.put("statutoryReservePercentage", statutoryReservePercentage);
out.put("statutoryReserveAmount", statutoryReserveAmount);
out.put("educationFundPercentage", educationFundPercentage);
out.put("educationFundAmount", educationFundAmount);
out.put("distributableProfit", distributableProfit);
return out;
}
}
/**
* The profit a dividend may be declared out of: the year's net profit less the statutory reserve and education
* fund, which are appropriated before any dividend.
* What the profit account holds for the period, which is what a dividend can be funded from.
*
* 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
* 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) {
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) {
final BigDecimal zero = BigDecimal.ZERO.setScale(2);
return new ProfitWaterfall(netProfit == null ? zero : netProfit, reservePercentage, zero, educationPercentage, zero,
zero);
return new ProfitWaterfall(netProfit == null ? zero : netProfit, zero);
}
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));
return new ProfitWaterfall(netProfit, netProfit);
}
/**
@@ -489,30 +451,27 @@ public class AllProductsDividendApiResource {
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.
*
* 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
* 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,
final int minimumActivePeriod) {
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
JOIN m_share_account_transactions t ON t.account_id = sa.id
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.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),
java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod);
return total == null ? BigDecimal.ZERO : total;
@@ -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 &amp; 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());
}
}
@@ -200,7 +200,12 @@ public class ShareDividendCapitalPreviewApiResource {
}
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;
}
@@ -154,7 +154,12 @@ public class ShareProductDividendAssembler {
if (DateUtils.isBefore(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) {
continue;
}
@@ -91,6 +91,15 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
// FinancialActivity ids (AccountingConstants.FinancialActivity) — kept local to avoid pulling the whole
// accounting module into this constructor just for two int constants.
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;
/**
@@ -105,11 +114,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
*/
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.
private static final int JOURNAL_ENTRY_TYPE_CREDIT = 1;
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(), ?, ?)
""";
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
public CommandProcessingResult createShareProduct(JsonCommand jsonCommand) {
try {
@@ -203,8 +204,9 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
this.shareProductDividentPayOutDetailsRepository.saveAndFlush(dividendPayOutDetails);
final ShareProduct product = this.repository.findOneWithNotFoundDetection(productId);
postStatutoryAppropriations(dividendPayOutDetails.getId(), product.getCurrency().getCode(), dividendPeriodStartDate,
dividendPeriodEndDate, financialYear);
// The statutory reserve and education fund are NOT appropriated here. They are a division
// 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,
financialYear);
recordAndSweepRoundingResidue(dividendPayOutDetails.getId(), productId, product.getCurrency().getCode(), dividendAmount,
@@ -282,8 +284,10 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
if (netProfit.signum() <= 0) {
return;
}
final BigDecimal distributable = netProfit.subtract(percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE))
.subtract(percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE));
// The balance in the profit GL is already net of the statutory reserve and education fund —
// 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
// 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
* 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.
* A dividend must not commit more money than the profit account holds.
*
* The declaration screen shows the same waterfall (see {@code DistributableProfitApiResource}), but the ceiling is
* enforced here 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.
* The amount itself is settled before this runs — it is the declared rate on members' paid-up capital, and profit
* plays no part in working it out. This is purely a funding check: whatever the rate produced, the society has to
* actually have it.
*
* Appropriation rates come from c_configuration (changelog 0478); a missing or disabled row means the society has
* an exemption from that appropriation, which is 0% rather than an error. If all rates are 0 and no other
* declaration overlaps, this reduces to "must not exceed net profit", which is still the right floor.
* What it is checked against is the profit remaining in the Bank Profit GL, which is already net of the statutory
* reserve and education fund — those are appropriated on the profit-distribution screen before any dividend is
* 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,
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.");
}
final BigDecimal statutoryReserve = percentageOfNetProfit(netProfit, CONFIG_STATUTORY_RESERVE_PERCENTAGE);
final BigDecimal educationFund = percentageOfNetProfit(netProfit, CONFIG_EDUCATION_FUND_PERCENTAGE);
final BigDecimal distributableProfit = netProfit.subtract(statutoryReserve).subtract(educationFund);
// 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.
// Every product draws on the same 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("""
SELECT COALESCE(SUM(amount), 0) FROM m_share_product_dividend_pay_out
WHERE dividend_period_start_date <= ? AND dividend_period_end_date >= ?
""", BigDecimal.class, to, from);
final BigDecimal available = distributableProfit.subtract(alreadyDeclared);
final BigDecimal available = netProfit.subtract(alreadyDeclared);
if (dividendAmount.compareTo(available) > 0) {
throw new DividentProcessingException("exceeds.distributable.profit",
"This dividend of " + dividendAmount + " exceeds what may be distributed for this period. Net profit " + netProfit
+ " less statutory reserve " + statutoryReserve + " and education fund " + educationFund + " leaves "
+ distributableProfit + ", of which " + alreadyDeclared + " is already declared — only "
+ available.max(BigDecimal.ZERO) + " remains available.");
"This dividend of " + dividendAmount + " is more than the profit available to fund it for this period. Bank profit "
+ netProfit + ", of which " + alreadyDeclared + " is already declared — only " + available.max(BigDecimal.ZERO)
+ " remains available.");
}
}
@@ -540,70 +542,6 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
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) {
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);
sweepRoundingResidueOnApproval(PayOutDetailId);
makeDividendsClaimable(PayOutDetailId);
forfeitDividendsBeyondClaimWindow(dividendPayOutDetails);
closeOffUndistributedProfit(dividendPayOutDetails);
return new CommandProcessingResultBuilder() //
.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,
* 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) {
final LocalDate today = DateUtils.getBusinessLocalDate();
final java.sql.Date lockedDate = java.sql.Date.valueOf(today);
@@ -23,10 +23,12 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
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.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
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.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -47,6 +51,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@RequiredArgsConstructor
@Slf4j
public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDIssueRequestWritePlatformService {
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_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,
// 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
@@ -67,6 +88,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final SponsorDDWritePlatformService sponsorDDWritePlatformService;
@@ -471,6 +493,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
}
} else if (METHOD_CHEQUE.equals(paymentMethod)) {
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 {
// 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.
@@ -495,10 +523,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId,
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)
.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
@Transactional
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 = ?",
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();
}
@@ -1068,7 +1158,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.withJson(payload.toString())
.build();
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();
}
@@ -1087,7 +1180,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.withJson(payload.toString())
.build();
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();
}
@@ -1121,6 +1217,61 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
currentAccountId);
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
@@ -1140,6 +1291,46 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
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). */
private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount,
final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId,
@@ -1192,7 +1383,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.withJson(payload.toString())
.build();
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;
if (transactionId == null) return null;
@@ -33,13 +33,16 @@ import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
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.exception.PlatformDataIntegrityException;
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.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -68,11 +71,12 @@ public class VoucherVerificationApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final VoucherService voucherService;
private final Gson gson = new Gson();
// ── POST /v1/vouchers/{voucherNo}/verify ──────────────────────────────────
// 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
@Path("verify")
@@ -412,6 +416,223 @@ public class VoucherVerificationApiResource {
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
* null when no register knows the number, which is how callers detect a bad voucher.
@@ -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);
}
}
@@ -35,15 +35,19 @@ import org.springframework.stereotype.Service;
/**
* Central voucher register (m_voucher). Every maker-checker cash transaction
* (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
* and DR/001/2108/0001 for a withdrawal on 21 August at branch 001.
* the form PREFIX/DDMM/NNNN — for example CR/2108/0001 for a deposit and
* DR/2108/0001 for a withdrawal on 21 August.
*
* 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
* same day never share a number, and neither does the same direction at another
* branch. 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.
* prefix, so a deposit and a withdrawal on the same day never share a number.
* The series is bank-wide rather than per-branch: the number names the slip
* side and the day, and nothing else. The office that raised the voucher is
* still recorded, in m_voucher.branch_office_id, so a per-branch register
* 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
* (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_DEPOSIT = "DEPOSIT";
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_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
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. */
public static final String PREFIX_CREDIT = "CR";
/** Money out of an account — the DR side of the slip. */
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 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
* 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;
}
/** 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
* 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
* moment are serialised by the database and can never be handed the same
* 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("""
INSERT INTO m_voucher_sequence (prefix, branch_code, series_date, last_number)
VALUES (?,?,?,1)
ON CONFLICT (prefix, branch_code, series_date)
VALUES (?,'',?,1)
ON CONFLICT (prefix, series_date)
DO UPDATE SET last_number = m_voucher_sequence.last_number + 1
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.
* "CR/001/2108/0001". The branch is taken from the acting user's own office
* rather than from any branch the screen happens to have selected, so the
* voucher always names the branch that actually handled the cash.
* "CR/2108/0001". The office that handled the cash is taken from the acting
* user rather than from any branch the screen happens to have selected, and
* is stored alongside the voucher instead of being printed into its number.
*/
public String createVoucher(final String voucherType, final String referenceType,
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,
final Long referenceId, final BigDecimal amount, final AppUser user,
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
// branch's business day, otherwise a late-evening voucher lands in the wrong series.
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String branch = branchCode(user.getOffice() != null ? user.getOffice().getId() : null);
final int serial = nextSeriesNumber(prefix, branch, today);
final String voucherNo = String.format("%s/%s/%s/%04d",
prefix, branch, today.format(DAY_MONTH), serial);
final int serial = nextSeriesNumber(prefix, today);
final String voucherNo = String.format("%s/%s/%04d",
prefix, today.format(DAY_MONTH), serial);
final Long branchOfficeId = user.getOffice() != null ? user.getOffice().getId() : null;
jdbcTemplate.update("""
INSERT INTO m_voucher
(voucher_no, voucher_type, reference_type, reference_id, amount,
created_by, created_by_username, created_at)
VALUES (?,?,?,?,?,?,?,?)
(voucher_no, voucher_type, reference_type, reference_id, reference_key, amount,
branch_office_id, created_by, created_by_username, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
""",
voucherNo, voucherType, referenceType, referenceId, amount,
user.getId(), user.getUsername(), Timestamp.from(Instant.now())
voucherNo, voucherType, referenceType, referenceId, referenceKey, amount,
branchOfficeId, user.getId(), user.getUsername(), Timestamp.from(Instant.now())
);
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,
* 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.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
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.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
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.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
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.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
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
@@ -455,7 +455,7 @@
<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"/>
<!-- 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
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
@@ -509,7 +509,7 @@
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
<!-- 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
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
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
<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/0498_backfill_required_charges.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>
@@ -42,4 +42,8 @@
<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/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>
@@ -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>
@@ -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 &gt; s.last_number
OR (t.last_number = s.last_number AND t.id &lt; 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>
@@ -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>
@@ -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>
@@ -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 &lt;= 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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>