Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aceea89c5c | ||
|
|
1fd94615a9 | ||
|
|
3d3ce92e93 | ||
|
|
1a914a3de0 | ||
|
|
6e7eb94776 | ||
|
|
6fbc3a85a8 | ||
|
|
949f45c4cd | ||
|
|
6c7c0f8445 | ||
|
|
88157ee9d6 | ||
|
|
760fff8d3e | ||
|
|
d7ff4e4a7b | ||
|
|
7068d65422 |
+1
-1
@@ -154,7 +154,7 @@ gitVersioning.apply {
|
||||
}
|
||||
}
|
||||
rev {
|
||||
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
|
||||
version = '1.15.0-SNAPSHOT'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
|
||||
Collection<Teller> findTellerByOfficeId(Long officeId);
|
||||
|
||||
long countByDeletedFalse();
|
||||
|
||||
long countByOfficeIdAndDeletedFalse(Long officeId);
|
||||
}
|
||||
|
||||
+5
@@ -58,4 +58,9 @@ public class TellerRepositoryWrapper {
|
||||
public boolean existsAny() {
|
||||
return this.repository.countByDeletedFalse() > 0;
|
||||
}
|
||||
|
||||
/** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */
|
||||
public boolean existsInOffice(final Long officeId) {
|
||||
return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce
|
||||
public TellerAlreadyExistsException() {
|
||||
super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
public TellerAlreadyExistsException(final String officeName) {
|
||||
super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.",
|
||||
officeName);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-10
@@ -137,16 +137,7 @@ public class CommandSourceService {
|
||||
String permission = commandSource.getPermissionCode();
|
||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||
// isApprovedByChecker is only ever true here for a genuine checker approval
|
||||
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
|
||||
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
|
||||
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
|
||||
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
|
||||
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
|
||||
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
|
||||
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
|
||||
// auto-approve their own submission just by holding it.
|
||||
if (isApprovedByChecker) {
|
||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||
commandSource.markAsChecked(user);
|
||||
} else {
|
||||
if (commandSource.isSanitized()) {
|
||||
@@ -160,6 +151,25 @@ public class CommandSourceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||
if (!user.isCheckerSuperUser()) {
|
||||
return false;
|
||||
}
|
||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||
return;
|
||||
|
||||
+8
-20
@@ -48,12 +48,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final CommandProcessingService processAndLogCommandService;
|
||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
||||
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
|
||||
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
|
||||
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
|
||||
// live, independently-configurable global switch (System -> Configurations); deleting this field,
|
||||
// and by extension deciding that switch should stop existing altogether, is a separate decision
|
||||
// from closing this specific self-approval loophole.
|
||||
private final ConfigurationDomainService configurationService;
|
||||
|
||||
@Override
|
||||
@@ -129,20 +123,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
AppUser appUser = this.context.authenticatedUser();
|
||||
String permissionCode = commandSource.getPermissionCode();
|
||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
||||
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
||||
// permission still lets its holder action a DIFFERENT user's request without needing the
|
||||
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
|
||||
// longer lets them action their own) and not overridable by the enable-same-maker-checker
|
||||
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
||||
// configuration is left in place and unused for this decision rather than removed, since
|
||||
// deleting a still-configurable global setting is a separate decision from this one).
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
}
|
||||
}
|
||||
return commandSource;
|
||||
}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ public final class GlobalConfigurationConstants {
|
||||
public static final String FORCE_PASSWORD_RESET_DAYS = "force-password-reset-days";
|
||||
public static final String GRACE_ON_PENALTY_POSTING = "grace-on-penalty-posting";
|
||||
public static final String SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END = "savings-interest-posting-current-period-end";
|
||||
public static final String FD_INTEREST_POSTING_APPROVAL = "fd-interest-posting-approval";
|
||||
public static final String FINANCIAL_YEAR_BEGINNING_MONTH = "financial-year-beginning-month";
|
||||
public static final String MIN_CLIENTS_IN_GROUP = "min-clients-in-group";
|
||||
public static final String MAX_CLIENTS_IN_GROUP = "max-clients-in-group";
|
||||
|
||||
+6
@@ -69,6 +69,12 @@ public interface ConfigurationDomainService {
|
||||
|
||||
boolean isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||
|
||||
/**
|
||||
* Whether fixed deposit interest is reported and approved before it is posted, instead of being
|
||||
* posted directly by the nightly job.
|
||||
*/
|
||||
boolean isFixedDepositInterestPostingApprovalEnabled();
|
||||
|
||||
Integer retrieveFinancialYearBeginningMonth();
|
||||
|
||||
Integer retrieveMinAllowedClientsInGroup();
|
||||
|
||||
+1
@@ -44,6 +44,7 @@ public enum JobName {
|
||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
||||
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
|
||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The deposit scheme a fixed deposit product is sold under, held as {@code m_savings_product.fd_type}.
|
||||
*
|
||||
* <p>
|
||||
* The three schemes differ in where a posting of interest ends up, which is what the interest posting
|
||||
* report has to show and the posting itself has to honour:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #SINGLE} — short deposits, priced from a slab table in days. Interest stays in the
|
||||
* deposit and is paid with the principal at maturity.</li>
|
||||
* <li>{@link #DOUBLE} — deposits of a year or more, priced from a slab table in years. Interest is
|
||||
* compounded back into the deposit, so the maturity value is principal plus interest.</li>
|
||||
* <li>{@link #SPECIAL} — the monthly income scheme. Interest is posted monthly and swept out to the
|
||||
* customer's linked savings account, which is why a special deposit cannot be opened without one;
|
||||
* the deposit itself matures at its principal, the interest having already been paid away.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The database column is a string rather than an enum ordinal, so the names here are the stored
|
||||
* values and must not be renamed. {@link #fromString(String)} is lenient about case and blanks
|
||||
* because the column carries a default of SINGLE and older products predate the column entirely.
|
||||
*/
|
||||
public enum FixedDepositType {
|
||||
|
||||
/** Interest is held in the deposit and paid at maturity. The default for a product that names none. */
|
||||
SINGLE,
|
||||
/** Interest is compounded into the deposit; the maturity value includes it. */
|
||||
DOUBLE,
|
||||
/** Interest is posted monthly and transferred to the linked savings account. */
|
||||
SPECIAL;
|
||||
|
||||
/**
|
||||
* The scheme named by a stored {@code fd_type}, defaulting to {@link #SINGLE}.
|
||||
*
|
||||
* <p>
|
||||
* A null, blank or unrecognised value reads as SINGLE: that is the column's own default, and a
|
||||
* product carrying something else is a misconfiguration that must not stop its customers' interest
|
||||
* from being reported.
|
||||
*/
|
||||
public static FixedDepositType fromString(final String fdType) {
|
||||
if (fdType == null || fdType.isBlank()) {
|
||||
return SINGLE;
|
||||
}
|
||||
for (final FixedDepositType candidate : values()) {
|
||||
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return SINGLE;
|
||||
}
|
||||
|
||||
public static boolean isValid(final String fdType) {
|
||||
if (fdType == null || fdType.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (final FixedDepositType candidate : values()) {
|
||||
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether interest leaves the deposit for the linked savings account each time it is posted. */
|
||||
public boolean isPaidOutMonthly() {
|
||||
return this == SPECIAL;
|
||||
}
|
||||
|
||||
/** Whether a posting of interest adds to the deposit's own balance. */
|
||||
public boolean isRetainedInDeposit() {
|
||||
return this != SPECIAL;
|
||||
}
|
||||
|
||||
/** What a customer sees: "FD Special", "FD Double", "FD Single". */
|
||||
public String displayName() {
|
||||
return "FD " + name().charAt(0) + name().substring(1).toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
+4
@@ -84,4 +84,8 @@ public final class LedgerTxnPrefix {
|
||||
// ── Savings ───────────────────────────────────────────────────────────────
|
||||
/** Pooled savings interest posting for one product and date. */
|
||||
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
|
||||
|
||||
// ── Fixed deposits ────────────────────────────────────────────────────────
|
||||
/** Approved fixed deposit interest posting report, for one product, branch and date. */
|
||||
public static final String FD_INTEREST_POSTED = "FDINT";
|
||||
}
|
||||
|
||||
+10
@@ -191,6 +191,16 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
||||
return property.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read on every run of the interest posting job and of the day close, so the two paths can never
|
||||
* both pay the same deposit: with this on the job leaves fixed deposits alone and they are paid
|
||||
* from an approved report, with it off the job pays them directly as it always did.
|
||||
*/
|
||||
@Override
|
||||
public boolean isFixedDepositInterestPostingApprovalEnabled() {
|
||||
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_INTEREST_POSTING_APPROVAL).isEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer retrieveFinancialYearBeginningMonth() {
|
||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||
|
||||
+6
@@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
|
||||
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
|
||||
private final TenantDataSourceFactory tenantDataSourceFactory;
|
||||
private final Environment environment;
|
||||
private final TenantInitialUserProvisioner initialUserProvisioner;
|
||||
|
||||
// DO NOT REMOVE! Required for liquibase custom task initialization
|
||||
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
|
||||
@@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
|
||||
// configuration
|
||||
// settings, and we should not use them to control the script order
|
||||
if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) {
|
||||
// a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones
|
||||
final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource);
|
||||
ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT,
|
||||
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
|
||||
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
|
||||
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
|
||||
if (brandNewTenant) {
|
||||
initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier());
|
||||
}
|
||||
}
|
||||
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
|
||||
tenant.getTenantIdentifier());
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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.infrastructure.core.service.migration;
|
||||
|
||||
import java.util.Locale;
|
||||
import javax.sql.DataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.useradministration.service.AppUserConstants;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3,
|
||||
* which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the
|
||||
* public default password).
|
||||
*
|
||||
* Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never
|
||||
* touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and
|
||||
* break validation on every tenant that already ran it.
|
||||
*
|
||||
* Usernames become {@code <tenantIdentifier>_admin} and {@code <tenantIdentifier>_interop}. The system user keeps the
|
||||
* {@link AppUserConstants#SYSTEM_USER_NAME} username because scheduler jobs, COB and other background processes look it
|
||||
* up by that name; only its password is changed. All three share one password: the value of
|
||||
* {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set,
|
||||
* otherwise {@link #DEFAULT_PASSWORD}.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TenantInitialUserProvisioner {
|
||||
|
||||
static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password";
|
||||
|
||||
private static final long ADMIN_ID = 1L;
|
||||
private static final long SYSTEM_ID = 2L;
|
||||
private static final long INTEROP_ID = 3L;
|
||||
// Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars).
|
||||
static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public void provision(final DataSource dataSource, final String tenantIdentifier) {
|
||||
final JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
final String prefix = usernamePrefix(tenantIdentifier);
|
||||
|
||||
final String configured = environment.getProperty(PASSWORD_PROPERTY);
|
||||
final boolean overridden = configured != null && !configured.isBlank();
|
||||
final String password = overridden ? configured : DEFAULT_PASSWORD;
|
||||
final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
final String encoded = encoder.encode(password);
|
||||
|
||||
update(jdbc, ADMIN_ID, prefix + "_admin", encoded);
|
||||
update(jdbc, SYSTEM_ID, AppUserConstants.SYSTEM_USER_NAME, encoded);
|
||||
update(jdbc, INTEROP_ID, prefix + "_interop", encoded);
|
||||
|
||||
log.warn("Tenant '{}' initial users created: {}_admin, {}, {}_interop (password: {}). Change it after first login.",
|
||||
tenantIdentifier, prefix, AppUserConstants.SYSTEM_USER_NAME, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default");
|
||||
}
|
||||
|
||||
private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) {
|
||||
jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username,
|
||||
encodedPassword, id);
|
||||
}
|
||||
|
||||
static String usernamePrefix(final String tenantIdentifier) {
|
||||
final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", "");
|
||||
return cleaned.isEmpty() ? "tenant" : cleaned;
|
||||
}
|
||||
|
||||
}
|
||||
+6
-1
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
|
||||
|
||||
@Override
|
||||
public Collection<TellerData> getTellers(Long officeId) {
|
||||
return retrieveAllTellers(false);
|
||||
if (officeId == null) {
|
||||
return retrieveAllTellers(false);
|
||||
}
|
||||
// The tellers of that one branch. The officeId used to be ignored, so the head office, whose
|
||||
// hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close.
|
||||
return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+6
-4
@@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
||||
try {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
if (this.tellerRepositoryWrapper.existsAny()) {
|
||||
throw new TellerAlreadyExistsException();
|
||||
}
|
||||
|
||||
final Long officeId = command.longValueOfParameterNamed("officeId");
|
||||
|
||||
this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json());
|
||||
@@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer
|
||||
// final Office parent =
|
||||
// validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId);
|
||||
final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
|
||||
// One teller (vault) per branch, not one for the whole bank: each branch holds and settles
|
||||
// its own cash, so a second branch needs a vault of its own.
|
||||
if (this.tellerRepositoryWrapper.existsInOffice(officeId)) {
|
||||
throw new TellerAlreadyExistsException(tellerOffice.getName());
|
||||
}
|
||||
final Teller teller = Teller.fromJson(tellerOffice, command);
|
||||
|
||||
// pre save to generate id for use in office hierarchy
|
||||
|
||||
+71
-20
@@ -48,6 +48,8 @@ import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
|
||||
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -71,12 +73,28 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class BranchDayRegisterApiResource {
|
||||
|
||||
/**
|
||||
* The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier
|
||||
* allocation if that allocation already existed when the cash was counted, otherwise the officer's
|
||||
* own office. A person moved to another branch's teller keeps what they took at the old branch
|
||||
* there.
|
||||
*/
|
||||
private static final String COUNTER_OFFICE_OF_STD = """
|
||||
COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = mc.teller_id
|
||||
WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """
|
||||
LIMIT 1),
|
||||
(SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id))
|
||||
""";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final SavingsInterestPostingReportService interestPostingReportService;
|
||||
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
|
||||
private final FixedDepositInterestPostingReportService fdInterestPostingReportService;
|
||||
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||
@@ -195,8 +213,8 @@ public class BranchDayRegisterApiResource {
|
||||
// Scoped to this office through the officer who took the cash (appuser -> staff -> cashier ->
|
||||
// teller -> office). Unscoped, a branch's own "does the physical count agree with the books"
|
||||
// cross-check was being compared against every branch's counted cash at once, which makes it
|
||||
// wrong at every office but the first. A row whose officer maps to no cashier is still
|
||||
// counted, for the same reason as the withdrawal legs in bookBreakdown.
|
||||
// wrong at every office but the first. A row whose officer has no cashier allocation counts
|
||||
// at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office.
|
||||
//
|
||||
// Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the
|
||||
// note there on why a manual GL entry's decoded reference is kept and a stamped millisecond
|
||||
@@ -205,27 +223,29 @@ public class BranchDayRegisterApiResource {
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||
) = ?)
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL'
|
||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||
AND (std.officer_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
WHERE au.id = std.officer_id)
|
||||
OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = std.officer_id AND t.office_id = ?))
|
||||
AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||
) = ?)
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
result.put("denominationIn", countedIn);
|
||||
result.put("denominationOut", countedOut);
|
||||
|
||||
// What will stop this close, so the screen can say so before the button is pressed rather than
|
||||
// after. The same lists closeDay() refuses on, read-only here.
|
||||
result.put("savingsInterestPending", interestPostingReportService.reportsBlockingDayClose(officeId, businessDate));
|
||||
// One call for the deposits: what is blocking, and the products and amounts behind it, so the
|
||||
// branch sees what it is being asked to approve without leaving the close screen. Pricing a
|
||||
// branch's deposits is the expensive part and is done once.
|
||||
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
|
||||
result.put("fdInterestPending", fdInterest.get("pending"));
|
||||
result.put("fdInterestDue", fdInterest.get("due"));
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@@ -604,16 +624,25 @@ public class BranchDayRegisterApiResource {
|
||||
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
|
||||
}
|
||||
|
||||
// ── And its fixed deposit interest reports, on the same grounds ──
|
||||
// Only while deposits are on the approval path at all; with that switched off the posting job
|
||||
// pays them directly and there is nothing here to approve.
|
||||
final List<String> fdInterestPending = fdInterestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
|
||||
if (!fdInterestPending.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.report.pending",
|
||||
"Cannot close the day — this branch's fixed deposit interest posting report is not approved for: "
|
||||
+ String.join(", ", fdInterestPending)
|
||||
+ ". Approve it under Reports → FD Interest Posting Reports, then close the day.");
|
||||
}
|
||||
|
||||
// ── The head office closes last ──
|
||||
// Every branch that opened the day closes first and posts its own interest; a branch that
|
||||
// never opened is not waited for.
|
||||
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
|
||||
if (isHeadOffice) {
|
||||
final List<String> stillOpen = jdbcTemplate.queryForList("""
|
||||
SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
|
||||
WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ?
|
||||
ORDER BY o.name
|
||||
""", String.class, Date.valueOf(businessDate), officeId);
|
||||
// Any day still open counts, not only today's: a branch whose earlier day was never closed is
|
||||
// still working and has not posted its interest.
|
||||
final List<String> stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId);
|
||||
if (!stillOpen.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
|
||||
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
|
||||
@@ -741,6 +770,23 @@ public class BranchDayRegisterApiResource {
|
||||
}
|
||||
}
|
||||
|
||||
// ── And the branch's approved fixed deposit interest, the same way ──
|
||||
// Separate from the savings run so a deposit product with a GL problem cannot stop savings
|
||||
// interest that has already posted cleanly, and so the refusal names which of the two failed.
|
||||
final List<Long> fdToPost = new ArrayList<>(fdInterestPostingReportService.approvedReportIds(officeId, businessDate));
|
||||
if (isHeadOffice) {
|
||||
fdToPost.addAll(fdInterestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
|
||||
}
|
||||
if (!fdToPost.isEmpty()) {
|
||||
log.info("Day close {}: office {} posting {} approved FD interest report(s)", businessDate, officeId, fdToPost.size());
|
||||
final List<String> fdFailures = fdInterestPostingOrchestrator.postReports(fdToPost);
|
||||
if (!fdFailures.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.posting.failed",
|
||||
"Cannot close the day — fixed deposit interest could not be posted: " + String.join("; ", fdFailures)
|
||||
+ ". The report is still approved; fix the cause and close the day again.");
|
||||
}
|
||||
}
|
||||
|
||||
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
|
||||
// this write are two separate statements, so two managers pressing Close at once (or one
|
||||
// double-click) both got past the check. The second UPDATE then overwrote the first's
|
||||
@@ -1058,6 +1104,9 @@ public class BranchDayRegisterApiResource {
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", r.get("id"));
|
||||
// The close screen fetches this office's tellers with it; left out, it fetched every teller
|
||||
// the user can see, and the head office counted each branch's vault as its own.
|
||||
m.put("officeId", r.get("office_id"));
|
||||
m.put("businessDate", str(r.get("business_date")));
|
||||
m.put("openedByName", r.get("opened_by_name"));
|
||||
m.put("openedAt", str(r.get("opened_at")));
|
||||
@@ -1111,11 +1160,13 @@ public class BranchDayRegisterApiResource {
|
||||
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
|
||||
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
|
||||
|
||||
// By the counter that took the cash. A savings deposit carries no office_id, and the old
|
||||
// "OR office_id IS NULL" counted every such deposit in the bank at every branch.
|
||||
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
|
||||
WHERE status = 'APPROVED'
|
||||
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
|
||||
AND (office_id = ? OR office_id IS NULL)
|
||||
AND COALESCE(counter_office_id, office_id) = ?
|
||||
""", BigDecimal.class, date, officeId));
|
||||
|
||||
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
|
||||
|
||||
+17
-3
@@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource {
|
||||
client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
||||
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at,
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
@@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource {
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId
|
||||
ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser)
|
||||
);
|
||||
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
@@ -691,6 +691,20 @@ public class CashierDepositRequestApiResource {
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch whose counter took the cash: the office of the teller the user is allocated to as a
|
||||
* cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries
|
||||
* none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it.
|
||||
*/
|
||||
private Long counterOfficeId(final AppUser user) {
|
||||
final List<Long> tellerOffice = user.getStaff() == null ? List.of()
|
||||
: jdbcTemplate.queryForList("""
|
||||
SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE c.staff_id = ? LIMIT 1
|
||||
""", Long.class, user.getStaff().getId());
|
||||
return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ public class CashReconciliationApiResource {
|
||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Out: settlements to vault
|
||||
@@ -123,7 +123,7 @@ public class CashReconciliationApiResource {
|
||||
+ "AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") "
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
|
||||
|
||||
+9
-9
@@ -671,25 +671,25 @@ public class CashReportApiResource {
|
||||
// day itself: taken from the vault, in over the counter, out over the counter, settled
|
||||
// back. What is left is what should physically be in the drawer.
|
||||
result.put("todayVaultIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||
"SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd"
|
||||
+ " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
|
||||
+ " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ct.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todaySettledOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||
"SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd"
|
||||
+ " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
|
||||
+ " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(cs.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierIn", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
scopedCashierId, txnDate));
|
||||
result.put("todayInterCashierOut", dayLegTotal(
|
||||
"SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
"SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd"
|
||||
+ " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
|
||||
+ " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)"
|
||||
+ " AND COALESCE(ic.is_reversed, false) = false",
|
||||
@@ -721,7 +721,7 @@ public class CashReportApiResource {
|
||||
final Long scopedCashierId) {
|
||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : "";
|
||||
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
|
||||
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
|
||||
|
||||
@@ -1171,7 +1171,7 @@ public class CashReportApiResource {
|
||||
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Settlements back to vault
|
||||
@@ -1197,7 +1197,7 @@ public class CashReportApiResource {
|
||||
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
||||
+ "AND std.officer_id IN ("
|
||||
+ " SELECT au.id FROM m_appuser au "
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED
|
||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||
|
||||
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
||||
@@ -1315,13 +1315,13 @@ public class CashReportApiResource {
|
||||
if (cashierId != null) {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
+ "SELECT au.id FROM m_appuser au "
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)";
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||
baseParams = new Object[] { txnDate, cashierId };
|
||||
} else if (tellerId != null) {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
+ "SELECT au.id FROM m_appuser au "
|
||||
+ "JOIN m_cashiers mc ON mc.staff_id = au.staff_id "
|
||||
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)";
|
||||
+ "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")";
|
||||
baseParams = new Object[] { txnDate, tellerId };
|
||||
} else {
|
||||
officerFilter = "AND std.officer_id IN ("
|
||||
|
||||
+11
-1
@@ -130,9 +130,19 @@ public final class CountedCashLedger {
|
||||
+ " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))"
|
||||
+ " AND" + REAL_TXN_ID + ") ";
|
||||
|
||||
/**
|
||||
* Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what
|
||||
* the login counted once this allocation existed. A breakdown is tied to a cashier through its
|
||||
* officer's login, so without this a person moved from one branch's teller to another's carried
|
||||
* every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation
|
||||
* with no recorded creation time keeps its whole history.
|
||||
*/
|
||||
public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL"
|
||||
+ " OR std.created_at >= mc.created_at)";
|
||||
|
||||
/** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */
|
||||
private static String breakdownDiffersFrom(final String amountColumn) {
|
||||
return "ABS(" + amountColumn + " - (SELECT SUM(sd.count * sd.denomination_value)"
|
||||
return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)"
|
||||
+ " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id"
|
||||
+ " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1";
|
||||
}
|
||||
|
||||
+4
-4
@@ -288,7 +288,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||
@@ -312,7 +312,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||
@@ -409,7 +409,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
|
||||
@@ -433,7 +433,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " AND " + CountedCashLedger.COUNTED_ROW
|
||||
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
|
||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"
|
||||
+ " GROUP BY std.denomination_id"
|
||||
+ " UNION ALL"
|
||||
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
|
||||
|
||||
+130
-31
@@ -222,9 +222,10 @@ public class SavingsInterestPostingReportService {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT r.id FROM m_savings_interest_posting_report r
|
||||
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
|
||||
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?)
|
||||
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id
|
||||
AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?)))
|
||||
ORDER BY r.posting_date, r.office_id, r.savings_product_id
|
||||
""", Long.class, businessDate, headOfficeId, businessDate);
|
||||
""", Long.class, businessDate, headOfficeId, businessDate, businessDate);
|
||||
}
|
||||
|
||||
/** Every approved report still waiting, for the nightly safety net. */
|
||||
@@ -245,15 +246,15 @@ public class SavingsInterestPostingReportService {
|
||||
// ── reading ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reports for the screen. A branch user sees only their branch; a head office user sees every
|
||||
* branch, so it can act for one that did not open. canAct says whether this user may regenerate
|
||||
* or approve the report.
|
||||
* Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a
|
||||
* head office user, is every other branch (or one, with officeId). canAct says whether this user
|
||||
* may regenerate or approve the report, and blockedReason why not.
|
||||
*/
|
||||
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
|
||||
final AppUser user) {
|
||||
final String scope, final AppUser user) {
|
||||
final Long userOffice = officeOf(user);
|
||||
final boolean headOffice = isHeadOffice(userOffice);
|
||||
final Long branch = headOffice ? officeId : userOffice;
|
||||
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||
final Long branch = others ? officeId : userOffice;
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||
@@ -287,6 +288,10 @@ public class SavingsInterestPostingReportService {
|
||||
sql.append(" AND r.office_id = ?");
|
||||
params.add(branch);
|
||||
}
|
||||
if (others) {
|
||||
sql.append(" AND r.office_id <> ?");
|
||||
params.add(userOffice);
|
||||
}
|
||||
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
|
||||
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
@@ -294,7 +299,9 @@ public class SavingsInterestPostingReportService {
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>(row);
|
||||
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
|
||||
out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate));
|
||||
final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate);
|
||||
out.put("canAct", blocked == null);
|
||||
out.put("blockedReason", blocked);
|
||||
rows.add(out);
|
||||
}
|
||||
return rows;
|
||||
@@ -339,6 +346,45 @@ public class SavingsInterestPostingReportService {
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* What each savings account has earned so far in its current cycle — what it would be paid if
|
||||
* interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first
|
||||
* brought up to the business date (which moves no money) so the figure includes today.
|
||||
*
|
||||
* <p>
|
||||
* A branch user sees their own branch; a head office user every branch, or one with officeId.
|
||||
*/
|
||||
public List<Map<String, Object>> preview(final Long officeId, final AppUser user) {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
|
||||
|
||||
final Long userOffice = officeOf(user);
|
||||
final Long branch = isHeadOffice(userOffice) ? officeId : userOffice;
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName",
|
||||
p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo",
|
||||
COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance",
|
||||
d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate",
|
||||
d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest"
|
||||
FROM m_savings_daily_interest d
|
||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||
JOIN m_savings_product p ON p.id = d.savings_product_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||
WHERE p.deposit_type_enum = 100
|
||||
AND sa.status_enum = ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(ACTIVE));
|
||||
if (branch != null) {
|
||||
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
|
||||
params.add(branch);
|
||||
}
|
||||
sql.append(" ORDER BY o.name, p.name, sa.account_no");
|
||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch and product interest for the head office: per report, how many customers and how much
|
||||
* interest, and where it stands. Superseded reports are left out — each line is the report that
|
||||
@@ -375,9 +421,13 @@ public class SavingsInterestPostingReportService {
|
||||
|
||||
/**
|
||||
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
|
||||
* before they had anything due, or failed. Limited to what the user may act on.
|
||||
* before they had anything due, or failed.
|
||||
*
|
||||
* <p>
|
||||
* {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user,
|
||||
* lists the other branches — each with canAct, and where the head office may not act for it, why.
|
||||
*/
|
||||
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user) {
|
||||
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
// A future date has nothing due yet, and offering to generate for it would label this cycle's
|
||||
// part-earned interest with a date it has not reached.
|
||||
@@ -386,15 +436,21 @@ public class SavingsInterestPostingReportService {
|
||||
return out;
|
||||
}
|
||||
final Long userOffice = officeOf(user);
|
||||
for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) {
|
||||
if (activeReportId(due.productId(), due.officeId(), postingDate) == null
|
||||
&& mayAct(userOffice, due.officeId(), businessDate)) {
|
||||
final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice);
|
||||
for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) {
|
||||
if (others == due.officeId().equals(userOffice)) {
|
||||
continue;
|
||||
}
|
||||
if (activeReportId(due.productId(), due.officeId(), postingDate) == null) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("productId", due.productId());
|
||||
row.put("productName", productName(due.productId()));
|
||||
row.put("officeId", due.officeId());
|
||||
row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId()));
|
||||
row.put("officeName", officeName(due.officeId()));
|
||||
row.put("postingDate", postingDate.toString());
|
||||
final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate);
|
||||
row.put("canAct", blocked == null);
|
||||
row.put("blockedReason", blocked);
|
||||
out.add(row);
|
||||
}
|
||||
}
|
||||
@@ -408,36 +464,79 @@ public class SavingsInterestPostingReportService {
|
||||
return !parent.isEmpty() && parent.get(0) == null;
|
||||
}
|
||||
|
||||
/** Whether the branch opened its day on the date — the head office may act only for one that did not. */
|
||||
/**
|
||||
* Whether the branch is working on the date: it opened that day, or a day it opened earlier is still
|
||||
* not closed. A branch with yesterday's day still open is open — it closes that day and goes on.
|
||||
*/
|
||||
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
|
||||
final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||
Long.class, officeId, businessDate);
|
||||
final Long rows = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_branch_day_register
|
||||
WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?))
|
||||
""", Long.class, officeId, businessDate, businessDate);
|
||||
return rows != null && rows > 0;
|
||||
}
|
||||
|
||||
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||
/** The branches, other than those given, that still have a day open on or before the date. */
|
||||
public List<String> branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
|
||||
WHERE r.closed_at IS NULL AND r.business_date <= ?
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>(List.of(businessDate));
|
||||
for (final Long exclude : excludeOfficeIds) {
|
||||
sql.append(" AND r.office_id <> ?");
|
||||
params.add(exclude);
|
||||
}
|
||||
sql.append(" ORDER BY o.name");
|
||||
return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Null where the user may generate or approve the branch's report, otherwise why not.
|
||||
*
|
||||
* <p>
|
||||
* A branch always acts for itself. The head office acts for another branch only when that branch
|
||||
* has not opened at all, and only once every branch that did open has closed — the head office's
|
||||
* own close being the last of the day, so nothing is taken out of a branch that may still open.
|
||||
*/
|
||||
private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||
if (userOffice == null) {
|
||||
return false;
|
||||
return "No branch is set for this user.";
|
||||
}
|
||||
if (userOffice.equals(reportOffice)) {
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate);
|
||||
final String branch = officeName(reportOffice);
|
||||
if (!isHeadOffice(userOffice)) {
|
||||
return "Only " + branch + " can generate or approve its own interest posting report.";
|
||||
}
|
||||
if (branchOpenedOn(reportOffice, businessDate)) {
|
||||
return branch + " is open — it generates and approves its own report.";
|
||||
}
|
||||
final List<String> stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice);
|
||||
if (!stillOpen.isEmpty()) {
|
||||
return "The head office can act for " + branch + " only after every open branch has closed. Still open: "
|
||||
+ String.join(", ", stillOpen) + ".";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||
return actBlockedReason(userOffice, reportOffice, businessDate) == null;
|
||||
}
|
||||
|
||||
/** @return true where the head office is acting on a branch's behalf */
|
||||
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
|
||||
final Long userOffice = officeOf(user);
|
||||
if (userOffice != null && userOffice.equals(reportOffice)) {
|
||||
return false;
|
||||
final String blocked = actBlockedReason(userOffice, reportOffice, businessDate);
|
||||
if (blocked != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked);
|
||||
}
|
||||
if (mayAct(userOffice, reportOffice, businessDate)) {
|
||||
return true;
|
||||
}
|
||||
final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice);
|
||||
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch",
|
||||
"Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day "
|
||||
+ branch + " has not opened.");
|
||||
return !reportOffice.equals(userOffice);
|
||||
}
|
||||
|
||||
private String officeName(final Long officeId) {
|
||||
return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
|
||||
}
|
||||
|
||||
private static Long officeOf(final AppUser user) {
|
||||
|
||||
+2
-2
@@ -424,8 +424,8 @@ public class SavingsPooledInterestPostingService {
|
||||
* 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);
|
||||
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
|
||||
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
|
||||
return ids.isEmpty() ? 1L : ids.get(0);
|
||||
}
|
||||
|
||||
|
||||
+27
-4
@@ -65,20 +65,34 @@ public class SavingsInterestPostingReportApiResource {
|
||||
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
|
||||
@GET
|
||||
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
|
||||
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) {
|
||||
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
|
||||
@QueryParam("scope") final String scope) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList());
|
||||
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */
|
||||
@GET
|
||||
@Path("/context")
|
||||
public String userContext() {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
final Long officeId = user.getOffice().getId();
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("officeId", officeId);
|
||||
result.put("officeName", user.getOffice().getName());
|
||||
result.put("headOffice", reportService.isHeadOffice(officeId));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
|
||||
@GET
|
||||
@Path("/due-without-report")
|
||||
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) {
|
||||
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
return gson.toJson(reportService.dueWithoutReport(date, user));
|
||||
return gson.toJson(reportService.dueWithoutReport(date, user, scope));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +113,15 @@ public class SavingsInterestPostingReportApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */
|
||||
@GET
|
||||
@Path("/preview")
|
||||
public String preview(@QueryParam("officeId") final Long officeId) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
|
||||
@GET
|
||||
@Path("/summary")
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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.fdinterest;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
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.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Posts approved fixed deposit interest reports, one transaction each.
|
||||
*
|
||||
* <p>
|
||||
* Called as a branch closes its day. A report that cannot be posted does not take the others down
|
||||
* with it: its failure is written to the report, which stays APPROVED, and reported back to the
|
||||
* caller so the close can be refused with a reason the branch can act on. Closing again once the
|
||||
* cause is fixed posts it.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositInterestPostingOrchestrator {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FixedDepositInterestPostingService postingService;
|
||||
private final FixedDepositInterestPostingReportService reportService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* Posts each report in a transaction of its own.
|
||||
*
|
||||
* @return one message per report that could not be posted, empty where all of them were
|
||||
*/
|
||||
public List<String> postReports(final List<Long> reportIds) {
|
||||
final TransactionTemplate newTransaction = new TransactionTemplate(this.transactionManager);
|
||||
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
final List<String> failures = new ArrayList<>();
|
||||
for (final Long reportId : reportIds) {
|
||||
final Map<String, Object> report = this.jdbcTemplate
|
||||
.queryForMap("SELECT fd_product_id, posting_date FROM m_fd_interest_posting_report WHERE id = ?", reportId);
|
||||
final long productId = ((Number) report.get("fd_product_id")).longValue();
|
||||
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
try {
|
||||
final BigDecimal paid = newTransaction.execute(status -> this.postingService.postReport(reportId));
|
||||
log.info("FD interest: report {} for product {} posted {} for {}", reportId, productId, paid, postingDate);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("FD interest: report {} for product {} failed to post for {}", reportId, productId, postingDate, e);
|
||||
newTransaction.executeWithoutResult(status -> {
|
||||
this.reportService.recordPostingFailure(reportId, e.getMessage());
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_fd_interest_posting_run
|
||||
SET status = 'FAILED', failure_reason = ?
|
||||
WHERE report_id = ?
|
||||
""", truncate(e.getMessage()), reportId);
|
||||
});
|
||||
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static String truncate(final String reason) {
|
||||
if (reason == null) {
|
||||
return "unknown failure";
|
||||
}
|
||||
return reason.length() > 1000 ? reason.substring(0, 1000) : reason;
|
||||
}
|
||||
}
|
||||
+1011
File diff suppressed because it is too large
Load Diff
+527
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* 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.fdinterest;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Pays an approved fixed deposit interest posting report.
|
||||
*
|
||||
* <p>
|
||||
* Nothing is recomputed here. The report's lines are the authority: each deposit is credited exactly
|
||||
* what was approved for it, and interest that accrued after the report was generated is not added,
|
||||
* because the approver signed off on these figures and not on others. The payable and paid accounts
|
||||
* are those recorded on the report when it was generated.
|
||||
*
|
||||
* <p>
|
||||
* The ledger entries are the three stages fixed deposit interest has always posted through, raised
|
||||
* once for the whole report rather than once per deposit — the same pooling the savings interest
|
||||
* posting report uses, for the same reason: a bank's interest cost is one figure in its books, not
|
||||
* one line per customer repeated three times over.
|
||||
*
|
||||
* <pre>
|
||||
* Dr Interest Expense (report total) → Cr Fixed Deposit Interest Payable
|
||||
* Dr Interest Payable (report total) → Cr Fixed Deposit Interest Paid
|
||||
* Dr Interest Paid (report total, once)
|
||||
* Cr Savings Control (per deposit, naming the account and its own amount)
|
||||
* </pre>
|
||||
*
|
||||
* A product that names no paid account posts the two-stage form, and one that names neither falls
|
||||
* back to the plain Dr Expense / Cr Control pair — pooled the same way, one expense debit against many
|
||||
* credits. Where one report holds deposits due for more than one period — a deposit owed two unpaid
|
||||
* cycles, or a branch posting a stale cycle alongside today's — each {@code interestPostingDate}
|
||||
* pools on its own: the chain above is raised once per date present on the report, not once overall,
|
||||
* because the narration and the ledger date belong to the cycle, not to the day it happened to post.
|
||||
*
|
||||
* <p>
|
||||
* Where the interest then goes is the product's scheme, and it is not decided here. A
|
||||
* {@code SPECIAL} deposit carries {@code transfer_interest_to_linked_account}, and the existing
|
||||
* transfer-interest-to-savings job sweeps the posted interest to the customer's linked savings
|
||||
* account as it always has; {@code SINGLE} and {@code DOUBLE} keep it in the deposit. Posting is the
|
||||
* same act for all three — the money reaches the customer's deposit — so this service does one thing
|
||||
* and the sweep is left where it already works.
|
||||
*
|
||||
* <p>
|
||||
* A deposit can mature between its report being generated and being posted, and a matured deposit's
|
||||
* last period is settled directly by the maturity job the moment it matures — see
|
||||
* {@code FixedDepositAccount#postMaturityInterest}, which this report's generation step cannot see
|
||||
* coming and does not block on. Posting checks for that first: a line whose deposit already carries an
|
||||
* interest posting for its exact date is linked to what actually paid it rather than paid again, and
|
||||
* its amount is reconciled off the report's approved total rather than failing the whole posting over
|
||||
* money that was never going to move twice.
|
||||
*
|
||||
* <p>
|
||||
* The whole report settles in one transaction: a failure leaves nobody half paid and the report still
|
||||
* APPROVED for the next attempt.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositInterestPostingService {
|
||||
|
||||
private static final int MONEY_SCALE = 2;
|
||||
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||
|
||||
/** 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;
|
||||
/** {@code CashAccountsForSavings.SAVINGS_CONTROL} */
|
||||
private static final int GL_SAVINGS_CONTROL = 2;
|
||||
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense interest is charged to. */
|
||||
private static final int GL_INTEREST_ON_SAVINGS = 3;
|
||||
/** {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product. */
|
||||
private static final int GL_INTEREST_PAYABLE = 17;
|
||||
|
||||
/**
|
||||
* The reserved names a deposit product may map its payable and paid accounts under, most specific
|
||||
* first. The same lists the scheduler reads, so a product configured for the old path needs no
|
||||
* change for the new one.
|
||||
*/
|
||||
private static final String[] PAYABLE_NAMES = { "Fixed Deposit Interest Payable", "Savings Interest Payable",
|
||||
"Recurring Deposit Interest Payable", "Interest Payable", "Interest Payable Account" };
|
||||
private static final String[] PAID_NAMES = { "Fixed Deposit Interest Paid", "Savings Interest Paid",
|
||||
"Recurring Deposit Interest Paid", "Interest Paid", "Interest Paid Account" };
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The payable and paid accounts a product's interest passes through. */
|
||||
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
|
||||
|
||||
/**
|
||||
* One deposit's credit, after its transaction and balances are written but before the pooled ledger
|
||||
* legs are raised — grouped with the others of its {@code interestDate} so the whole group is posted
|
||||
* as one chain.
|
||||
*/
|
||||
private record PaidLine(Long accountId, BigDecimal amount, LocalDate interestDate, Long transactionId) {}
|
||||
|
||||
/**
|
||||
* What one line's posting came to: either a new credit to pool with the rest of its date-group, or
|
||||
* an amount that turned out to be settled already — a deposit that matured, and had this exact
|
||||
* period's interest posted directly by the maturity job, between this report being generated and
|
||||
* being posted. {@code paid} is null in that case, and {@code reconciledAway} carries the amount so
|
||||
* the report's approved total can be reconciled down by it rather than the whole posting failing a
|
||||
* balance check over money that was never going to move twice.
|
||||
*/
|
||||
private record DepositOutcome(PaidLine paid, BigDecimal reconciledAway) {}
|
||||
|
||||
/**
|
||||
* The payable and paid accounts as the product maps them now, read when a report is generated so a
|
||||
* product missing one is refused before anybody approves figures that could never be posted.
|
||||
*
|
||||
* <p>
|
||||
* Unlike savings, neither is required: a deposit product that names no payable posts the plain two
|
||||
* legs it always did, and nulls here say so rather than stopping the report.
|
||||
*/
|
||||
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
|
||||
if (optionalMappedGlAccount(productId, GL_INTEREST_ON_SAVINGS) == null) {
|
||||
final String productName = this.jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
|
||||
productId);
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.gl.not.mapped",
|
||||
"Fixed deposit product '" + productName + "' has no Interest On Savings GL mapped. Set it under the product's"
|
||||
+ " Accounting and generate the report again.");
|
||||
}
|
||||
Long payableGl = optionalMappedGlAccount(productId, GL_INTEREST_PAYABLE);
|
||||
if (payableGl == null) {
|
||||
payableGl = namedGlAccount(productId, PAYABLE_NAMES);
|
||||
}
|
||||
return new PostingGlAccounts(payableGl, namedGlAccount(productId, PAID_NAMES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts one approved report, crediting each deposit exactly what was approved for it.
|
||||
*
|
||||
* <p>
|
||||
* Everything is dated the day it is actually posted — the business date — not the cycle's end: a
|
||||
* branch that was shut on its posting date posts when it next closes, into a day that is still
|
||||
* open, and the narration names the date the interest is for.
|
||||
*
|
||||
* @return the amount distributed
|
||||
*/
|
||||
@Transactional
|
||||
public BigDecimal postReport(final long reportId) {
|
||||
final List<Map<String, Object>> reports = this.jdbcTemplate.queryForList("""
|
||||
SELECT id, fd_product_id, fd_type, office_id, posting_date, status, total_interest, payable_gl_account_id,
|
||||
paid_gl_account_id
|
||||
FROM m_fd_interest_posting_report
|
||||
WHERE id = ?
|
||||
FOR UPDATE
|
||||
""", reportId);
|
||||
if (reports.isEmpty()) {
|
||||
throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " does not exist");
|
||||
}
|
||||
final Map<String, Object> report = reports.get(0);
|
||||
if (!FixedDepositInterestPostingReportService.APPROVED.equals(report.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Fixed deposit interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
|
||||
}
|
||||
final long productId = ((Number) report.get("fd_product_id")).longValue();
|
||||
final long officeId = ((Number) report.get("office_id")).longValue();
|
||||
final LocalDate interestFor = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
|
||||
final BigDecimal approved = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
|
||||
final List<Map<String, Object>> lines = this.jdbcTemplate.queryForList("""
|
||||
SELECT l.id AS line_id, l.fd_account_id, l.interest_posting_date, l.interest_amount, l.fd_transaction_id,
|
||||
sa.currency_code
|
||||
FROM m_fd_interest_posting_report_line l
|
||||
JOIN m_savings_account sa ON sa.id = l.fd_account_id
|
||||
WHERE l.report_id = ?
|
||||
ORDER BY l.interest_posting_date, l.fd_account_id
|
||||
""", reportId);
|
||||
if (lines.isEmpty()) {
|
||||
throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " has no deposits to pay");
|
||||
}
|
||||
|
||||
final long runId = openRun(reportId, productId, officeId, interestFor, lines.size());
|
||||
|
||||
final Long expenseGl = mappedGlAccount(productId, GL_INTEREST_ON_SAVINGS);
|
||||
final Long controlGl = mappedGlAccount(productId, GL_SAVINGS_CONTROL);
|
||||
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
|
||||
final Long payableGl = report.get("payable_gl_account_id") != null ? ((Number) report.get("payable_gl_account_id")).longValue()
|
||||
: current.payableGl();
|
||||
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
|
||||
: current.paidGl();
|
||||
|
||||
final String currency = (String) lines.get(0).get("currency_code");
|
||||
// One per report, and deterministic: a retried posting carries the same reference as the legs a
|
||||
// failed attempt rolled back, and two branches posting the same product never share one.
|
||||
final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.FD_INTEREST_POSTED, postedOn, reportId);
|
||||
|
||||
// Each deposit's transaction and balances are settled first; the ledger is raised after, pooled
|
||||
// by the date its interest belongs to, so a report spanning two cycles raises two chains rather
|
||||
// than crediting one period's money under the other's date.
|
||||
final Map<LocalDate, List<PaidLine>> byDate = new LinkedHashMap<>();
|
||||
BigDecimal distributed = BigDecimal.ZERO;
|
||||
BigDecimal reconciledAway = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> line : lines) {
|
||||
final DepositOutcome outcome = payDeposit(line, officeId, postedOn);
|
||||
if (outcome.paid() != null) {
|
||||
byDate.computeIfAbsent(outcome.paid().interestDate(), key -> new ArrayList<>()).add(outcome.paid());
|
||||
distributed = distributed.add(outcome.paid().amount());
|
||||
}
|
||||
reconciledAway = reconciledAway.add(outcome.reconciledAway());
|
||||
}
|
||||
|
||||
// A line reconciled away was never going to move twice: its amount is taken off what this
|
||||
// posting is expected to distribute rather than failing the whole report over money that is
|
||||
// already in the customer's deposit by another, older path. What was actually paid here still
|
||||
// has to add up exactly to what is left.
|
||||
final BigDecimal expected = approved.subtract(reconciledAway);
|
||||
if (expected.compareTo(distributed) != 0) {
|
||||
throw new IllegalStateException(
|
||||
String.format("Fixed deposit interest posting report %d did not balance: approved %s, reconciled away %s,"
|
||||
+ " distributed %s", reportId, approved, reconciledAway, distributed));
|
||||
}
|
||||
|
||||
for (final Map.Entry<LocalDate, List<PaidLine>> group : byDate.entrySet()) {
|
||||
postPooledLedgerLegs(officeId, currency, group.getKey(), postedOn, group.getValue(), reference, expenseGl, payableGl, paidGl,
|
||||
controlGl);
|
||||
}
|
||||
|
||||
completeReport(reportId, productId, interestFor, approved, distributed, runId, lines.size());
|
||||
return distributed;
|
||||
}
|
||||
|
||||
/**
|
||||
* One deposit's interest transaction and balances, settled without raising any ledger entry — the
|
||||
* caller pools this with the rest of its date-group before the GL is touched.
|
||||
*
|
||||
* <p>
|
||||
* The transaction carries the date the interest engine gave the period, not the day the branch is
|
||||
* closing. That is not presentation: the engine decides a period has already been paid by looking
|
||||
* for an interest transaction on exactly that date, so a posting written under the close date would
|
||||
* be invisible to it and the same period would be computed and paid again at the next run. The
|
||||
* close date is kept as the submitted-on stamp, which is what the scheduler has always done.
|
||||
*
|
||||
* <p>
|
||||
* Checked first against the deposit's own transactions for one this report did not put there: a
|
||||
* deposit can mature between its report being generated and being posted, and the maturity job
|
||||
* settles a matured deposit's last period itself, directly, the moment it matures — see
|
||||
* {@code FixedDepositAccount#postMaturityInterest}. Where that has already happened for this exact
|
||||
* date, the line is linked to what actually paid it rather than paying it again.
|
||||
*/
|
||||
private DepositOutcome payDeposit(final Map<String, Object> line, final long officeId, final LocalDate postedOn) {
|
||||
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
final long accountId = ((Number) line.get("fd_account_id")).longValue();
|
||||
final long lineId = ((Number) line.get("line_id")).longValue();
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return new DepositOutcome(null, BigDecimal.ZERO);
|
||||
}
|
||||
final LocalDate interestDate = ((java.sql.Date) line.get("interest_posting_date")).toLocalDate();
|
||||
|
||||
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
|
||||
// payment, so this holds even where one deposit has two periods on one report.
|
||||
if (line.get("fd_transaction_id") != null) {
|
||||
throw new IllegalStateException("Fixed deposit " + accountId + " was already paid by this report");
|
||||
}
|
||||
|
||||
final Long existing = findExistingInterestPosting(accountId, interestDate);
|
||||
if (existing != null) {
|
||||
log.warn(
|
||||
"FD interest report line {}: deposit {} already has an interest posting for {} (transaction {}) not raised by this"
|
||||
+ " report — most likely matured in the meantime. Linking to it instead of posting again.",
|
||||
lineId, accountId, interestDate, existing);
|
||||
this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", existing, lineId);
|
||||
return new DepositOutcome(null, amount);
|
||||
}
|
||||
|
||||
// The balance as it stands now, not as the report showed it: money may have moved since the
|
||||
// report was generated, and the transaction's running balance has to follow the deposit.
|
||||
final BigDecimal currentBalance = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
|
||||
final BigDecimal newBalance = currentBalance.add(amount);
|
||||
|
||||
final long transactionId = createInterestPostingTransaction(accountId, officeId, interestDate, postedOn, amount, newBalance);
|
||||
|
||||
// Earned is only ever raised to what has now been posted, never added to: the engine may
|
||||
// already have counted this interest as earned, and adding on top of that doubles it. Screens
|
||||
// read unposted interest as earned - posted, so earned must not fall below posted either.
|
||||
//
|
||||
// interest_posted_till_date is what takes the deposit out of the next report's candidates; the
|
||||
// interest transaction itself is what stops the engine recomputing the same period.
|
||||
this.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) + ?,
|
||||
total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
|
||||
COALESCE(total_interest_posted_derived, 0) + ?),
|
||||
interest_posted_till_date = GREATEST(COALESCE(interest_posted_till_date, ?), ?)
|
||||
WHERE id = ?
|
||||
""", amount, amount, amount, interestDate, interestDate, accountId);
|
||||
|
||||
this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", transactionId,
|
||||
lineId);
|
||||
|
||||
return new DepositOutcome(new PaidLine(accountId, amount, interestDate, transactionId), BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
/** A non-reversed interest posting this deposit already carries for this exact date, if any. */
|
||||
private Long findExistingInterestPosting(final long accountId, final LocalDate interestDate) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND transaction_date = ? AND is_reversed = false
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", Long.class, accountId, INTEREST_POSTING, interestDate);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* One date-group's chain, pooled: the report's cost moves through the fewer-or-more intermediate
|
||||
* accounts the product's mappings support as one figure, and only the last leg — crediting each
|
||||
* deposit — is raised per account. The group's own total is what moves, not the report's, so two
|
||||
* periods on one report each carry their own entries under their own date.
|
||||
*/
|
||||
private void postPooledLedgerLegs(final long officeId, final String currency, final LocalDate interestDate, final LocalDate postedOn,
|
||||
final List<PaidLine> group, final String reference, final Long expenseGl, final Long payableGl, final Long paidGl,
|
||||
final Long controlGl) {
|
||||
final String narration = "Fixed deposit interest for " + interestDate;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final PaidLine paid : group) {
|
||||
total = total.add(paid.amount());
|
||||
}
|
||||
|
||||
final boolean hasPayable = payableGl != null && payableGl != 0 && !payableGl.equals(controlGl);
|
||||
final boolean hasPaid = paidGl != null && paidGl != 0 && !paidGl.equals(payableGl);
|
||||
|
||||
// The chain of pooled accounts the group's cost passes through before it is split: Expense, then
|
||||
// Payable and Paid where the product maps them — each consecutive pair raised once for the whole
|
||||
// total. The last account in the chain is what Stage 2 debits before crediting each deposit.
|
||||
final List<Long> chain = new ArrayList<>();
|
||||
chain.add(expenseGl);
|
||||
if (hasPayable) {
|
||||
chain.add(payableGl);
|
||||
if (hasPaid) {
|
||||
chain.add(paidGl);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < chain.size() - 1; i++) {
|
||||
journalEntry(officeId, currency, chain.get(i), interestDate, postedOn, total, true, reference, null, null, narration);
|
||||
journalEntry(officeId, currency, chain.get(i + 1), interestDate, postedOn, total, false, reference, null, null, narration);
|
||||
}
|
||||
|
||||
// Stage 2: one debit for the group's total, and under it a credit to each deposit. Every leg
|
||||
// carries the report's one transaction id, and every credit names its account and interest
|
||||
// transaction, so who was paid what reads back from the journal alone.
|
||||
journalEntry(officeId, currency, chain.get(chain.size() - 1), interestDate, postedOn, total, true, reference, null, null,
|
||||
narration);
|
||||
for (final PaidLine paid : group) {
|
||||
journalEntry(officeId, currency, controlGl, interestDate, postedOn, paid.amount(), false, reference, paid.accountId(),
|
||||
paid.transactionId(), narration);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeReport(final long reportId, final long productId, final LocalDate interestFor, final BigDecimal approved,
|
||||
final BigDecimal distributed, final long runId, final int accountCount) {
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_fd_interest_posting_run
|
||||
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
|
||||
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", approved, distributed, runId);
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_fd_interest_posting_report
|
||||
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
|
||||
WHERE id = ?
|
||||
""", runId, reportId);
|
||||
log.info("FD interest posting: report {} (product {}, for {}) paid {} across {} deposits", reportId, productId, interestFor,
|
||||
distributed, accountCount);
|
||||
}
|
||||
|
||||
/** One run per report. A retry of the same report reuses its run. */
|
||||
private long openRun(final long reportId, final long productId, final long officeId, final LocalDate postingDate,
|
||||
final int accountCount) {
|
||||
this.jdbcTemplate.update("""
|
||||
INSERT INTO m_fd_interest_posting_run
|
||||
(report_id, fd_product_id, office_id, posting_date, account_count, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'PENDING')
|
||||
ON CONFLICT (report_id) DO UPDATE SET
|
||||
account_count = EXCLUDED.account_count, status = 'PENDING',
|
||||
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
|
||||
""", reportId, productId, officeId, postingDate, accountCount);
|
||||
return this.jdbcTemplate.queryForObject("SELECT id FROM m_fd_interest_posting_run WHERE report_id = ?", Long.class, reportId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param interestDate
|
||||
* the period the interest is for, which is the transaction's own date
|
||||
* @param postedOn
|
||||
* the business date it is being written on, kept as the submitted-on stamp
|
||||
*/
|
||||
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate interestDate,
|
||||
final LocalDate postedOn, final BigDecimal amount, final BigDecimal runningBalance) {
|
||||
this.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, interestDate, amount, runningBalance, postedOn, systemUserId(),
|
||||
systemUserId());
|
||||
return this.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, interestDate, INTEREST_POSTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* One journal entry leg. Fineract's {@code type_enum} is 2 = DEBIT, 1 = CREDIT — {@code
|
||||
* JournalEntryType} declares {@code CREDIT(1)} first.
|
||||
*
|
||||
* <p>
|
||||
* Dated the period the interest is for, as the scheduler dates its own legs, with the business date
|
||||
* kept as the submitted-on stamp.
|
||||
*/
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate interestDate,
|
||||
final LocalDate postedOn, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||
final Long transactionId, final String narration) {
|
||||
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
|
||||
this.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, interestDate, interestDate, debit ? 2 : 1, amount, narration,
|
||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(),
|
||||
postedOn);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user these rows are attributed to. A day close has a user, but the nightly retry does not and
|
||||
* the audit columns are NOT NULL, so entries are stamped to the system account either way — as the
|
||||
* scheduler's own postings always were.
|
||||
*/
|
||||
private Long systemUserId() {
|
||||
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
|
||||
return ids.isEmpty() ? 1L : ids.get(0);
|
||||
}
|
||||
|
||||
private Long mappedGlAccount(final long productId, final int financialAccountType) {
|
||||
final Long found = optionalMappedGlAccount(productId, financialAccountType);
|
||||
if (found == null) {
|
||||
throw new IllegalStateException("Fixed deposit product " + productId + " has no GL account mapped for financial account type "
|
||||
+ financialAccountType + ". Set the product's Accounting tab before its interest can be posted.");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private Long optionalMappedGlAccount(final long productId, final int financialAccountType) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("""
|
||||
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);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account a product maps under one of the reserved names in {@code other_gl_mappings}, tried
|
||||
* in order of preference. The column holds an empty string on a product saved with no named
|
||||
* mappings and casting that to jsonb is an error, so the cast sits inside a subquery that has
|
||||
* already filtered those rows out.
|
||||
*/
|
||||
private Long namedGlAccount(final long productId, final String... accountNames) {
|
||||
for (final String accountName : accountNames) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("""
|
||||
SELECT gl.id
|
||||
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||
FROM m_savings_product
|
||||
WHERE id = ?
|
||||
AND other_gl_mappings IS NOT NULL
|
||||
AND other_gl_mappings <> ''
|
||||
AND other_gl_mappings LIKE '{%') sp
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||
) AS m(entry)
|
||||
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
|
||||
LIMIT 1
|
||||
""", Long.class, productId, accountName);
|
||||
if (!found.isEmpty()) {
|
||||
return found.get(0);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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.fdinterest.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
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.savings.fdinterest.FixedDepositInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The fixed deposit interest posting reports, per branch and product: list, detail, generate, approve,
|
||||
* the day-close check, and the head office's branch-wise summary.
|
||||
*
|
||||
* <p>
|
||||
* Posting is not offered here. A branch's approved reports are posted when that branch closes the
|
||||
* day, so there is exactly one way interest reaches a customer's deposit.
|
||||
*/
|
||||
@Path("/v1/fixeddepositinterestpostingreports")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class FixedDepositInterestPostingReportApiResource {
|
||||
|
||||
private static final String RESOURCE = "FDINTERESTREPORT";
|
||||
|
||||
private final FixedDepositInterestPostingReportService reportService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
|
||||
@GET
|
||||
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
|
||||
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId,
|
||||
@QueryParam("scope") final String scope) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return this.gson
|
||||
.toJson(this.reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream()
|
||||
.map(this::plain).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is looking: their branch, whether it is the head office (which gets the Other branches tab),
|
||||
* and whether deposits are on the approval path at all — with the switch off every screen here is
|
||||
* empty by design, and the UI should say so rather than look broken.
|
||||
*/
|
||||
@GET
|
||||
@Path("/context")
|
||||
public String userContext() {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final Long officeId = user.getOffice().getId();
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("officeId", officeId);
|
||||
result.put("officeName", user.getOffice().getName());
|
||||
result.put("headOffice", this.reportService.isHeadOffice(officeId));
|
||||
result.put("approvalEnabled", this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled());
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Branch and product pairs with deposit interest due by the date and no report yet. */
|
||||
@GET
|
||||
@Path("/due-without-report")
|
||||
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
return this.gson.toJson(this.reportService.dueWithoutReport(date, user, scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* What stops a branch closing the business day: its own deposit reports due and not yet approved,
|
||||
* and the products and amounts behind them. The day-close screen reads it to warn before the close
|
||||
* is attempted.
|
||||
*/
|
||||
@GET
|
||||
@Path("/day-close-check")
|
||||
public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||
final Map<String, Object> view = this.reportService.dayCloseInterestView(officeId, businessDate);
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<String> blocking = (List<String>) view.get("pending");
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("businessDate", businessDate.toString());
|
||||
result.put("canClose", blocking.isEmpty());
|
||||
result.put("pendingProducts", blocking);
|
||||
result.put("due", view.get("due"));
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
|
||||
/** What each deposit has earned and not been paid, as if posted today. View only — nothing is saved. */
|
||||
@GET
|
||||
@Path("/preview")
|
||||
public String preview(@QueryParam("officeId") final Long officeId) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return this.gson.toJson(this.reportService.preview(officeId, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** The head office's view: per branch and product, deposits paid and interest, over a date range. */
|
||||
@GET
|
||||
@Path("/summary")
|
||||
public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("productId") final Long productId) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
if (!this.reportService.isHeadOffice(user.getOffice().getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.summary.head.office.only",
|
||||
"The branch-wise fixed deposit interest summary is for the head office.");
|
||||
}
|
||||
return this.gson
|
||||
.toJson(this.reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit interest posted without this report ever seeing it — chiefly a deposit maturing before
|
||||
* its posting cycle completed, whose last period a different, older path settles directly. Here so
|
||||
* a branch can see every rupee of deposit interest it has paid, approved or not.
|
||||
*/
|
||||
@GET
|
||||
@Path("/outside-approval")
|
||||
public String outsideApproval(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("officeId") final Long officeId, @QueryParam("scope") final String scope) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return this.gson.toJson(
|
||||
this.reportService.outsideApproval(parseDate(fromDate), parseDate(toDate), officeId, user, scope).stream()
|
||||
.map(this::plain).toList());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{reportId}")
|
||||
public String detail(@PathParam("reportId") final Long reportId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
final Map<String, Object> report = plain(this.reportService.detail(reportId));
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> lines = (List<Map<String, Object>>) report.get("lines");
|
||||
report.put("lines", lines.stream().map(this::plain).toList());
|
||||
return this.gson.toJson(report);
|
||||
}
|
||||
|
||||
/** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String command(@QueryParam("command") final String command, final String body) {
|
||||
if (!"regenerate".equalsIgnoreCase(command)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.unknown.command", "Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long productId = this.fromJsonHelper.extractLongNamed("productId", root);
|
||||
final Long officeIdParam = this.fromJsonHelper.extractLongNamed("officeId", root);
|
||||
final String postingDate = this.fromJsonHelper.extractStringNamed("postingDate", root);
|
||||
if (productId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.product.required", "Which product?");
|
||||
}
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
final Long reportId = this.reportService.regenerate(productId, officeId, date, user);
|
||||
return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.GENERATED));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code ?command=approve}. A user of the report's branch who did not generate it approves it; see
|
||||
* the service for the head office's one exception.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{reportId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
|
||||
if (!"approve".equalsIgnoreCase(command)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.unknown.command", "Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
|
||||
this.reportService.approve(reportId, user);
|
||||
return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.APPROVED));
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(raw.trim());
|
||||
} catch (final RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.invalid.date",
|
||||
"Dates are expected as yyyy-MM-dd, not " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
|
||||
private Map<String, Object> plain(final Map<String, Object> row) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
row.forEach((key, value) -> {
|
||||
if (value instanceof java.sql.Date d) {
|
||||
out.put(key, d.toLocalDate().toString());
|
||||
} else if (value instanceof Timestamp t) {
|
||||
out.put(key, t.toLocalDateTime().toString());
|
||||
} else {
|
||||
out.put(key, value);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.fdinterest.jobs;
|
||||
|
||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public class FixedDepositInterestPostingReportConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Bean
|
||||
protected Step fdInterestPostingReportStep(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) {
|
||||
return new StepBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.tasklet(fdInterestPostingReportTasklet, transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job fdInterestPostingReportJob(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) {
|
||||
return new JobBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.start(fdInterestPostingReportStep(fdInterestPostingReportTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.fdinterest.jobs;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Noon on the business date: generates the interest posting report for every fixed deposit product
|
||||
* whose interest falls due, so a branch can review and approve it before it closes the day.
|
||||
*
|
||||
* <p>
|
||||
* Does nothing while deposits are off the approval path — the posting job is paying them directly,
|
||||
* and a report generated alongside it would be approved against money that has already moved.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FixedDepositInterestPostingReportTasklet implements Tasklet {
|
||||
|
||||
private final FixedDepositInterestPostingReportService reportService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) throws Exception {
|
||||
if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) {
|
||||
log.info("FD interest posting report: skipped — fixed deposit interest is posted directly by the posting job");
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
this.reportService.generateDue(DateUtils.getBusinessLocalDate());
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -250,11 +250,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement elsewhere.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||
}
|
||||
|
||||
@@ -310,11 +306,7 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement elsewhere.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -260,11 +260,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -354,11 +350,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -264,11 +264,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -361,11 +357,7 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -203,11 +203,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||
}
|
||||
|
||||
@@ -269,11 +265,7 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
|
||||
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
|
||||
}
|
||||
|
||||
|
||||
+69
-2
@@ -36,6 +36,7 @@ import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.common.AccountingRuleType;
|
||||
import org.apache.fineract.accounting.glaccount.data.GLAccountData;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
@@ -109,16 +110,24 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
|
||||
private final SavingsDailyInterestService savingsDailyInterestService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
|
||||
/** m_savings_account.status_enum for an active account. */
|
||||
private static final Integer ACTIVE_STATUS = 300;
|
||||
/** m_savings_product.deposit_type_enum for a fixed deposit. */
|
||||
private static final Integer DEPOSIT_TYPE_FIXED = 200;
|
||||
|
||||
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
|
||||
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
|
||||
final SavingsDailyInterestService savingsDailyInterestService) {
|
||||
final SavingsDailyInterestService savingsDailyInterestService,
|
||||
final ConfigurationDomainService configurationDomainService) {
|
||||
this.context = context;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.sqlGenerator = sqlGenerator;
|
||||
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
|
||||
this.savingsDailyInterestService = savingsDailyInterestService;
|
||||
this.configurationDomainService = configurationDomainService;
|
||||
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
|
||||
this.transactionsMapper = new SavingsAccountTransactionsMapper();
|
||||
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
|
||||
@@ -440,10 +449,19 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
|
||||
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
|
||||
// subquery so a page is never cut short by accounts that are then dropped.
|
||||
//
|
||||
// Fixed deposits come out on the same grounds once their interest goes through an approved
|
||||
// report (FixedDepositInterestPostingReportService): the report's figures are posted when the
|
||||
// branch closes its day, and this job running over the same deposits would pay them twice.
|
||||
// Recurring deposits are not part of that gate and still post here either way.
|
||||
final String fixedDepositFilter = this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()
|
||||
? "and a.deposit_type_enum <> " + DEPOSIT_TYPE_FIXED + " "
|
||||
: "";
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
|
||||
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
|
||||
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
|
||||
+ "where s.savings_product_id = a.product_id and s.is_active = true) " + fixedDepositFilter
|
||||
+ "limit ?) b on b.id = sa.id ";
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
sql = sql
|
||||
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
||||
@@ -461,6 +479,55 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
return savingsAccountDataList;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixed deposits of one branch, and optionally one product, whose interest may be due on the
|
||||
* posting date — the candidates an interest posting report is drawn from.
|
||||
*
|
||||
* <p>
|
||||
* "May be" is deliberate. A deposit is selected here on the same cheap test the posting job has
|
||||
* always used, that its interest has not been posted past the day before, and whether a posting
|
||||
* period has actually ended is then settled by the interest engine, which is the only thing that
|
||||
* knows a product's compounding and posting periods. A candidate the engine finds nothing for
|
||||
* simply contributes no line to the report.
|
||||
*
|
||||
* <p>
|
||||
* Unpaged, unlike the posting job: a report covers one branch and one product and is read in full
|
||||
* before anybody approves it, so there is no page for a deposit to fall off the end of.
|
||||
*/
|
||||
@Override
|
||||
public List<SavingsAccountData> retrieveFixedDepositsDueForInterestPosting(final boolean backdatedTxnsAllowedTill,
|
||||
final LocalDate postingDate, final Long officeId, final Long productId) {
|
||||
final StringBuilder candidates = new StringBuilder("select a.id from m_savings_account a ")
|
||||
.append("left join m_client ic on ic.id = a.client_id ")
|
||||
.append("left join m_group ig on ig.id = a.group_id ")
|
||||
.append("where a.status_enum = ? and a.deposit_type_enum = ? ")
|
||||
.append("and (a.interest_posted_till_date is null or a.interest_posted_till_date <= ?) ");
|
||||
final List<Object> params = new ArrayList<>(List.of(ACTIVE_STATUS, DEPOSIT_TYPE_FIXED, postingDate.minusDays(1)));
|
||||
if (officeId != null) {
|
||||
candidates.append("and coalesce(ic.office_id, ig.office_id) = ? ");
|
||||
params.add(officeId);
|
||||
}
|
||||
if (productId != null) {
|
||||
candidates.append("and a.product_id = ? ");
|
||||
params.add(productId);
|
||||
}
|
||||
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + "join (" + candidates
|
||||
+ ") b on b.id = sa.id ";
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
sql = sql
|
||||
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
||||
}
|
||||
sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id";
|
||||
|
||||
final List<SavingsAccountData> deposits = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR
|
||||
params.toArray());
|
||||
for (final SavingsAccountData deposit : deposits) {
|
||||
this.savingAccountAssembler.assembleSavings(deposit);
|
||||
}
|
||||
return deposits;
|
||||
}
|
||||
|
||||
private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor<List<SavingsAccountData>> {
|
||||
|
||||
private final String schemaSql;
|
||||
|
||||
+2
-10
@@ -420,11 +420,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -528,11 +524,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new SavingsProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -430,9 +430,9 @@ public class SavingsConfiguration {
|
||||
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator,
|
||||
// @Lazy because the accrual service depends on beans from this same package: resolving
|
||||
// both eagerly closes a cycle and the context fails to start.
|
||||
@Lazy SavingsDailyInterestService savingsDailyInterestService) {
|
||||
@Lazy SavingsDailyInterestService savingsDailyInterestService, ConfigurationDomainService configurationDomainService) {
|
||||
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
|
||||
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService);
|
||||
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService, configurationDomainService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+2
-12
@@ -172,12 +172,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||
// it just no longer lets them bypass the hold on their own (matches
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||
// equivalent rule for the classic command pipeline).
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||
}
|
||||
|
||||
@@ -219,12 +214,7 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
|
||||
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
|
||||
// it just no longer lets them bypass the hold on their own (matches
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
|
||||
// equivalent rule for the classic command pipeline).
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -882,11 +882,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
@@ -972,11 +968,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
|
||||
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
|
||||
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
|
||||
|
||||
// Maker-identity rule: the same user can never approve their own submission, even when they
|
||||
// hold CHECKER_SUPER_USER - see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
|
||||
// equivalent enforcement in the classic command pipeline.
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
|
||||
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
|
||||
throw new ShareProductSelfApprovalNotAllowedException(productId);
|
||||
}
|
||||
|
||||
|
||||
+30
-1
@@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRu
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.PlatformEmailService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
@@ -82,6 +83,7 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final UserDataValidator fromApiJsonDeserializer;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final PlatformEmailService emailService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -293,13 +295,40 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
||||
appUser.updatePasswordResetRequired(true);
|
||||
}
|
||||
|
||||
// Set pre-hashed password
|
||||
// AppUser.fromJson() already generated a random plaintext password here when the maker ticked
|
||||
// "send password to email" (it ignores whatever password the maker typed in that case) -- the
|
||||
// field still holds that plaintext at this point, since nothing has encoded it yet. If the
|
||||
// maker instead supplied their own password, request.getPasswordHash() carries the bcrypt
|
||||
// hash submitCreateUser() produced from it; that plaintext was redacted to "[PROTECTED]"
|
||||
// immediately and is gone for good, so there is nothing to email in that case.
|
||||
final boolean sendPasswordToEmail = Boolean.TRUE.equals(replay.booleanObjectValueOfParameterNamed("sendPasswordToEmail"));
|
||||
final String unencodedPassword = appUser.getPassword();
|
||||
|
||||
if (request.getPasswordHash() != null) {
|
||||
// Maker-chosen password: already hashed, used as-is.
|
||||
appUser.updatePassword(request.getPasswordHash());
|
||||
} else {
|
||||
// Auto-generated password (the normal "send password to email" case): this service's own
|
||||
// generic PasswordEncoder, not the salted PlatformPasswordEncoder the live create-user flow
|
||||
// uses -- consistent with how the maker-chosen-password branch above and submitCreateUser()
|
||||
// already hash a password in this file. Encoding it here is not optional: AppUser's
|
||||
// constructor stores whatever string it is given verbatim (see AppUser(Office, User, ...)),
|
||||
// so skipping this would persist the plaintext password straight into m_appuser.password.
|
||||
appUser.updatePassword(this.passwordEncoder.encode(unencodedPassword));
|
||||
}
|
||||
|
||||
this.appUserRepository.saveAndFlush(appUser);
|
||||
|
||||
// Mirrors JpaUserDomainService.create(): the welcome email the live create-user flow sends is
|
||||
// the one thing this parallel, pending-request flow never did -- sendPasswordToEmail was
|
||||
// accepted and stored in the payload but nothing here ever acted on it, so a user created this
|
||||
// way never received their password. Only fires for the auto-generated password: there is no
|
||||
// plaintext to send for a maker-chosen one, per the comment above.
|
||||
if (sendPasswordToEmail && request.getPasswordHash() == null) {
|
||||
this.emailService.sendToUserAccount(userOffice.getName(), appUser.getFirstname(), appUser.getEmail(), appUser.getUsername(),
|
||||
unencodedPassword);
|
||||
}
|
||||
|
||||
// MFA Enrollment
|
||||
final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod");
|
||||
if (otpDeliveryMethod != null) {
|
||||
|
||||
@@ -163,5 +163,7 @@
|
||||
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
<?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">
|
||||
|
||||
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
|
||||
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
|
||||
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
|
||||
dev database - none of that was ever captured by a migration, so a fresh/other environment
|
||||
restored from this changelog history would come up with every one of them still at its
|
||||
originally-seeded false value. Every code here was independently confirmed, via read-only
|
||||
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
|
||||
= false and currently true in the running dev DB with no existing changeset already covering
|
||||
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
|
||||
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
|
||||
|
||||
This changeset only ever moves can_maker_checker from false to true for these exact codes -
|
||||
entity_name, action_name, grouping and every other column are left exactly as their origin
|
||||
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
|
||||
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
|
||||
touched: those already exist for every code below (added earlier by 0583/0586, or by base
|
||||
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
|
||||
|
||||
Grouped by the screen each permission belongs to:
|
||||
- Currency Configuration: UPDATE_CURRENCY
|
||||
- Manage Funds: CREATE_FUND
|
||||
- Working Days: UPDATE_WORKINGDAYS
|
||||
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
|
||||
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
|
||||
- Branch Profile: UPDATE_BRANCHPROFILE
|
||||
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
|
||||
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
|
||||
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
|
||||
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
|
||||
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
|
||||
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
|
||||
- Manage User Agreement: CREATE_USERAGREEMENT
|
||||
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
|
||||
|
||||
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
|
||||
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
|
||||
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
|
||||
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
|
||||
Survey permissions (confirmed still false - never actually toggled on)
|
||||
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
|
||||
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
|
||||
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
|
||||
Organization or Admin -> System - out of scope for this change and left as pre-existing
|
||||
drift for a separate decision. -->
|
||||
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
|
||||
<update tableName="m_permission">
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
<where>code IN (
|
||||
'UPDATE_CURRENCY',
|
||||
'CREATE_FUND',
|
||||
'UPDATE_WORKINGDAYS',
|
||||
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
|
||||
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
|
||||
'UPDATE_BRANCHPROFILE',
|
||||
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
|
||||
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
|
||||
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
|
||||
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
|
||||
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
|
||||
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
|
||||
'CREATE_USERAGREEMENT',
|
||||
'UPDATE_BATCH_BUSINESS_STEP'
|
||||
)</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Cash taken over a counter belongs to the drawer and the branch that took it, not to whoever the
|
||||
same login is allocated to today.
|
||||
|
||||
m_cashiers.created_at: a cashier position is built from the counter deposits and withdrawals of
|
||||
the cashier's login, so a person moved from one branch's teller to another's carried every
|
||||
rupee they had ever taken into the new drawer. The position now counts only what was taken
|
||||
once the allocation existed. Existing rows take the time their ALLOCATECASHIER command ran;
|
||||
a row with no such command stays NULL and keeps its whole history, as before.
|
||||
|
||||
m_cashier_deposit_request.counter_office_id: savings deposits are submitted with no office_id,
|
||||
and the day close counted every such deposit at every branch. The counter's office is now
|
||||
stored at submit. Existing rows take the teller office of the submitter's allocation if it
|
||||
already existed when the deposit was submitted, otherwise the submitter's own office.
|
||||
-->
|
||||
<changeSet id="0591_cashier_created_at" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_cashiers" columnName="created_at"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_cashiers">
|
||||
<column name="created_at" type="TIMESTAMP"/>
|
||||
</addColumn>
|
||||
<sql>
|
||||
UPDATE m_cashiers mc
|
||||
SET created_at = (SELECT CAST(MIN(cs.made_on_date_utc) AS TIMESTAMP)
|
||||
FROM m_portfolio_command_source cs
|
||||
WHERE cs.entity_name = 'TELLER' AND cs.action_name = 'ALLOCATECASHIER'
|
||||
AND cs.subresource_id = mc.id)
|
||||
</sql>
|
||||
<addDefaultValue tableName="m_cashiers" columnName="created_at" columnDataType="TIMESTAMP"
|
||||
defaultValueComputed="CURRENT_TIMESTAMP"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0591_deposit_request_counter_office" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_cashier_deposit_request" columnName="counter_office_id"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_cashier_deposit_request">
|
||||
<column name="counter_office_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
<sql>
|
||||
UPDATE m_cashier_deposit_request r
|
||||
SET counter_office_id = COALESCE(
|
||||
(SELECT t.office_id FROM m_appuser au
|
||||
JOIN m_cashiers c ON c.staff_id = au.staff_id
|
||||
JOIN m_tellers t ON t.id = c.teller_id
|
||||
WHERE au.id = r.cashier_id
|
||||
AND (c.created_at IS NULL OR r.submitted_at IS NULL OR r.submitted_at >= c.created_at)
|
||||
LIMIT 1),
|
||||
(SELECT au.office_id FROM m_appuser au WHERE au.id = r.cashier_id),
|
||||
r.office_id)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?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">
|
||||
|
||||
<!-- Same pattern as 0588_persist_manually_enabled_maker_checker_permissions: converts a
|
||||
manually-applied Configure Maker Checker Tasks change into a repeatable migration, this time
|
||||
for the Teller/Cashier module. Found by diffing can_maker_checker across every permission code
|
||||
common to fineract_lincpay and a reference environment (fineract_default) - these 4 were true
|
||||
in the reference but still seeded false here, confirming they were toggled on manually there
|
||||
and never captured for any other environment (including this one and UAT).
|
||||
|
||||
Grouped by the screen each permission belongs to (Organization -> Manage Tellers -> allocate/
|
||||
settle cash for a cashier):
|
||||
ALLOCATECASHIER_TELLER, ALLOCATECASHTOCASHIER_TELLER,
|
||||
SETTLECASHFROMCASHIER_TELLER, UPDATECASHIERALLOCATION_TELLER
|
||||
|
||||
No _CHECKER row is touched: those already exist for every code below (seeded by base
|
||||
Fineract), and can_maker_checker is not a column on a _CHECKER row. -->
|
||||
<changeSet id="0592_enable_teller_cashier_maker_checker" author="cbs-nitesh">
|
||||
<update tableName="m_permission">
|
||||
<column name="can_maker_checker" valueBoolean="true"/>
|
||||
<where>code IN (
|
||||
'ALLOCATECASHIER_TELLER',
|
||||
'ALLOCATECASHTOCASHIER_TELLER',
|
||||
'SETTLECASHFROMCASHIER_TELLER',
|
||||
'UPDATECASHIERALLOCATION_TELLER'
|
||||
)</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
|
||||
<!--
|
||||
|
||||
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.9.xsd">
|
||||
|
||||
<!--
|
||||
Fixed deposit interest stops posting itself.
|
||||
|
||||
Until now an FD was paid by the nightly posting job the moment its posting date arrived: the
|
||||
amount was worked out in memory and written straight to the customer's account, with nothing
|
||||
in between that anybody could look at, question or sign. The branch learnt what it had paid
|
||||
by reading the transactions afterwards.
|
||||
|
||||
This is the same gate savings interest was put behind in 0589, built for deposits. On a
|
||||
posting date a report is generated listing every deposit of one branch and one product, what
|
||||
it has earned, and where that interest is going; an authorised user who did not generate it
|
||||
approves it; and only the approved figures are posted, as the branch closes its day. A branch
|
||||
with an unapproved report cannot close.
|
||||
|
||||
Where the interest goes is the part that is particular to deposits, and it follows the
|
||||
product's scheme (m_savings_product.fd_type):
|
||||
|
||||
SINGLE interest stays in the deposit and is paid with the principal at maturity
|
||||
DOUBLE interest is compounded into the deposit, so maturity pays principal plus interest
|
||||
SPECIAL interest is posted monthly and swept to the customer's linked savings account
|
||||
|
||||
so a SPECIAL line also records the savings account the money leaves for, and the transfer it
|
||||
left on. The report keeps the figures rather than recomputing them at posting time: the
|
||||
approved list is the authority, and the posting pays exactly what was approved, deposit by
|
||||
deposit. A regeneration supersedes its predecessor instead of overwriting it, so what was
|
||||
shown and what was approved stay on the record.
|
||||
-->
|
||||
<changeSet author="fineract" id="0593-1-fd-interest-posting-report">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_fd_interest_posting_report"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_fd_interest_posting_report">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- Fixed deposit products are savings products with deposit_type_enum 200. -->
|
||||
<column name="fd_product_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_fdipr_product" referencedTableName="m_savings_product"
|
||||
referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- SINGLE, DOUBLE or SPECIAL as the product carried it when the report was generated:
|
||||
the scheme decides where the interest goes, and a product retyped afterwards must not
|
||||
change the meaning of figures somebody has already approved. -->
|
||||
<column name="fd_type" type="VARCHAR(20)" defaultValue="SINGLE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="office_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_fdipr_office" referencedTableName="m_office"
|
||||
referencedColumnNames="id"/>
|
||||
</column>
|
||||
<column name="posting_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- GENERATED, APPROVED, POSTED, or SUPERSEDED by a regeneration. -->
|
||||
<column name="status" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="total_interest" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="account_count" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- As the product mapped them when the report was generated, for display and checking. -->
|
||||
<column name="payable_gl_account_id" type="BIGINT"/>
|
||||
<column name="paid_gl_account_id" type="BIGINT"/>
|
||||
<!-- AUTO from the scheduled run, MANUAL from a regeneration. -->
|
||||
<column name="generation_mode" type="VARCHAR(10)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="generated_by" type="BIGINT"/>
|
||||
<column name="generated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="approved_by" type="BIGINT"/>
|
||||
<column name="approved_at" type="TIMESTAMP"/>
|
||||
<!-- True where the head office approved for a branch that did not open that day. -->
|
||||
<column name="approved_on_behalf" type="BOOLEAN" defaultValueBoolean="false"/>
|
||||
<column name="posted_at" type="TIMESTAMP"/>
|
||||
<column name="posting_run_id" type="BIGINT"/>
|
||||
<!-- Why an approved report could not be posted; the next close retries it. -->
|
||||
<column name="failure_reason" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_fd_interest_posting_report" indexName="idx_fdipr_product_date">
|
||||
<column name="fd_product_id"/>
|
||||
<column name="posting_date"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_fd_interest_posting_report" indexName="idx_fdipr_status_date">
|
||||
<column name="status"/>
|
||||
<column name="posting_date"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_fd_interest_posting_report" indexName="idx_fdipr_office_date">
|
||||
<column name="office_id"/>
|
||||
<column name="posting_date"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0593-2-fd-interest-posting-report-line">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_fd_interest_posting_report_line"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_fd_interest_posting_report_line">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="report_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_fdiprl_report"
|
||||
referencedTableName="m_fd_interest_posting_report" referencedColumnNames="id"/>
|
||||
</column>
|
||||
<!-- The fixed deposit itself, which is a row of m_savings_account. -->
|
||||
<column name="fd_account_id" type="BIGINT">
|
||||
<constraints nullable="false" foreignKeyName="fk_fdiprl_account" referencedTableName="m_savings_account"
|
||||
referencedColumnNames="id"/>
|
||||
</column>
|
||||
<column name="account_no" type="VARCHAR(50)"/>
|
||||
<column name="client_name" type="VARCHAR(300)"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<!--
|
||||
The date the interest engine itself assigned to this posting — the end of the period the
|
||||
interest was earned in, not the day the branch happens to close.
|
||||
|
||||
It is the line's identity, not decoration. The engine decides a period is already paid by
|
||||
looking for an interest transaction ON that date, so a posting written under any other
|
||||
date is invisible to it and the same period is computed, reported and paid again. It also
|
||||
means one deposit can have more than one line on a report: a branch that did not close for
|
||||
two quarters owes two postings, on two dates, and they stay two.
|
||||
-->
|
||||
<column name="interest_posting_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="interest_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="balance_before" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="balance_after" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- What the deposit is, shown beside what it earned: the checker is approving interest
|
||||
against a principal, a rate and a term, not against a number on its own. -->
|
||||
<column name="deposit_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="interest_rate" type="DECIMAL(19,6)"/>
|
||||
<column name="maturity_date" type="DATE"/>
|
||||
<!-- SPECIAL only: the linked savings account this interest is swept to, and the transfer
|
||||
it left on. Null for SINGLE and DOUBLE, where the interest stays in the deposit. -->
|
||||
<column name="payout_savings_account_id" type="BIGINT"/>
|
||||
<column name="payout_account_no" type="VARCHAR(50)"/>
|
||||
<column name="payout_transaction_id" type="BIGINT"/>
|
||||
<!-- The interest posting transaction, written when the report is posted. -->
|
||||
<column name="fd_transaction_id" type="BIGINT"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_fd_interest_posting_report_line" indexName="idx_fdiprl_report">
|
||||
<column name="report_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_fd_interest_posting_report_line" indexName="idx_fdiprl_account">
|
||||
<column name="fd_account_id"/>
|
||||
<column name="interest_posting_date"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0593-3-fd-interest-posting-run">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_fd_interest_posting_run"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_fd_interest_posting_run">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- One run per report: a retried posting reuses its run rather than opening a second. -->
|
||||
<column name="report_id" type="BIGINT">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_fdipru_report"/>
|
||||
</column>
|
||||
<column name="fd_product_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<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)"/>
|
||||
<column name="distributed_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="rounding_residual" type="DECIMAL(19,6)"/>
|
||||
<!-- PENDING, COMPLETED or FAILED. -->
|
||||
<column name="status" type="VARCHAR(20)">
|
||||
<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>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The scheduled generation. Runs at noon like the savings one, and for the same reason: the
|
||||
branch has the afternoon to look at the figures and approve them before it closes.
|
||||
-->
|
||||
<changeSet author="fineract" id="0593-4-fd-interest-posting-report-job">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Generate Fixed Deposit Interest Posting Report'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="job">
|
||||
<column name="name" value="Generate Fixed Deposit Interest Posting Report"/>
|
||||
<column name="display_name" value="Generate Fixed Deposit Interest Posting Report"/>
|
||||
<column name="short_name" value="FD_IPRPT"/>
|
||||
<column name="cron_expression" value="0 0 12 1/1 * ? *"/>
|
||||
<column name="create_time" valueDate="${current_datetime}"/>
|
||||
<column name="task_priority" valueNumeric="5"/>
|
||||
<column name="group_name"/>
|
||||
<column name="previous_run_start_time"/>
|
||||
<column name="job_key" value="Generate Fixed Deposit Interest Posting ReportJobDetail1 _ DEFAULT"/>
|
||||
<column name="initializing_errorlog"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="currently_running" valueBoolean="false"/>
|
||||
<column name="updates_allowed" valueBoolean="true"/>
|
||||
<column name="scheduler_group" valueNumeric="1"/>
|
||||
<column name="is_misfired" valueBoolean="false"/>
|
||||
<column name="node_id" valueNumeric="1"/>
|
||||
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0593-5-fd-interest-posting-report-permissions">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_FDINTERESTREPORT',
|
||||
'REGENERATE_FDINTERESTREPORT', 'APPROVE_FDINTERESTREPORT')
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="READ_FDINTERESTREPORT"/>
|
||||
<column name="entity_name" value="FDINTERESTREPORT"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="REGENERATE_FDINTERESTREPORT"/>
|
||||
<column name="entity_name" value="FDINTERESTREPORT"/>
|
||||
<column name="action_name" value="REGENERATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<!--
|
||||
can_maker_checker stays false on APPROVE: the report IS the maker-checker record. The user
|
||||
who generates it is the maker and the one who approves it the checker, both held on the
|
||||
report itself, and the approval is refused if they are the same person. Routing this
|
||||
through the generic command queue as well would park the approval behind a second approval
|
||||
and replay it against a staging table it cannot see.
|
||||
-->
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="APPROVE_FDINTERESTREPORT"/>
|
||||
<column name="entity_name" value="FDINTERESTREPORT"/>
|
||||
<column name="action_name" value="APPROVE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0593-6-grant-fd-interest-posting-report-permissions">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_role_permission rp
|
||||
JOIN m_permission p ON p.id = rp.permission_id
|
||||
WHERE p.code IN ('READ_FDINTERESTREPORT', 'REGENERATE_FDINTERESTREPORT',
|
||||
'APPROVE_FDINTERESTREPORT')
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<!-- Super user and manager-named roles, as 0589 grants the savings equivalent. -->
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r
|
||||
CROSS JOIN m_permission p
|
||||
WHERE p.code IN ('READ_FDINTERESTREPORT', 'REGENERATE_FDINTERESTREPORT',
|
||||
'APPROVE_FDINTERESTREPORT')
|
||||
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
The switch back. With this on, fixed deposits are held out of the nightly posting job and paid
|
||||
only through an approved report; with it off, the job pays them directly as it always did and
|
||||
the report screens simply have nothing to show. It is here so a bank that hits trouble on the
|
||||
new path can return to the old one without a code change, and so the two never run at once —
|
||||
every query that selects deposits for posting reads this same flag.
|
||||
-->
|
||||
<!--
|
||||
The period a line's interest was earned over, not just the date it ends on: a checker reading
|
||||
"Quarterly: 01 Oct - 31 Dec" is told what it is approving in a way a bare end date never said.
|
||||
Nullable because a line already posted before this column existed has no retroactive start date
|
||||
and is shown as an open-ended period rather than invented.
|
||||
-->
|
||||
<changeSet author="fineract" id="0593-8-fd-interest-posting-report-line-period-from">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_fd_interest_posting_report_line" columnName="interest_period_from"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_fd_interest_posting_report_line">
|
||||
<column name="interest_period_from" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0593-7-fd-interest-posting-approval-configuration">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM c_configuration WHERE name = 'fd-interest-posting-approval'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="c_configuration">
|
||||
<column name="name" value="fd-interest-posting-approval"/>
|
||||
<column name="value" valueNumeric="0"/>
|
||||
<column name="enabled" valueBoolean="true"/>
|
||||
<column name="is_trap_door" valueBoolean="false"/>
|
||||
<column name="description"
|
||||
value="Fixed deposit interest is reported and approved before it is posted, and posts when the branch closes its day. Turn this off to let the nightly job post fixed deposit interest directly, as it did before."/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -159,7 +159,9 @@ public class TestConfiguration {
|
||||
FineractProperties fineractProperties, Environment environment,
|
||||
List<CustomTaskChange> customTaskChangesForDependencyInjection) {
|
||||
return new TenantDatabaseUpgradeService(tenantDetailsService, tenantDataSource, fineractProperties, tenantDatabaseStateVerifier,
|
||||
liquibaseFactory, tenantDataSourceFactory, environment, customTaskChangesForDependencyInjection);
|
||||
liquibaseFactory, tenantDataSourceFactory, environment,
|
||||
new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment),
|
||||
customTaskChangesForDependencyInjection);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-12
@@ -116,11 +116,7 @@ public class CommandSourceServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
|
||||
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
|
||||
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
|
||||
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
|
||||
// replaces the old bypass this test used to assert.
|
||||
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
|
||||
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
|
||||
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
|
||||
CommandSource commandSource = Mockito.mock(CommandSource.class);
|
||||
@@ -129,8 +125,6 @@ public class CommandSourceServiceTest {
|
||||
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
|
||||
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
|
||||
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
|
||||
Mockito.when(commandSource.getId()).thenReturn(300L);
|
||||
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
|
||||
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
|
||||
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
|
||||
|
||||
@@ -138,12 +132,12 @@ public class CommandSourceServiceTest {
|
||||
.withCommandId(1L).build();
|
||||
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
|
||||
|
||||
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
|
||||
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
|
||||
});
|
||||
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
|
||||
commandSource, superUser, false);
|
||||
|
||||
Mockito.verify(commandSource).markAsAwaitingApproval();
|
||||
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
|
||||
Assertions.assertNotNull(result);
|
||||
Mockito.verify(commandSource).markAsChecked(superUser);
|
||||
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -189,6 +189,8 @@ public class LiquibaseStepDefinitions implements En {
|
||||
.willReturn(tenantLiquibase);
|
||||
|
||||
tenantDatabaseUpgradeService = new TenantDatabaseUpgradeService(tenantDetailsService, tenantStoreDataSource, fineractProperties,
|
||||
databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment, Arrays.asList(tenantPasswordEncryptor));
|
||||
databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment,
|
||||
new org.apache.fineract.infrastructure.core.service.migration.TenantInitialUserProvisioner(environment),
|
||||
Arrays.asList(tenantPasswordEncryptor));
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -68,6 +68,18 @@ public interface SavingsAccountReadPlatformService {
|
||||
List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(boolean backdatedTxnsAllowedTill, int pageSize, Integer status,
|
||||
Long maxSavingsId);
|
||||
|
||||
/**
|
||||
* The active fixed deposits of one branch — and one product, where given — whose interest may be
|
||||
* due for posting on the date. The interest engine settles whether any actually is.
|
||||
*
|
||||
* @param officeId
|
||||
* the branch the deposit's customer belongs to, or null for every branch
|
||||
* @param productId
|
||||
* one fixed deposit product, or null for every product
|
||||
*/
|
||||
List<SavingsAccountData> retrieveFixedDepositsDueForInterestPosting(boolean backdatedTxnsAllowedTill, LocalDate postingDate,
|
||||
Long officeId, Long productId);
|
||||
|
||||
List<SavingsAccountTransactionData> retrieveAllTransactionData(List<String> refNo);
|
||||
|
||||
Long retrieveAccountIdByExternalId(ExternalId externalId);
|
||||
|
||||
Reference in New Issue
Block a user