Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7ff4e4a7b | ||
|
|
7068d65422 | ||
|
|
fd827dec27 | ||
|
|
aa264097e7 | ||
|
|
16c67f6701 | ||
|
|
e846892e9f | ||
|
|
56d98bac8d | ||
|
|
0d6066e0b6 | ||
|
|
4499b8afad | ||
|
|
11a206f42a | ||
|
|
11a0d502be | ||
|
|
2afd4aaf83 | ||
|
|
e5dd6af366 | ||
|
|
ce8bb7e5ca | ||
|
|
82336cbe98 | ||
|
|
9436eab129 | ||
|
|
1408bddf58 | ||
|
|
e02ab51ee1 | ||
|
|
3a37a5f90f | ||
|
|
e7417d16a6 | ||
|
|
24d3bf83bb | ||
|
|
af052dcb4f | ||
|
|
b12268e740 | ||
|
|
6ee3bbe67c | ||
|
|
d8f6c25875 | ||
|
|
2155c94161 | ||
|
|
87f3e591ce | ||
|
|
1e903232f0 | ||
|
|
2c49d6f637 | ||
|
|
331c553164 | ||
|
|
3fb008a22a | ||
|
|
8b2fb052d4 | ||
|
|
cebba87864 | ||
|
|
ea39c8e82a | ||
|
|
32411c924b | ||
|
|
f696499102 | ||
|
|
7eabaf2542 | ||
|
|
4b9093007f | ||
|
|
9beae3df28 | ||
|
|
d2e13cc7b9 | ||
|
|
ecfe8aa76a | ||
|
|
a7cb09f15c | ||
|
|
26d4c241c7 | ||
|
|
dbc2737ada | ||
|
|
f457b9b8c7 | ||
|
|
23ee4f1063 | ||
|
|
bc38216ca3 |
+2
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
|
||||
|
||||
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
|
||||
|
||||
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
|
||||
|
||||
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
|
||||
|
||||
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
|
||||
|
||||
+16
@@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
return accountMappingDetails;
|
||||
}
|
||||
|
||||
/** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */
|
||||
@Override
|
||||
public Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
|
||||
final List<ProductToGLAccountMapping> mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId,
|
||||
PortfolioProductType.CURRENT.getValue());
|
||||
if (AccountingValidations.isCashBasedAccounting(accountingType)) {
|
||||
return setCashSavingsProductToGLAccountMaps(mappings);
|
||||
}
|
||||
if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) {
|
||||
return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) {
|
||||
return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId);
|
||||
@@ -463,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
|
||||
// Income
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
|
||||
|
||||
+7
@@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
||||
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||
// Interest Payable is editable on cash too, and optional. Interest posting routes
|
||||
// through it on either basis, so a product can name one to carry interest declared
|
||||
// but not yet credited; a product that names none still posts straight to Savings
|
||||
// Control. Created-or-merged because a product configured before this row existed
|
||||
// has no mapping to merge into.
|
||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
|
||||
savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes);
|
||||
break;
|
||||
case ACCRUAL_PERIODIC:
|
||||
// asset
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -270,6 +270,7 @@ public final class AccountingConstants {
|
||||
INCOME_FROM_INTEREST(12), //
|
||||
LOSSES_WRITTEN_OFF(13), //
|
||||
ESCHEAT_LIABILITY(14), //
|
||||
INTEREST_PAYABLE(17), //
|
||||
/**
|
||||
* Where the paise dropped from a cash closure are absorbed.
|
||||
*
|
||||
|
||||
+10
-32
@@ -137,7 +137,16 @@ public class CommandSourceService {
|
||||
String permission = commandSource.getPermissionCode();
|
||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||
// 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) {
|
||||
commandSource.markAsChecked(user);
|
||||
} else {
|
||||
if (commandSource.isSanitized()) {
|
||||
@@ -151,37 +160,6 @@ 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())
|
||||
|| isDdsEntity(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DDS module's maker-checker entities. A super user's DDS work goes to a checker like anyone
|
||||
* else's: DDS moves customers' money, and every such move is to be approved by a second person.
|
||||
*/
|
||||
public static boolean isDdsEntity(final String entityName) {
|
||||
return entityName != null && switch (entityName.toUpperCase()) {
|
||||
case "PARTY", "AGENT", "PARTYTRANSACTION", "DDSACCOUNTTYPE", "COMMISSIONSLAB", "DDSBATCH", "DDSCOLLECTIONENTRY" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||
return;
|
||||
|
||||
+111
-161
@@ -664,167 +664,6 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createAgent() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "AGENT";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/agents/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateAgent(final Long agentId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "AGENT";
|
||||
this.entityId = agentId;
|
||||
this.href = "/dds/agents/" + agentId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder closeAgent(final Long agentId) {
|
||||
this.actionName = "CLOSE";
|
||||
this.entityName = "AGENT";
|
||||
this.entityId = agentId;
|
||||
this.href = "/dds/agents/" + agentId + "?command=close";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createParty() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/parties/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateParty(final Long partyId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/dds/parties/" + partyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder closeParty(final Long partyId) {
|
||||
this.actionName = "CLOSE";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/dds/parties/" + partyId + "?command=close";
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a matured DDS account out and closes it. A separate action from CLOSE because it moves
|
||||
* money, and so is worth its own permission and its own line in the audit trail.
|
||||
*/
|
||||
public CommandWrapperBuilder payOutParty(final Long partyId) {
|
||||
this.actionName = "PAYOUT";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/dds/parties/" + partyId + "?command=payout";
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a DDS account before its term is up, settling the shortfall against the customer or the
|
||||
* agent. Separate from both CLOSE and PAYOUT because it is the only one of the three that changes
|
||||
* what a customer is owed, so a bank will want it approved by someone other than whoever raised it.
|
||||
*/
|
||||
public CommandWrapperBuilder prematureCloseParty(final Long partyId) {
|
||||
this.actionName = "PREMATURECLOSE";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/dds/parties/" + partyId + "?command=prematureclose";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder prematureCloseOverrideParty(final Long partyId) {
|
||||
this.actionName = "PREMATURECLOSEOVERRIDE";
|
||||
this.entityName = "PARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/dds/parties/" + partyId + "?command=prematurecloseoverride";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createDdsAccountType() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "DDSACCOUNTTYPE";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/accounttypes/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateDdsAccountType(final Long accountTypeId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "DDSACCOUNTTYPE";
|
||||
this.entityId = accountTypeId;
|
||||
this.href = "/dds/accounttypes/" + accountTypeId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createPartyTransaction() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "PARTYTRANSACTION";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/partytransactions/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createDdsCollectionEntries() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "DDSCOLLECTIONENTRY";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/collectionentries/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder cancelDdsCollectionEntry(final Long entryId) {
|
||||
this.actionName = "CANCEL";
|
||||
this.entityName = "DDSCOLLECTIONENTRY";
|
||||
this.entityId = entryId;
|
||||
this.href = "/dds/collectionentries/" + entryId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder settleDdsCollectionEntries() {
|
||||
this.actionName = "SETTLE";
|
||||
this.entityName = "DDSCOLLECTIONENTRY";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/collectionentries/settle";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createCommissionSlabs() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "COMMISSIONSLAB";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/commissionslabs/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateCommissionSlab(final Long slabId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "COMMISSIONSLAB";
|
||||
this.entityId = slabId;
|
||||
this.href = "/dds/commissionslabs/" + slabId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder generateDdsBatchRun() {
|
||||
this.actionName = "GENERATE";
|
||||
this.entityName = "DDSBATCH";
|
||||
this.entityId = null;
|
||||
this.href = "/dds/batchruns/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder postDdsBatchRun(final Long batchRunId) {
|
||||
this.actionName = "POST";
|
||||
this.entityName = "DDSBATCH";
|
||||
this.entityId = batchRunId;
|
||||
this.href = "/dds/batchruns/" + batchRunId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "OFFICE";
|
||||
@@ -917,6 +756,14 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteFund(final Long fundId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "FUND";
|
||||
this.entityId = fundId;
|
||||
this.href = "/funds/" + fundId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createReport() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "REPORT";
|
||||
@@ -1045,6 +892,70 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createPaymentType() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "PAYMENTTYPE";
|
||||
this.entityId = null;
|
||||
this.href = "/paymenttypes/template";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updatePaymentType(final Long paymentTypeId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "PAYMENTTYPE";
|
||||
this.entityId = paymentTypeId;
|
||||
this.href = "/paymenttypes/" + paymentTypeId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deletePaymentType(final Long paymentTypeId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "PAYMENTTYPE";
|
||||
this.entityId = paymentTypeId;
|
||||
this.href = "/paymenttypes/" + paymentTypeId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createSponsorBank() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "SPONSORBANK";
|
||||
this.entityId = null;
|
||||
this.href = "/sponsorbanks";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateSponsorBank(final Long sponsorBankId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "SPONSORBANK";
|
||||
this.entityId = sponsorBankId;
|
||||
this.href = "/sponsorbanks/" + sponsorBankId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateSponsorDdGlMapping() {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "SPONSORDDGLMAPPING";
|
||||
this.entityId = null;
|
||||
this.href = "/sponsordd/gl-mapping";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateInstitutionProfile() {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "INSTITUTIONPROFILE";
|
||||
this.entityId = null;
|
||||
this.href = "/institutionprofile";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateBranchProfile(final Long officeId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "BRANCHPROFILE";
|
||||
this.entityId = officeId;
|
||||
this.href = "/branchprofile/" + officeId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createLoanProduct() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "LOANPRODUCT";
|
||||
@@ -3551,6 +3462,13 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateCurrency() {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "CURRENCY";
|
||||
this.href = "/currencies";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "EXTERNALSERVICES";
|
||||
@@ -4891,4 +4809,36 @@ public class CommandWrapperBuilder {
|
||||
this.href = "/loans/" + loanId + "/originators/" + originatorId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createSurvey() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "SURVEY";
|
||||
this.entityId = null;
|
||||
this.href = "/surveys";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateSurvey(final Long surveyId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "SURVEY";
|
||||
this.entityId = surveyId;
|
||||
this.href = "/surveys/" + surveyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder activateSurvey(final Long surveyId) {
|
||||
this.actionName = "ACTIVATE";
|
||||
this.entityName = "SURVEY";
|
||||
this.entityId = surveyId;
|
||||
this.href = "/surveys/" + surveyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deactivateSurvey(final Long surveyId) {
|
||||
this.actionName = "DEACTIVATE";
|
||||
this.entityName = "SURVEY";
|
||||
this.entityId = surveyId;
|
||||
this.href = "/surveys/" + surveyId;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-10
@@ -48,6 +48,12 @@ 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
|
||||
@@ -123,16 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
AppUser appUser = this.context.authenticatedUser();
|
||||
String permissionCode = commandSource.getPermissionCode();
|
||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||
// A DDS entry is never checked by the user who made it, super user or not - see CommandSourceService.isDdsEntity.
|
||||
if (!configurationService.isSameMakerCheckerEnabled()
|
||||
&& (!appUser.isCheckerSuperUser() || CommandSourceService.isDdsEntity(commandSource.getEntityName()))) {
|
||||
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.");
|
||||
}
|
||||
// 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.");
|
||||
}
|
||||
return commandSource;
|
||||
}
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ public enum JobName {
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
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"), //
|
||||
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"), //
|
||||
@@ -72,8 +73,8 @@ public enum JobName {
|
||||
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
||||
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||
POST_DDS_INTEREST("Post DDS Interest"), //
|
||||
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
||||
; //
|
||||
|
||||
private final String name;
|
||||
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Immutable data object representing a DDS agent and its guarantors, following the legacy Agent
|
||||
* Master Entry screen. Built via the Lombok builder rather than a long positional constructor, as the
|
||||
* other DDS data objects are.
|
||||
*
|
||||
* The commission block below is on the legacy SubAgent Master screen rather than the Agent one, and
|
||||
* drives the commission batch.
|
||||
*/
|
||||
@Getter
|
||||
@Builder(toBuilder = true)
|
||||
public final class AgentData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final Long clientId;
|
||||
private final String clientName;
|
||||
private final String agentCode;
|
||||
private final String agentNameId;
|
||||
private final String agentAddress1;
|
||||
private final String agentAddress2;
|
||||
private final String agentCity;
|
||||
private final String agentPinCode;
|
||||
private final String agentTelephone;
|
||||
private final String agentSavingAccountType;
|
||||
private final String agentSavingAccountNo;
|
||||
private final LocalDate agentOpeningDate;
|
||||
private final LocalDate agentBirthDate;
|
||||
private final BigDecimal agentCurrentBalance;
|
||||
private final BigDecimal agentOpeningBalance;
|
||||
|
||||
/**
|
||||
* The branch this agent belongs to. {@code officeId} is what the screen sends back on an edit;
|
||||
* {@code homeBranch} is that office's current name, read from the office row rather than from
|
||||
* the agent, so a renamed branch reads correctly here without the agent being touched.
|
||||
*/
|
||||
private final Long officeId;
|
||||
private final String homeBranch;
|
||||
|
||||
/**
|
||||
* The ledger account this agent's collections pass through, if the bank gave them one. The id is
|
||||
* what the screen sends back on an edit; the code and name are what it shows.
|
||||
*/
|
||||
private final Long glAccountId;
|
||||
private final String glAccountCode;
|
||||
private final String glAccountName;
|
||||
|
||||
/** Commission owed but not yet paid out, and the TDS withheld so far. Both are batch outcomes. */
|
||||
private final BigDecimal commission;
|
||||
private final BigDecimal tdsAmount;
|
||||
|
||||
/** Commission settings and the standing figures the commission run deducts. */
|
||||
private final boolean commissionCalculation;
|
||||
private final boolean tdsApplicable;
|
||||
private final BigDecimal tdsPercent;
|
||||
private final BigDecimal deductCommission;
|
||||
private final BigDecimal agentDeposit;
|
||||
private final BigDecimal festivalAdvance;
|
||||
private final BigDecimal otherDeduction;
|
||||
|
||||
private final boolean active;
|
||||
|
||||
/** When the relationship ended, or null while the agent is on the books. */
|
||||
private final LocalDate closeDate;
|
||||
|
||||
private final LocalDateTime createdDate;
|
||||
private final LocalDateTime lastModifiedDate;
|
||||
private final Collection<AgentGuarantorData> guarantors;
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Immutable data object representing a single agent guarantor.
|
||||
*/
|
||||
@Getter
|
||||
public final class AgentGuarantorData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final Long clientId;
|
||||
private final String guarantorName;
|
||||
private final String address1;
|
||||
private final String address2;
|
||||
private final String city;
|
||||
private final String pinCode;
|
||||
private final String telephone;
|
||||
|
||||
public static AgentGuarantorData instance(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||
final String address2, final String city, final String pinCode, final String telephone) {
|
||||
return new AgentGuarantorData(id, clientId, guarantorName, address1, address2, city, pinCode, telephone);
|
||||
}
|
||||
|
||||
private AgentGuarantorData(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||
final String address2, final String city, final String pinCode, final String telephone) {
|
||||
this.id = id;
|
||||
this.clientId = clientId;
|
||||
this.guarantorName = guarantorName;
|
||||
this.address1 = address1;
|
||||
this.address2 = address2;
|
||||
this.city = city;
|
||||
this.pinCode = pinCode;
|
||||
this.telephone = telephone;
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.domain;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
/**
|
||||
* A DDS (Daily Deposit Scheme) collection agent. Can represent either a person or, at the top of the
|
||||
* hierarchy, the bank itself acting as its own agent. Holds its own settlement savings account and one
|
||||
* or more guarantors.
|
||||
*
|
||||
* The field set follows the legacy Agent Master Entry screen. SubAgent was folded into Agent
|
||||
* (2026-08-24) so the Agent now does the daily collection work directly, but the SubAgent-only fields
|
||||
* that came across with it (TDS, transfer/security accounts, machine rent, collection-limit history)
|
||||
* were on no screen the business uses and were read by no query, report or job, so they were dropped
|
||||
* again in 0481. Two fields are kept despite not appearing on that screen, because code reads them:
|
||||
* {@code commission}/{@code lastCommissionDate}, and the deduction figures the Agent Commission
|
||||
* Statement reports.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
|
||||
public class Agent extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@Column(name = "agent_code", length = 20, nullable = false)
|
||||
private String agentCode;
|
||||
|
||||
@Column(name = "agent_name", length = 100, nullable = false)
|
||||
private String agentName;
|
||||
|
||||
@Column(name = "agent_address1", length = 200, nullable = false)
|
||||
private String agentAddress1;
|
||||
|
||||
@Column(name = "agent_address2", length = 200)
|
||||
private String agentAddress2;
|
||||
|
||||
@Column(name = "agent_city", length = 100, nullable = false)
|
||||
private String agentCity;
|
||||
|
||||
@Column(name = "agent_pincode", length = 20, nullable = false)
|
||||
private String agentPinCode;
|
||||
|
||||
@Column(name = "agent_telephone", length = 20)
|
||||
private String agentTelephone;
|
||||
|
||||
@Column(name = "agent_saving_account_type", length = 100, nullable = false)
|
||||
private String agentSavingAccountType;
|
||||
|
||||
@Column(name = "agent_saving_account_no", length = 50, nullable = false)
|
||||
private String agentSavingAccountNo;
|
||||
|
||||
@Column(name = "agent_opening_date", nullable = false)
|
||||
private LocalDate agentOpeningDate;
|
||||
|
||||
@Column(name = "agent_birth_date", nullable = false)
|
||||
private LocalDate agentBirthDate;
|
||||
|
||||
@Column(name = "agent_current_balance", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal agentCurrentBalance;
|
||||
|
||||
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal agentOpeningBalance;
|
||||
|
||||
/**
|
||||
* The branch this agent belongs to, and the thing of record for it.
|
||||
*
|
||||
* Defaults to the agent's client's office - where the person sits on the bank's books - but is
|
||||
* chosen on the screen, since an agent can be registered at one branch and collect out of
|
||||
* another.
|
||||
*/
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "office_id")
|
||||
private Office office;
|
||||
|
||||
/**
|
||||
* The office's name, held alongside the office itself.
|
||||
*
|
||||
* Redundant by design: the batch job's branch filter and several reports match on this name, and
|
||||
* were written before the office link existed. It is always written from {@link #office}, never
|
||||
* on its own, so the two cannot drift apart.
|
||||
*/
|
||||
@Column(name = "home_branch", length = 100)
|
||||
private String homeBranch;
|
||||
|
||||
/**
|
||||
* The ledger account this agent's collections pass through, if the bank has given them one.
|
||||
*
|
||||
* Collections debit it on their way to the customer and settlements credit it when the cash
|
||||
* arrives, so what is left standing on it is what this agent is holding and has not brought in.
|
||||
* One agent to an account - shared, it would say nothing that the old single account did not.
|
||||
*
|
||||
* Null for the agents that predate this, and they go on using the scheme's shared agent-cash
|
||||
* account exactly as before. Nothing about the route the money takes depends on which of the
|
||||
* two it is.
|
||||
*/
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "gl_account_id")
|
||||
private GLAccount glAccount;
|
||||
|
||||
/**
|
||||
* Commission earned by Commission Calculation but not yet paid out. Commission Posting pays it and
|
||||
* resets this to zero, so it reads as "owed to this agent right now" - which is what the legacy
|
||||
* SubAgent Master screen's "Comm" box shows.
|
||||
*/
|
||||
@Column(name = "commission", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal commission;
|
||||
|
||||
/** Whether Commission Calculation covers this agent at all ("Com. Calculation" on the legacy screen). */
|
||||
@Column(name = "commission_calculation", nullable = false)
|
||||
private boolean commissionCalculation;
|
||||
|
||||
/** TDS is withheld from commission at source when the bank has it switched on for this agent. */
|
||||
@Column(name = "tds_applicable", nullable = false)
|
||||
private boolean tdsApplicable;
|
||||
|
||||
@Column(name = "tds_percent", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal tdsPercent;
|
||||
|
||||
/** Running total of TDS withheld, the legacy screen's "TDS Amt". */
|
||||
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal tdsAmount;
|
||||
|
||||
/** The four figures the Agent Commission Statement subtracts from gross commission. */
|
||||
@Column(name = "deduct_commission", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal deductCommission;
|
||||
|
||||
@Column(name = "agent_deposit", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal agentDeposit;
|
||||
|
||||
@Column(name = "festival_advance", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal festivalAdvance;
|
||||
|
||||
@Column(name = "other_deduction", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal otherDeduction;
|
||||
|
||||
@Column(name = "last_commission_date")
|
||||
private LocalDate lastCommissionDate;
|
||||
|
||||
/**
|
||||
* An agent who stops collecting is deactivated, never deleted: every party collection, settlement
|
||||
* and commission posting is recorded against it, so removing it would orphan its whole history.
|
||||
* An inactive agent stays out of the pick lists for new collection work while its record and
|
||||
* history stay intact, and it can be switched back on.
|
||||
*/
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
/**
|
||||
* When the relationship ended, or null while the agent is on the books.
|
||||
*
|
||||
* Distinct from {@code active}: switching an agent off stops them collecting and has to be
|
||||
* possible immediately, whereas closing is the end of the relationship and is only allowed once
|
||||
* nothing is left hanging. A closed agent is never deleted - their whole collection history is
|
||||
* recorded against them.
|
||||
*/
|
||||
@Column(name = "close_date")
|
||||
private LocalDate closeDate;
|
||||
|
||||
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("id")
|
||||
private List<AgentGuarantor> guarantors = new ArrayList<>();
|
||||
|
||||
protected Agent() {
|
||||
//
|
||||
}
|
||||
|
||||
private Agent(final Client client, final String agentCode, final String agentName, final String agentAddress1,
|
||||
final String agentAddress2, final String agentCity, final String agentPinCode, final String agentTelephone,
|
||||
final String agentSavingAccountType, final String agentSavingAccountNo, final LocalDate agentOpeningDate,
|
||||
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final Office office) {
|
||||
this.client = client;
|
||||
this.agentCode = agentCode;
|
||||
this.agentName = agentName;
|
||||
this.agentAddress1 = agentAddress1;
|
||||
this.agentAddress2 = StringUtils.defaultIfEmpty(agentAddress2, null);
|
||||
this.agentCity = agentCity;
|
||||
this.agentPinCode = agentPinCode;
|
||||
this.agentTelephone = StringUtils.defaultIfEmpty(agentTelephone, null);
|
||||
this.agentSavingAccountType = agentSavingAccountType;
|
||||
this.agentSavingAccountNo = agentSavingAccountNo;
|
||||
this.agentOpeningDate = agentOpeningDate;
|
||||
this.agentBirthDate = agentBirthDate;
|
||||
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
|
||||
this.agentCurrentBalance = this.agentOpeningBalance;
|
||||
setOffice(office);
|
||||
this.active = true;
|
||||
|
||||
this.commission = BigDecimal.ZERO;
|
||||
// Commission is earned unless someone says otherwise, matching the legacy screen's default.
|
||||
this.commissionCalculation = true;
|
||||
this.tdsApplicable = false;
|
||||
this.tdsPercent = BigDecimal.ZERO;
|
||||
this.tdsAmount = BigDecimal.ZERO;
|
||||
this.deductCommission = BigDecimal.ZERO;
|
||||
this.agentDeposit = BigDecimal.ZERO;
|
||||
this.festivalAdvance = BigDecimal.ZERO;
|
||||
this.otherDeduction = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param office
|
||||
* the branch this agent belongs to, resolved by the caller from the command's
|
||||
* {@code officeId} or, failing that, from the client's own office.
|
||||
*/
|
||||
public static Agent fromJson(final Client client, final String agentCode, final Office office, final JsonCommand command) {
|
||||
final String agentName = command.stringValueOfParameterNamed("agentNameId");
|
||||
final String agentAddress1 = command.stringValueOfParameterNamed("agentAddress1");
|
||||
final String agentAddress2 = command.stringValueOfParameterNamedAllowingNull("agentAddress2");
|
||||
final String agentCity = command.stringValueOfParameterNamed("agentCity");
|
||||
final String agentPinCode = command.stringValueOfParameterNamed("agentPinCode");
|
||||
final String agentTelephone = command.stringValueOfParameterNamedAllowingNull("agentTelephone");
|
||||
final String agentSavingAccountType = command.stringValueOfParameterNamed("agentSavingAccountType");
|
||||
final String agentSavingAccountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||
final LocalDate agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
||||
final LocalDate agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
||||
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
|
||||
|
||||
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
|
||||
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, office);
|
||||
}
|
||||
|
||||
public void addGuarantor(final AgentGuarantor guarantor) {
|
||||
guarantor.setAgent(this);
|
||||
this.guarantors.add(guarantor);
|
||||
}
|
||||
|
||||
public void resetGuarantors() {
|
||||
this.guarantors.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commission Calculation: adds what this agent earned to the amount owed to them, withholds the
|
||||
* TDS that went with it, and stamps the date commission is now calculated through - so the next
|
||||
* Commission Calculation only covers collections since then, and re-running it for the same
|
||||
* period finds nothing left to earn.
|
||||
*/
|
||||
public void accrueCommission(final BigDecimal netAmount, final BigDecimal tdsWithheld, final LocalDate throughDate) {
|
||||
this.commission = this.commission.add(netAmount);
|
||||
this.tdsAmount = this.tdsAmount.add(tdsWithheld);
|
||||
this.lastCommissionDate = throughDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commission Posting: pays the given amount out of what is owed and returns what remains owed.
|
||||
*
|
||||
* The amount is the one the run previewed rather than whatever is owed at this moment, so what
|
||||
* gets paid is always what was seen and approved. Since it comes off the owed figure, a second
|
||||
* posting of the same commission finds nothing left to pay.
|
||||
*/
|
||||
public BigDecimal payOutCommission(final BigDecimal amount) {
|
||||
this.commission = this.commission.subtract(amount);
|
||||
return this.commission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges the agent for an account of theirs that was closed before its term was up.
|
||||
*
|
||||
* Booked against {@code deductCommission} rather than taken straight off {@code commission},
|
||||
* because what is owed at this moment may be nothing - commission is paid out in runs, and an
|
||||
* agent who was paid yesterday would otherwise escape the charge entirely. Held as a deduction it
|
||||
* is carried until there is commission to take it from, and it shows on the Agent Commission
|
||||
* Statement between gross and take-home, where an agent can see what was charged and why.
|
||||
*/
|
||||
public BigDecimal chargePrematureClosure(final BigDecimal amount) {
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return this.deductCommission;
|
||||
}
|
||||
this.deductCommission = this.deductCommission.add(amount);
|
||||
return this.deductCommission;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four figures the Agent Commission Statement subtracts between gross commission and the
|
||||
* agent's take-home total. They are standing amounts on the agent, not per-run ones, so a run
|
||||
* deducts whatever they hold when it is calculated.
|
||||
*/
|
||||
public BigDecimal totalDeductions() {
|
||||
return this.deductCommission.add(this.agentDeposit).add(this.festivalAdvance).add(this.otherDeduction);
|
||||
}
|
||||
|
||||
public boolean isClosed() {
|
||||
return this.closeDate != null;
|
||||
}
|
||||
|
||||
/** Cash collected from customers that has not yet reached a branch. */
|
||||
public boolean holdsCash() {
|
||||
return this.agentCurrentBalance.compareTo(BigDecimal.ZERO) != 0;
|
||||
}
|
||||
|
||||
/** Commission earned but not yet paid out. */
|
||||
public boolean isOwedCommission() {
|
||||
return this.commission.compareTo(BigDecimal.ZERO) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the relationship. The caller checks first that no accounts are still assigned; the two
|
||||
* money conditions are checked here, where the figures live.
|
||||
*
|
||||
* Closing also switches the agent off, since an agent who is no longer on the books cannot be
|
||||
* collecting either.
|
||||
*/
|
||||
public void close(final LocalDate closeDate) {
|
||||
this.closeDate = closeDate;
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash this agent has collected from parties but not yet handed over to a branch (an Agent Tran
|
||||
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement.
|
||||
*/
|
||||
public BigDecimal credit(final BigDecimal amount) {
|
||||
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
|
||||
return this.agentCurrentBalance;
|
||||
}
|
||||
|
||||
public BigDecimal settle(final BigDecimal amount) {
|
||||
this.agentCurrentBalance = this.agentCurrentBalance.subtract(amount);
|
||||
return this.agentCurrentBalance;
|
||||
}
|
||||
|
||||
public void changeClient(final Client newClient) {
|
||||
this.client = newClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the agent to a branch, writing the office's name into {@code homeBranch} in the same
|
||||
* step so the name never has to be set by hand and never lags behind the office.
|
||||
*/
|
||||
public void setOffice(final Office office) {
|
||||
this.office = office;
|
||||
this.homeBranch = office == null ? null : StringUtils.defaultIfEmpty(office.getName(), null);
|
||||
}
|
||||
|
||||
public Long officeId() {
|
||||
return this.office == null ? null : this.office.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Points this agent's collections at a ledger account, or back at the scheme's shared one.
|
||||
*
|
||||
* Whether the account may be used at all - that it exists, is an asset account, is not disabled,
|
||||
* and is not already another agent's - is settled before this is called. By the time the agent
|
||||
* holds it, the question is closed.
|
||||
*/
|
||||
public void setGlAccount(final GLAccount glAccount) {
|
||||
this.glAccount = glAccount;
|
||||
}
|
||||
|
||||
public Long glAccountId() {
|
||||
return this.glAccount == null ? null : this.glAccount.getId();
|
||||
}
|
||||
|
||||
public Long clientId() {
|
||||
return this.client.getId();
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(10);
|
||||
|
||||
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
||||
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentNameId", this.agentName)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentNameId");
|
||||
actualChanges.put("agentNameId", newValue);
|
||||
this.agentName = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentAddress1", this.agentAddress1)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentAddress1");
|
||||
actualChanges.put("agentAddress1", newValue);
|
||||
this.agentAddress1 = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentAddress2", this.agentAddress2)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentAddress2");
|
||||
actualChanges.put("agentAddress2", newValue);
|
||||
this.agentAddress2 = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentCity", this.agentCity)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentCity");
|
||||
actualChanges.put("agentCity", newValue);
|
||||
this.agentCity = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentPinCode", this.agentPinCode)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentPinCode");
|
||||
actualChanges.put("agentPinCode", newValue);
|
||||
this.agentPinCode = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentTelephone", this.agentTelephone)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentTelephone");
|
||||
actualChanges.put("agentTelephone", newValue);
|
||||
this.agentTelephone = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentSavingAccountType", this.agentSavingAccountType)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountType");
|
||||
actualChanges.put("agentSavingAccountType", newValue);
|
||||
this.agentSavingAccountType = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("agentSavingAccountNo", this.agentSavingAccountNo)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||
actualChanges.put("agentSavingAccountNo", newValue);
|
||||
this.agentSavingAccountNo = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("agentOpeningDate", this.agentOpeningDate)) {
|
||||
actualChanges.put("agentOpeningDate", command.stringValueOfParameterNamed("agentOpeningDate"));
|
||||
this.agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("agentBirthDate", this.agentBirthDate)) {
|
||||
actualChanges.put("agentBirthDate", command.stringValueOfParameterNamed("agentBirthDate"));
|
||||
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("agentOpeningBalance", this.agentOpeningBalance)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentOpeningBalance");
|
||||
actualChanges.put("agentOpeningBalance", newValue);
|
||||
// The current balance is the opening balance plus collections less settlements, so
|
||||
// correcting the opening figure has to move the current one by the same amount.
|
||||
// Setting it alone would leave the two contradicting each other for good, since nothing
|
||||
// else ever recomputes the current balance from scratch.
|
||||
this.agentCurrentBalance = this.agentCurrentBalance.add(newValue.subtract(this.agentOpeningBalance));
|
||||
this.agentOpeningBalance = newValue;
|
||||
actualChanges.put("agentCurrentBalance", this.agentCurrentBalance);
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
actualChanges.put("active", newValue);
|
||||
this.active = newValue;
|
||||
}
|
||||
|
||||
// Commission settings and the standing deductions. The running totals the batch maintains -
|
||||
// `commission` and `tdsAmount` - are deliberately not editable here: they are the outcome of
|
||||
// posted runs, and typing over them would put the agent's balance out of step with the runs
|
||||
// that produced it.
|
||||
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
|
||||
actualChanges.put("commissionCalculation", newValue);
|
||||
this.commissionCalculation = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("tdsApplicable", this.tdsApplicable)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
|
||||
actualChanges.put("tdsApplicable", newValue);
|
||||
this.tdsApplicable = newValue;
|
||||
}
|
||||
|
||||
decimalChange(command, actualChanges, "tdsPercent", this.tdsPercent, v -> this.tdsPercent = v);
|
||||
decimalChange(command, actualChanges, "deductCommission", this.deductCommission, v -> this.deductCommission = v);
|
||||
decimalChange(command, actualChanges, "agentDeposit", this.agentDeposit, v -> this.agentDeposit = v);
|
||||
decimalChange(command, actualChanges, "festivalAdvance", this.festivalAdvance, v -> this.festivalAdvance = v);
|
||||
decimalChange(command, actualChanges, "otherDeduction", this.otherDeduction, v -> this.otherDeduction = v);
|
||||
|
||||
// A rate means nothing while TDS is switched off, so it is not left behind to be applied by a
|
||||
// later switch-on nobody connected to it.
|
||||
if (!this.tdsApplicable) {
|
||||
this.tdsPercent = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
|
||||
private void decimalChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
||||
final BigDecimal existingValue, final Consumer<BigDecimal> setter) {
|
||||
if (command.isChangeInBigDecimalParameterNamed(paramName, existingValue)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(paramName);
|
||||
actualChanges.put(paramName, newValue);
|
||||
setter.accept(newValue == null ? BigDecimal.ZERO : newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* A single guarantor recorded against an {@link Agent} (or, reused with the same shape, against a
|
||||
* SubAgent). Always created/replaced as part of its parent agent's own create/update command - it has
|
||||
* no independent API of its own.
|
||||
*
|
||||
* Audited in its own right even so: a guarantor is who the bank goes to if an agent's collections go
|
||||
* missing, and a timestamp on the agent saying something changed does not say that the person
|
||||
* standing behind them was swapped, nor by whom.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_agent_guarantor")
|
||||
public class AgentGuarantor extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
private Agent agent;
|
||||
|
||||
@Column(name = "client_id", nullable = false)
|
||||
private Long clientId;
|
||||
|
||||
@Column(name = "guarantor_name", length = 100, nullable = false)
|
||||
private String guarantorName;
|
||||
|
||||
@Column(name = "address1", length = 200, nullable = false)
|
||||
private String address1;
|
||||
|
||||
@Column(name = "address2", length = 200)
|
||||
private String address2;
|
||||
|
||||
@Column(name = "city", length = 100, nullable = false)
|
||||
private String city;
|
||||
|
||||
@Column(name = "pin_code", length = 20, nullable = false)
|
||||
private String pinCode;
|
||||
|
||||
@Column(name = "telephone", length = 20)
|
||||
private String telephone;
|
||||
|
||||
protected AgentGuarantor() {
|
||||
//
|
||||
}
|
||||
|
||||
public AgentGuarantor(final Long clientId, final String guarantorName, final String address1, final String address2,
|
||||
final String city, final String pinCode, final String telephone) {
|
||||
this.clientId = clientId;
|
||||
this.guarantorName = guarantorName;
|
||||
this.address1 = address1;
|
||||
this.address2 = StringUtils.defaultIfEmpty(address2, null);
|
||||
this.city = city;
|
||||
this.pinCode = pinCode;
|
||||
this.telephone = StringUtils.defaultIfEmpty(telephone, null);
|
||||
}
|
||||
|
||||
void setAgent(final Agent agent) {
|
||||
this.agent = agent;
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is closed while their books are not square.
|
||||
*
|
||||
* Closing is the end of the relationship, so it is refused until nothing is left hanging: no accounts
|
||||
* still being collected from, no cash held that has not reached a branch, and no commission earned but
|
||||
* unpaid. Each of those is money or a customer that would be left with no one responsible for it.
|
||||
*
|
||||
* Deactivating is deliberately not guarded this way. Switching an agent off has to be possible the
|
||||
* moment the bank decides it - if it waited for the books to be squared, an agent the bank wanted
|
||||
* stopped would keep collecting while the paperwork caught up.
|
||||
*/
|
||||
public class AgentCannotBeClosedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentCannotBeClosedException(final String agentCode, final String reason) {
|
||||
super("error.msg.dds.agent.cannot.be.closed", "Agent " + agentCode + " cannot be closed: " + reason, agentCode, reason);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is listed as one of its own guarantors - a guarantee is only worth something
|
||||
* coming from someone other than the agent it backs.
|
||||
*/
|
||||
public class AgentCannotGuaranteeItselfException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentCannotGuaranteeItselfException(final Long clientId) {
|
||||
super("error.msg.agent.cannot.guarantee.itself", "An agent cannot be its own guarantor", clientId);
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a commission run cannot pay an agent because their account will not take the credit.
|
||||
*
|
||||
* The account is checked when it is chosen, so reaching this means something changed afterwards -
|
||||
* most likely the account was closed. Failing here rolls the whole run back rather than paying the
|
||||
* other agents and leaving this one silently unpaid: a commission run that has half happened is
|
||||
* worse to unpick than one that has not happened at all.
|
||||
*/
|
||||
public class AgentCommissionAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentCommissionAccountUnusableException(final String agentCode, final String reason) {
|
||||
super("error.msg.dds.agent.commission.account.unusable",
|
||||
"Commission for agent " + agentCode + " could not be paid: " + reason + ".", agentCode, reason);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when the ledger account named for an agent could not carry that agent's cash.
|
||||
*
|
||||
* Refused when it is chosen rather than when it is first posted to. An account that cannot take the
|
||||
* entry fails the whole collection, at the counter, with a customer waiting - and the person who
|
||||
* picked it is not the person who finds out.
|
||||
*/
|
||||
public class AgentGlAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentGlAccountUnusableException(final String glCode, final String reason) {
|
||||
super("error.msg.dds.agent.gl.account.unusable",
|
||||
"Ledger account " + glCode + " cannot be used for this agent: " + reason
|
||||
+ ". Choose a detail asset account that no other agent already holds.",
|
||||
glCode, reason);
|
||||
}
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
|
||||
/**
|
||||
* A {@link RuntimeException} thrown when a DDS agent resource is not found.
|
||||
*/
|
||||
public class AgentNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public AgentNotFoundException(final Long id) {
|
||||
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id);
|
||||
}
|
||||
|
||||
public AgentNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id, e);
|
||||
}
|
||||
|
||||
public AgentNotFoundException(final String agentCode) {
|
||||
super("error.msg.agent.code.invalid", "Agent with code " + agentCode + " does not exist", agentCode);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is given a savings account that belongs to somebody else.
|
||||
*
|
||||
* An agent's commission is their own earnings, so it is paid into their own account. Accepting
|
||||
* another person's account number here would route one person's pay into another's balance - a
|
||||
* mistake that reads as ordinary data entry and would only surface when the person who should have
|
||||
* been paid asked where their money went.
|
||||
*/
|
||||
public class AgentSavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentSavingsAccountClientMismatchException(final String accountNo) {
|
||||
super("error.msg.dds.agent.savings.account.client.mismatch",
|
||||
"Savings account " + accountNo + " belongs to a different client. An agent's commission is paid into their own account.",
|
||||
accountNo);
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent is given a savings account number that no open account carries.
|
||||
*
|
||||
* The agent's commission is paid into this account, so a number that exists only as typed text means
|
||||
* a payment with nowhere to land - and nothing downstream would catch it, because the commission run
|
||||
* records what it paid, not whether the account it named was real.
|
||||
*/
|
||||
public class AgentSavingsAccountNotFoundException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentSavingsAccountNotFoundException(final String accountNo) {
|
||||
super("error.msg.dds.agent.savings.account.not.found",
|
||||
"No open savings account has the number " + accountNo + ". Pick one of the client's own accounts.", accountNo);
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent's commission account is a fixed or recurring deposit rather than a savings
|
||||
* account.
|
||||
*
|
||||
* Commission is paid in as a credit whenever a commission run posts. A savings account takes credits
|
||||
* at any time; a fixed deposit is a single sum placed for a term, and a recurring deposit takes only
|
||||
* its own scheduled instalments - so paying into either is not a thing a bank does, and the posting
|
||||
* would fail when the run came round. Refusing it as the account is chosen puts the error in front
|
||||
* of the officer who can fix it, rather than in a batch log weeks later.
|
||||
*/
|
||||
public class AgentSavingsAccountNotOperativeException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public AgentSavingsAccountNotOperativeException(final String accountNo, final String accountKind) {
|
||||
super("error.msg.dds.agent.savings.account.not.operative",
|
||||
"Account " + accountNo + " is a " + accountKind + " account. Commission has to be paid into an ordinary savings "
|
||||
+ "account, so pick one of the client's own - not a deposit and not a DDS scheme account.",
|
||||
accountNo, accountKind);
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.commissionslab.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public final class CommissionSlabData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final String slabType;
|
||||
private final BigDecimal slabFrom;
|
||||
private final BigDecimal slabTo;
|
||||
private final BigDecimal commissionPercent;
|
||||
}
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.commissionslab.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* One row of the agent commission chart: an ACCOUNT slab (commission percentage on collection
|
||||
* volume) or a COMMISSION slab (the override a senior agent earns on a sub-agent's commission).
|
||||
* Reference data, not history: a slab says what an agent earns on a given collection volume, and the
|
||||
* commission run reads whatever the chart says at the time it runs. A mistyped range or percentage
|
||||
* therefore has to be correctable - the legacy screen offered an edit on every saved row - so a slab
|
||||
* can be updated. It still cannot be deleted: removing a range silently pays nothing to any agent who
|
||||
* lands in it, which reads as a system fault rather than a decision.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_commission_slab")
|
||||
public class CommissionSlab extends AbstractAuditableCustom {
|
||||
|
||||
@Column(name = "slab_type", length = 20, nullable = false)
|
||||
private String slabType;
|
||||
|
||||
@Column(name = "slab_from", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal slabFrom;
|
||||
|
||||
@Column(name = "slab_to", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal slabTo;
|
||||
|
||||
@Column(name = "commission_percent", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal commissionPercent;
|
||||
|
||||
protected CommissionSlab() {
|
||||
//
|
||||
}
|
||||
|
||||
public CommissionSlab(final String slabType, final BigDecimal slabFrom, final BigDecimal slabTo, final BigDecimal commissionPercent) {
|
||||
this.slabType = slabType;
|
||||
this.slabFrom = slabFrom;
|
||||
this.slabTo = slabTo;
|
||||
this.commissionPercent = commissionPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects this slab, reporting only what actually changed so the audit trail records the
|
||||
* correction rather than a wholesale rewrite.
|
||||
*/
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
|
||||
|
||||
if (command.isChangeInStringParameterNamed("slabType", this.slabType)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("slabType");
|
||||
actualChanges.put("slabType", newValue);
|
||||
this.slabType = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("slabFrom", this.slabFrom)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("slabFrom");
|
||||
actualChanges.put("slabFrom", newValue);
|
||||
this.slabFrom = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("slabTo", this.slabTo)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("slabTo");
|
||||
actualChanges.put("slabTo", newValue);
|
||||
this.slabTo = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("commissionPercent", this.commissionPercent)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("commissionPercent");
|
||||
actualChanges.put("commissionPercent", newValue);
|
||||
this.commissionPercent = newValue;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.commissionslab.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
/**
|
||||
* Thrown when a commission slab being corrected no longer exists.
|
||||
*/
|
||||
public class CommissionSlabNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public CommissionSlabNotFoundException(final Long slabId) {
|
||||
super("error.msg.commissionslab.id.invalid", "Commission slab with identifier " + slabId + " does not exist", slabId);
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.commissionslab.exception;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a commission slab's range overlaps one that already covers part of it.
|
||||
*
|
||||
* The commission run picks the first slab whose range contains the agent's collection volume, so
|
||||
* two slabs covering the same figure make the rate that gets applied a matter of which row the
|
||||
* database returns first. The same agent could be paid a different percentage from one month to the
|
||||
* next with nothing in the data to explain it, which is why this is refused rather than warned
|
||||
* about.
|
||||
*/
|
||||
public class OverlappingCommissionSlabException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public OverlappingCommissionSlabException(final String slabType, final BigDecimal from, final BigDecimal to,
|
||||
final BigDecimal existingFrom, final BigDecimal existingTo) {
|
||||
super("error.msg.commissionslab.overlapping",
|
||||
slabType + " slab " + from.toPlainString() + " to " + to.toPlainString() + " overlaps the existing slab "
|
||||
+ existingFrom.toPlainString() + " to " + existingTo.toPlainString(),
|
||||
slabType, from, to, existingFrom, existingTo);
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.command.core.CommandPipeline;
|
||||
import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData;
|
||||
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
|
||||
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
|
||||
import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -44,7 +45,8 @@ import org.springframework.stereotype.Component;
|
||||
public class CurrenciesApiResource {
|
||||
|
||||
private final OrganisationCurrencyReadPlatformService readPlatformService;
|
||||
private final CommandPipeline commandPipeline;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final DefaultToApiJsonSerializer<CurrencyUpdateRequest> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -65,13 +67,12 @@ public class CurrenciesApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.")
|
||||
public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) {
|
||||
final var command = new CurrencyUpdateCommand();
|
||||
public String updateCurrencies(@Valid final CurrencyUpdateRequest request) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrency()
|
||||
.withJson(toApiJsonSerializer.serialize(request)).build();
|
||||
|
||||
command.setPayload(request);
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
|
||||
final Supplier<CurrencyUpdateResponse> response = commandPipeline.send(command);
|
||||
|
||||
return response.get();
|
||||
return toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-7
@@ -16,31 +16,48 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.dds.batch.handler;
|
||||
package org.apache.fineract.organisation.monetary.handler;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest;
|
||||
import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse;
|
||||
import org.apache.fineract.organisation.monetary.service.CurrencyWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "DDSBATCH", action = "POST")
|
||||
public class PostBatchRunCommandHandler implements NewCommandSourceHandler {
|
||||
@CommandType(entity = "CURRENCY", action = "UPDATE")
|
||||
public class UpdateCurrencyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final BatchRunWritePlatformService writePlatformService;
|
||||
private final CurrencyWritePlatformService writePlatformService;
|
||||
|
||||
@Autowired
|
||||
public PostBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
|
||||
public UpdateCurrencyCommandHandler(final CurrencyWritePlatformService writePlatformService) {
|
||||
this.writePlatformService = writePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
return this.writePlatformService.postBatchRun(command.entityId(), command);
|
||||
|
||||
final String[] currencyCodes = command.arrayValueOfParameterNamed("currencies");
|
||||
final List<String> currencies = currencyCodes == null ? List.of() : Arrays.asList(currencyCodes);
|
||||
|
||||
final CurrencyUpdateRequest request = CurrencyUpdateRequest.builder().currencies(currencies).build();
|
||||
|
||||
final CurrencyUpdateResponse response = this.writePlatformService.updateAllowedCurrencies(request);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.with(Map.of("currencies", response.getCurrencies())) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -24,7 +24,10 @@ public enum PortfolioProductType {
|
||||
SAVING(2, "productType.saving"), //
|
||||
CLIENT(5, "productType.client"), //
|
||||
PROVISIONING(3, "productType.provisioning"), //
|
||||
SHARES(4, "productType.shares"); //
|
||||
SHARES(4, "productType.shares"), //
|
||||
// Current products map their GLs under their own type: sharing SAVING with savings products let a savings
|
||||
// product and a current product with the same id read each other's GL rows.
|
||||
CURRENT(6, "productType.current"); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
@@ -52,17 +55,25 @@ public enum PortfolioProductType {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Matched against each constant's own declared value above, not its declaration position --
|
||||
// those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written
|
||||
// by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING)
|
||||
// returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES,
|
||||
// and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry
|
||||
// (entity_type_enum=6) reached this on the Journal Entries screen.
|
||||
switch (v) {
|
||||
case 1:
|
||||
return LOAN;
|
||||
case 2:
|
||||
return SAVING;
|
||||
case 3:
|
||||
return CLIENT;
|
||||
case 4:
|
||||
return PROVISIONING;
|
||||
case 5:
|
||||
case 4:
|
||||
return SHARES;
|
||||
case 5:
|
||||
return CLIENT;
|
||||
case 6:
|
||||
return CURRENT;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 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.dds.accounttype.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Immutable data object representing a DDS Account Type (the legacy Account Type Creation record).
|
||||
*/
|
||||
@Getter
|
||||
@Builder(toBuilder = true)
|
||||
public final class DdsAccountTypeData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final String accountTypeGroup;
|
||||
private final String accountTypeCode;
|
||||
private final String accountTypeDescription;
|
||||
private final Boolean autoInterestPost;
|
||||
private final String autoInterestPostWhen;
|
||||
private final Integer autoInterestPostDay;
|
||||
/** Whether an account on this scheme closes itself on its maturity date. */
|
||||
private final Boolean maturityAutoClose;
|
||||
/**
|
||||
* What this scheme charges for closing an account before its term is up: how long it may not be
|
||||
* closed at all, what comes off the customer's rate if they bear the cost, and what comes off the
|
||||
* agent's commission either way. Read at the moment of closure, so a change on the scheme takes
|
||||
* effect on accounts already open.
|
||||
*/
|
||||
private final Integer prematureLockinMonths;
|
||||
private final BigDecimal prematureInterestReduction;
|
||||
private final BigDecimal prematureAgentCommissionPenalty;
|
||||
/** The scheme's annual interest rate, which an account opened on it takes. */
|
||||
private final BigDecimal interestRate;
|
||||
private final Boolean active;
|
||||
private final LocalDateTime createdDate;
|
||||
private final LocalDateTime lastModifiedDate;
|
||||
}
|
||||
-267
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* 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.dds.accounttype.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* A DDS scheme variant, as registered on the legacy "Account Type Creation" screen: a group (e.g.
|
||||
* "DDS"), a short code (e.g. "DS"), a description (e.g. "DAILY DEPOSIT"), and whether interest is
|
||||
* posted automatically and how often.
|
||||
*
|
||||
* This is master/reference data, not a deposit product - a Party picks one of these by code in its
|
||||
* "Sub Type" field. Nothing here drives interest calculation: the DDS batch job accrues interest
|
||||
* straight off each Party's own rate and balance.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_account_type", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "account_type_code" }, name = "uq_dds_account_type_code") })
|
||||
public class DdsAccountType extends AbstractAuditableCustom {
|
||||
|
||||
@Column(name = "account_type_group", length = 60, nullable = false)
|
||||
private String accountTypeGroup;
|
||||
|
||||
@Column(name = "account_type_code", length = 20, nullable = false)
|
||||
private String accountTypeCode;
|
||||
|
||||
@Column(name = "account_type_description", length = 200, nullable = false)
|
||||
private String accountTypeDescription;
|
||||
|
||||
@Column(name = "auto_interest_post", nullable = false)
|
||||
private boolean autoInterestPost;
|
||||
|
||||
@Column(name = "auto_interest_post_when", length = 20)
|
||||
private String autoInterestPostWhen;
|
||||
|
||||
/** Day of the month interest posts on. Only meaningful when {@code autoInterestPost} is set. */
|
||||
@Column(name = "auto_interest_post_day")
|
||||
private Integer autoInterestPostDay;
|
||||
|
||||
/**
|
||||
* Whether an account on this scheme closes itself the day it matures, instead of waiting for a
|
||||
* clerk to pay it out by hand.
|
||||
*
|
||||
* Kept on the scheme rather than switched on bank-wide, for the same reason interest posting is:
|
||||
* a bank runs several schemes at once and will not want them all behaving the same way. It says
|
||||
* only whether the closing should happen, never when - the batch run decides that, exactly as it
|
||||
* does for {@code autoInterestPost}.
|
||||
*
|
||||
* A scheme with this set still only closes an account that has somewhere for the money to go. If
|
||||
* the account names no savings account to be paid into, nothing happens automatically and the
|
||||
* clerk pays it out as before: money is never sent somewhere nobody chose.
|
||||
*/
|
||||
@Column(name = "maturity_auto_close", nullable = false)
|
||||
private boolean maturityAutoClose;
|
||||
|
||||
/**
|
||||
* How long an account on this scheme must run before it may be closed early at all. Below this
|
||||
* the request is refused outright rather than charged for.
|
||||
*
|
||||
* Per-scheme rather than one bank-wide figure: a lock-in longer than the scheme's own term would
|
||||
* make early closure unreachable, so a two-month scheme and a five-year one cannot share one.
|
||||
*/
|
||||
@Column(name = "premature_lockin_months", nullable = false)
|
||||
private Integer prematureLockinMonths;
|
||||
|
||||
/**
|
||||
* Percentage points off the customer's interest rate when the customer bears the cost of closing
|
||||
* early - a 2% scheme pays 1% over the whole period, not only the part left to run.
|
||||
*/
|
||||
@Column(name = "premature_interest_reduction", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal prematureInterestReduction;
|
||||
|
||||
/**
|
||||
* Percentage off the collecting agent's commission on an early closure, applied whichever way the
|
||||
* shortfall is met - an account closed early is a round the agent did not keep going.
|
||||
*/
|
||||
@Column(name = "premature_agent_commission_penalty", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal prematureAgentCommissionPenalty;
|
||||
|
||||
/**
|
||||
* The scheme's annual interest rate. Mandatory: an account opened on the scheme takes this rate,
|
||||
* rather than one typed at the counter for each customer.
|
||||
*/
|
||||
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal interestRate;
|
||||
|
||||
/**
|
||||
* An account type is retired by deactivating it, never by deleting: Parties are opened against
|
||||
* it, so removing the row would orphan every account already using it. Inactive types stay out
|
||||
* of the pick lists for new accounts but leave existing accounts untouched.
|
||||
*/
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
protected DdsAccountType() {
|
||||
//
|
||||
}
|
||||
|
||||
private DdsAccountType(final JsonCommand command) {
|
||||
this.accountTypeGroup = command.stringValueOfParameterNamed("accountTypeGroup");
|
||||
this.accountTypeCode = normaliseCode(command.stringValueOfParameterNamed("accountTypeCode"));
|
||||
this.accountTypeDescription = command.stringValueOfParameterNamed("accountTypeDescription");
|
||||
this.autoInterestPost = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
|
||||
this.autoInterestPostWhen = StringUtils
|
||||
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("autoInterestPostWhen"), null);
|
||||
this.autoInterestPostDay = this.autoInterestPost
|
||||
? command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay")
|
||||
: null;
|
||||
this.maturityAutoClose = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||
this.prematureLockinMonths = valueOr(command, "prematureLockinMonths", DEFAULT_LOCKIN_MONTHS);
|
||||
this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION);
|
||||
this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY);
|
||||
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||
// A newly created account type is available for use straight away.
|
||||
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
}
|
||||
|
||||
/**
|
||||
* The bank's stated terms, used when a scheme is created without saying otherwise. They are
|
||||
* defaults rather than fixed rules - the screen offers them and the bank changes them per scheme.
|
||||
*/
|
||||
private static final int DEFAULT_LOCKIN_MONTHS = 12;
|
||||
private static final BigDecimal DEFAULT_INTEREST_REDUCTION = BigDecimal.ONE;
|
||||
private static final BigDecimal DEFAULT_AGENT_PENALTY = BigDecimal.ONE;
|
||||
|
||||
private static Integer valueOr(final JsonCommand command, final String parameter, final int fallback) {
|
||||
final Integer supplied = command.parameterExists(parameter)
|
||||
? command.integerValueOfParameterNamedDefaultToNullIfZero(parameter)
|
||||
: null;
|
||||
return supplied == null ? fallback : supplied;
|
||||
}
|
||||
|
||||
private static BigDecimal valueOr(final JsonCommand command, final String parameter, final BigDecimal fallback) {
|
||||
// Deliberately not "default to null if zero": zero is a real answer here - a scheme that
|
||||
// charges the customer nothing for closing early - and must not be read as "unset".
|
||||
final BigDecimal supplied = command.parameterExists(parameter)
|
||||
? command.bigDecimalValueOfParameterNamed(parameter)
|
||||
: null;
|
||||
return supplied == null ? fallback : supplied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes are the account type's identity and are matched against elsewhere, so "ds" and "DS" must
|
||||
* not become two different types - they are stored upper-cased and trimmed.
|
||||
*/
|
||||
private static String normaliseCode(final String code) {
|
||||
return code == null ? null : code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
public static DdsAccountType fromJson(final JsonCommand command) {
|
||||
return new DdsAccountType(command);
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(6);
|
||||
|
||||
if (command.isChangeInStringParameterNamed("accountTypeGroup", this.accountTypeGroup)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("accountTypeGroup");
|
||||
actualChanges.put("accountTypeGroup", newValue);
|
||||
this.accountTypeGroup = newValue;
|
||||
}
|
||||
|
||||
final String incomingCode = normaliseCode(command.stringValueOfParameterNamedAllowingNull("accountTypeCode"));
|
||||
if (incomingCode != null && !incomingCode.equals(this.accountTypeCode)) {
|
||||
actualChanges.put("accountTypeCode", incomingCode);
|
||||
this.accountTypeCode = incomingCode;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("accountTypeDescription", this.accountTypeDescription)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("accountTypeDescription");
|
||||
actualChanges.put("accountTypeDescription", newValue);
|
||||
this.accountTypeDescription = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("autoInterestPost", this.autoInterestPost)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
|
||||
actualChanges.put("autoInterestPost", newValue);
|
||||
this.autoInterestPost = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed("autoInterestPostWhen", this.autoInterestPostWhen)) {
|
||||
final String newValue = command.stringValueOfParameterNamed("autoInterestPostWhen");
|
||||
actualChanges.put("autoInterestPostWhen", newValue);
|
||||
this.autoInterestPostWhen = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("autoInterestPostDay", this.autoInterestPostDay)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay");
|
||||
actualChanges.put("autoInterestPostDay", newValue);
|
||||
this.autoInterestPostDay = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("maturityAutoClose", this.maturityAutoClose)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||
actualChanges.put("maturityAutoClose", newValue);
|
||||
this.maturityAutoClose = newValue;
|
||||
}
|
||||
|
||||
// A posting day only means anything while auto-posting is on.
|
||||
if (!this.autoInterestPost) {
|
||||
this.autoInterestPostDay = null;
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("prematureLockinMonths", this.prematureLockinMonths)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("prematureLockinMonths");
|
||||
actualChanges.put("prematureLockinMonths", newValue);
|
||||
// Zero lock-in is meaningful - it lets an account be closed early from day one - but the
|
||||
// column cannot hold null, so it is kept as zero rather than reset to the default.
|
||||
this.prematureLockinMonths = newValue == null ? 0 : newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("prematureInterestReduction", this.prematureInterestReduction)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("prematureInterestReduction");
|
||||
actualChanges.put("prematureInterestReduction", newValue);
|
||||
this.prematureInterestReduction = newValue == null ? BigDecimal.ZERO : newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("prematureAgentCommissionPenalty", this.prematureAgentCommissionPenalty)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("prematureAgentCommissionPenalty");
|
||||
actualChanges.put("prematureAgentCommissionPenalty", newValue);
|
||||
this.prematureAgentCommissionPenalty = newValue == null ? BigDecimal.ZERO : newValue;
|
||||
}
|
||||
|
||||
// The scheme's rate - every open account on it follows it (see the account type service).
|
||||
if (command.parameterExists("interestRate") && command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||
actualChanges.put("interestRate", newValue);
|
||||
this.interestRate = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
actualChanges.put("active", newValue);
|
||||
this.active = newValue;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 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.dds.accounttype.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
|
||||
/**
|
||||
* A {@link RuntimeException} thrown when a DDS account type resource is not found.
|
||||
*/
|
||||
public class DdsAccountTypeNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public DdsAccountTypeNotFoundException(final Long id) {
|
||||
super("error.msg.dds.account.type.id.invalid", "DDS account type with identifier " + id + " does not exist", id);
|
||||
}
|
||||
|
||||
public DdsAccountTypeNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||
super("error.msg.dds.account.type.id.invalid", "DDS account type with identifier " + id + " does not exist", id, e);
|
||||
}
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 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.dds.batch.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder(toBuilder = true)
|
||||
public final class BatchRunData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final String jobType;
|
||||
private final String branchScope;
|
||||
|
||||
/** Which DDS scheme the run covered, or null for every scheme. Set by the automatic posting job. */
|
||||
private final String subType;
|
||||
|
||||
private final LocalDate jobDate;
|
||||
private final Long agentFrom;
|
||||
private final Long agentTo;
|
||||
private final Long partyFrom;
|
||||
private final Long partyTo;
|
||||
private final String status;
|
||||
private final Integer totalRows;
|
||||
/** How many rows a crediting run has applied. Equal to totalRows once it has finished. */
|
||||
private final Integer rowsPosted;
|
||||
/** Why a run stopped, when it did - a crediting run happens in the background and has no other voice. */
|
||||
private final String failureReason;
|
||||
private final BigDecimal totalAmount;
|
||||
private final LocalDate generatedOnDate;
|
||||
private final LocalDate postedOnDate;
|
||||
private final Collection<BatchRunRowData> rows;
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* 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.dds.batch.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public final class BatchRunRowData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final Long partyId;
|
||||
private final String partyCode;
|
||||
private final String partyName;
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
|
||||
/** What the row applies: interest, or commission net of every deduction. */
|
||||
private final BigDecimal amount;
|
||||
|
||||
/**
|
||||
* How that amount was arrived at. Null on rows where it does not apply - a posting row moves a
|
||||
* figure an earlier calculation run worked out, so the rate and base belong to that run.
|
||||
*/
|
||||
private final BigDecimal baseAmount;
|
||||
private final BigDecimal ratePercent;
|
||||
private final BigDecimal grossAmount;
|
||||
private final BigDecimal tdsAmount;
|
||||
private final BigDecimal deductionAmount;
|
||||
private final LocalDate fromDate;
|
||||
private final LocalDate toDate;
|
||||
}
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* 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.dds.batch.domain;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
|
||||
* menu (Interest Calculation -> Interest Posting, OD Interest Calculation -> OD Interest Posting,
|
||||
* Commission Calculation -> Commission Posting): a run is generated (rows computed and saved as a
|
||||
* preview, nothing else changes), then posted (a separate, explicit step that applies exactly the
|
||||
* rows that were previewed - never a fresh recalculation, so what gets applied is always what was
|
||||
* seen).
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_batch_run")
|
||||
public class BatchRun extends AbstractAuditableCustom {
|
||||
|
||||
@Column(name = "job_type", length = 40, nullable = false)
|
||||
private String jobType;
|
||||
|
||||
@Column(name = "branch_scope", length = 20, nullable = false)
|
||||
private String branchScope;
|
||||
|
||||
/**
|
||||
* Which DDS scheme this run covered, or null for every scheme.
|
||||
*
|
||||
* A posting day belongs to an account type rather than to the bank, so a run started by the
|
||||
* scheduled job covers one scheme at a time - otherwise the scheme due on the 15th would drag the
|
||||
* month-end scheme's accounts along with it. A run started by hand from the screen leaves this
|
||||
* null and covers everything, as it always did.
|
||||
*/
|
||||
@Column(name = "sub_type", length = 60)
|
||||
private String subType;
|
||||
|
||||
@Column(name = "job_date", nullable = false)
|
||||
private LocalDate jobDate;
|
||||
|
||||
@Column(name = "agent_from", nullable = false)
|
||||
private Long agentFrom;
|
||||
|
||||
@Column(name = "agent_to", nullable = false)
|
||||
private Long agentTo;
|
||||
|
||||
@Column(name = "party_from")
|
||||
private Long partyFrom;
|
||||
|
||||
@Column(name = "party_to")
|
||||
private Long partyTo;
|
||||
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private String status;
|
||||
|
||||
@Column(name = "total_rows", nullable = false)
|
||||
private Integer totalRows;
|
||||
|
||||
/** How many of those rows have been credited so far - what a screen reads to show progress. */
|
||||
@Column(name = "rows_posted", nullable = false)
|
||||
private Integer rowsPosted = 0;
|
||||
|
||||
/** Why a run stopped, when it did. A background run has nowhere else to say so. */
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
|
||||
@Column(name = "total_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
@Column(name = "generated_on_date", nullable = false)
|
||||
private LocalDate generatedOnDate;
|
||||
|
||||
@Column(name = "posted_on_date")
|
||||
private LocalDate postedOnDate;
|
||||
|
||||
@OneToMany(mappedBy = "batchRun", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("id")
|
||||
private List<BatchRunRow> rows = new ArrayList<>();
|
||||
|
||||
protected BatchRun() {
|
||||
//
|
||||
}
|
||||
|
||||
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
|
||||
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate) {
|
||||
this(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, generatedOnDate, null);
|
||||
}
|
||||
|
||||
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
|
||||
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate, final String subType) {
|
||||
this.jobType = jobType;
|
||||
this.branchScope = branchScope;
|
||||
this.subType = subType;
|
||||
this.jobDate = jobDate;
|
||||
this.agentFrom = agentFrom;
|
||||
this.agentTo = agentTo;
|
||||
this.partyFrom = partyFrom;
|
||||
this.partyTo = partyTo;
|
||||
this.status = "CALCULATED";
|
||||
this.totalRows = 0;
|
||||
this.totalAmount = BigDecimal.ZERO;
|
||||
this.generatedOnDate = generatedOnDate;
|
||||
}
|
||||
|
||||
public void addRow(final BatchRunRow row) {
|
||||
row.setBatchRun(this);
|
||||
this.rows.add(row);
|
||||
this.totalRows = this.rows.size();
|
||||
this.totalAmount = this.totalAmount.add(row.getAmount());
|
||||
}
|
||||
|
||||
public boolean isPosted() {
|
||||
return STATUS_POSTED.equals(this.status);
|
||||
}
|
||||
|
||||
/** Under way. Set before the work starts, so a second press finds it already running. */
|
||||
public boolean isPosting() {
|
||||
return STATUS_POSTING.equals(this.status);
|
||||
}
|
||||
|
||||
public void markPosting() {
|
||||
this.status = STATUS_POSTING;
|
||||
this.rowsPosted = 0;
|
||||
this.failureReason = null;
|
||||
}
|
||||
|
||||
public void recordProgress(final int posted) {
|
||||
this.rowsPosted = posted;
|
||||
}
|
||||
|
||||
public void markPosted(final LocalDate postedOnDate) {
|
||||
this.status = STATUS_POSTED;
|
||||
this.postedOnDate = postedOnDate;
|
||||
this.rowsPosted = this.totalRows;
|
||||
this.failureReason = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records why a run stopped, and leaves it able to be run again.
|
||||
*
|
||||
* Nothing it had credited stands: the whole run is one transaction, so a failure takes all of it
|
||||
* back. That is the point - the branch is left with a run that plainly did not happen rather than
|
||||
* one it has to reconcile by hand.
|
||||
*/
|
||||
public void markFailed(final String reason) {
|
||||
this.status = STATUS_FAILED;
|
||||
this.failureReason = reason == null ? "Unknown error" : reason.substring(0, Math.min(reason.length(), 500));
|
||||
}
|
||||
|
||||
private static final String STATUS_POSTED = "POSTED";
|
||||
private static final String STATUS_POSTING = "POSTING";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
}
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* 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.dds.batch.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
/**
|
||||
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's interest; for a
|
||||
* commission job, one Agent's commission on their collected volume (partyId is null there).
|
||||
*
|
||||
* {@code amount} is always what actually gets applied. The remaining figures record how that amount
|
||||
* was arrived at, which is what the Agent Commission Statement prints and what an agent querying a
|
||||
* payout needs to see. They are stored rather than recomputed on demand because the inputs move: the
|
||||
* slab chart can be corrected and the standing deductions change, so a later recomputation would not
|
||||
* reproduce the figure that was actually paid.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_batch_run_row")
|
||||
public class BatchRunRow extends AbstractPersistableCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "batch_run_id", nullable = false)
|
||||
private BatchRun batchRun;
|
||||
|
||||
@Column(name = "party_id")
|
||||
private Long partyId;
|
||||
|
||||
@Column(name = "agent_id", nullable = false)
|
||||
private Long agentId;
|
||||
|
||||
/** What this row applies: interest accrued or posted, or commission net of every deduction. */
|
||||
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal amount;
|
||||
|
||||
/**
|
||||
* What the rate was applied to: the collection volume for commission, or the daily product
|
||||
* (balance summed over the days it was held) for interest.
|
||||
*/
|
||||
@Column(name = "base_amount", scale = 6, precision = 19)
|
||||
private BigDecimal baseAmount;
|
||||
|
||||
@Column(name = "rate_percent", scale = 6, precision = 19)
|
||||
private BigDecimal ratePercent;
|
||||
|
||||
/** Before deductions. Equal to {@code amount} on rows that have none. */
|
||||
@Column(name = "gross_amount", scale = 6, precision = 19)
|
||||
private BigDecimal grossAmount;
|
||||
|
||||
@Column(name = "tds_amount", scale = 6, precision = 19)
|
||||
private BigDecimal tdsAmount;
|
||||
|
||||
@Column(name = "deduction_amount", scale = 6, precision = 19)
|
||||
private BigDecimal deductionAmount;
|
||||
|
||||
@Column(name = "from_date")
|
||||
private LocalDate fromDate;
|
||||
|
||||
@Column(name = "to_date")
|
||||
private LocalDate toDate;
|
||||
|
||||
protected BatchRunRow() {
|
||||
//
|
||||
}
|
||||
|
||||
private BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount, final BigDecimal baseAmount,
|
||||
final BigDecimal ratePercent, final BigDecimal grossAmount, final BigDecimal tdsAmount, final BigDecimal deductionAmount,
|
||||
final LocalDate fromDate, final LocalDate toDate) {
|
||||
this.partyId = partyId;
|
||||
this.agentId = agentId;
|
||||
this.amount = amount;
|
||||
this.baseAmount = baseAmount;
|
||||
this.ratePercent = ratePercent;
|
||||
this.grossAmount = grossAmount;
|
||||
this.tdsAmount = tdsAmount;
|
||||
this.deductionAmount = deductionAmount;
|
||||
this.fromDate = fromDate;
|
||||
this.toDate = toDate;
|
||||
}
|
||||
|
||||
/** Interest earned on a Party's balance over the days it was held. Nothing is deducted from it. */
|
||||
public static BatchRunRow forInterest(final Long partyId, final Long agentId, final BigDecimal amount,
|
||||
final BigDecimal dailyProduct, final BigDecimal ratePercent, final LocalDate fromDate, final LocalDate toDate) {
|
||||
return new BatchRunRow(partyId, agentId, amount, dailyProduct, ratePercent, amount, null, null, fromDate, toDate);
|
||||
}
|
||||
|
||||
/** An Agent's commission on their collection volume, net of TDS and the standing deductions. */
|
||||
public static BatchRunRow forCommission(final Long agentId, final BigDecimal netAmount, final BigDecimal collectionVolume,
|
||||
final BigDecimal ratePercent, final BigDecimal grossAmount, final BigDecimal tdsAmount, final BigDecimal deductionAmount,
|
||||
final LocalDate fromDate, final LocalDate toDate) {
|
||||
return new BatchRunRow(null, agentId, netAmount, collectionVolume, ratePercent, grossAmount, tdsAmount, deductionAmount,
|
||||
fromDate, toDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* A posting row: it moves an amount that a prior calculation run already worked out, so there is
|
||||
* no rate or base to record against it - the run that calculated it holds those.
|
||||
*/
|
||||
public static BatchRunRow forPosting(final Long partyId, final Long agentId, final BigDecimal amount, final LocalDate asOnDate) {
|
||||
return new BatchRunRow(partyId, agentId, amount, null, null, amount, null, null, asOnDate, asOnDate);
|
||||
}
|
||||
|
||||
void setBatchRun(final BatchRun batchRun) {
|
||||
this.batchRun = batchRun;
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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.dds.batch.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when Post is attempted on a batch run that has already been posted - a run applies once,
|
||||
* matching the calculate-then-post safety control (never re-post the same preview).
|
||||
*/
|
||||
public class BatchRunAlreadyPostedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public BatchRunAlreadyPostedException(final Long batchRunId) {
|
||||
super("error.msg.ddsbatchrun.already.posted", "DDS batch run " + batchRunId + " has already been posted", batchRunId);
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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.dds.batch.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
public class BatchRunNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public BatchRunNotFoundException(final Long id) {
|
||||
super("error.msg.ddsbatchrun.id.invalid", "DDS batch run with identifier " + id + " does not exist", id);
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 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.dds.batch.exception;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an agent's collection volume falls in a gap in the commission chart, so there is no rate
|
||||
* to pay them at.
|
||||
*
|
||||
* The run stops rather than skipping the agent. A gap is a mistake in the chart, and an agent who
|
||||
* worked all month reading as earning nothing looks exactly like an agent who collected nothing - the
|
||||
* one failure nobody would query until the agent complained. Naming the agent and the amount that
|
||||
* found no slab points straight at the range that needs adding.
|
||||
*/
|
||||
public class NoCommissionSlabException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public NoCommissionSlabException(final String agentCode, final BigDecimal collectionVolume) {
|
||||
super("error.msg.ddsbatchrun.no.commission.slab",
|
||||
"No commission slab covers " + collectionVolume.toPlainString() + ", the collection volume for agent " + agentCode
|
||||
+ ". Add a slab covering this amount to the Commission Slab Master and run the calculation again.",
|
||||
agentCode, collectionVolume);
|
||||
}
|
||||
}
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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.dds.calculation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* What a collection agent has earned on a period's collections, and what is left after deductions.
|
||||
*
|
||||
* <pre>
|
||||
* gross the slab's percentage of everything collected
|
||||
* tds withheld from the gross, when the agent is liable for it
|
||||
* deductions the four standing amounts the bank recovers from them
|
||||
* net what the agent is actually paid
|
||||
* </pre>
|
||||
*
|
||||
* Every figure is kept, not just the net: an agent asking why their commission was short needs the
|
||||
* whole breakdown, and a later change to the slab must not alter what was already paid.
|
||||
*
|
||||
* <p>
|
||||
* Kept apart from the job that runs it so the sum can be checked on its own.
|
||||
*/
|
||||
public record DdsCommissionCalculation(BigDecimal collectionVolume, BigDecimal ratePercent, BigDecimal gross, BigDecimal tds,
|
||||
BigDecimal deductions, BigDecimal net) {
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final int MONEY_SCALE = 2;
|
||||
|
||||
/**
|
||||
* Works out a period's commission.
|
||||
*
|
||||
* @param collectionVolume
|
||||
* everything the agent collected in the period.
|
||||
* @param ratePercent
|
||||
* the matching slab's percentage.
|
||||
* @param tdsApplicable
|
||||
* whether tax is withheld from this agent at all.
|
||||
* @param tdsPercent
|
||||
* the rate withheld when it is.
|
||||
* @param deductions
|
||||
* the standing amounts the bank recovers - see {@code Agent.totalDeductions()}.
|
||||
*/
|
||||
public static DdsCommissionCalculation of(final BigDecimal collectionVolume, final BigDecimal ratePercent,
|
||||
final boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal deductions) {
|
||||
|
||||
final BigDecimal volume = collectionVolume == null ? BigDecimal.ZERO : collectionVolume;
|
||||
final BigDecimal rate = ratePercent == null ? BigDecimal.ZERO : ratePercent;
|
||||
final BigDecimal recovered = deductions == null ? BigDecimal.ZERO : deductions;
|
||||
|
||||
final BigDecimal gross = volume.multiply(rate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
final BigDecimal withheld = tdsApplicable && tdsPercent != null
|
||||
? gross.multiply(tdsPercent).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
return new DdsCommissionCalculation(volume, rate, gross, withheld, recovered, gross.subtract(withheld).subtract(recovered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is anything to pay.
|
||||
*
|
||||
* The deductions can swallow a period's commission entirely. Nothing is owed then, and the agent
|
||||
* is not put into debt over it - the deductions are standing figures the bank adjusts, not a
|
||||
* balance a commission run is entitled to carry forward.
|
||||
*/
|
||||
public boolean isPayable() {
|
||||
return this.net.compareTo(BigDecimal.ZERO) > 0;
|
||||
}
|
||||
}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* 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.dds.calculation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What a DDS account has earned over a period.
|
||||
*
|
||||
* Interest is on the <em>daily product</em>: the balance summed over the days it was actually held.
|
||||
* A customer paying in daily holds each instalment for a different length of time, so interest on
|
||||
* the closing balance for the whole period would pay them for money they had not yet deposited -
|
||||
* roughly double what they are owed, which is what this replaced.
|
||||
*
|
||||
* This is the same basis {@code Party.calculateMaturityAmount()} quotes at opening, so what the
|
||||
* interest runs add up to over a term matches the figure the customer was promised.
|
||||
*
|
||||
* <p>
|
||||
* Kept apart from the job that runs it so the sum can be checked on its own. It reads nothing and
|
||||
* writes nothing - given the same movements it always answers the same, which is what makes it
|
||||
* worth testing.
|
||||
*/
|
||||
public final class DdsInterestCalculation {
|
||||
|
||||
/** Wide enough that the intermediate division never loses precision before the final rounding. */
|
||||
private static final MathContext MATH_CONTEXT = new MathContext(19, RoundingMode.HALF_UP);
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
|
||||
private static final int MONEY_SCALE = 2;
|
||||
|
||||
private DdsInterestCalculation() {}
|
||||
|
||||
/**
|
||||
* One movement on the account: a collection is positive, a payout negative.
|
||||
*
|
||||
* A plain pair rather than the transaction itself, so this sum has no opinion about how
|
||||
* transactions are stored and can be checked with a handful of literals.
|
||||
*/
|
||||
public record Movement(LocalDate date, BigDecimal signedAmount) {}
|
||||
|
||||
/**
|
||||
* The balance summed over the days it was held, between {@code from} (exclusive) and {@code to}
|
||||
* (inclusive).
|
||||
*
|
||||
* @param openingBalance
|
||||
* what the account held before any of these movements.
|
||||
* @param movements
|
||||
* every movement up to the end of the period, oldest first. Anything dated on or
|
||||
* before {@code from} has already happened and makes up the balance this period opens
|
||||
* with rather than a movement within it.
|
||||
*/
|
||||
public static BigDecimal dailyProduct(final BigDecimal openingBalance, final List<Movement> movements, final LocalDate from,
|
||||
final LocalDate to) {
|
||||
|
||||
BigDecimal balance = openingBalance == null ? BigDecimal.ZERO : openingBalance;
|
||||
int index = 0;
|
||||
while (index < movements.size() && !movements.get(index).date().isAfter(from)) {
|
||||
balance = balance.add(movements.get(index).signedAmount());
|
||||
index++;
|
||||
}
|
||||
|
||||
BigDecimal product = BigDecimal.ZERO;
|
||||
LocalDate cursor = from;
|
||||
|
||||
while (index < movements.size()) {
|
||||
final LocalDate movementDate = movements.get(index).date();
|
||||
if (movementDate.isAfter(to)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// The untouched days between the last movement and this one, at the balance they stood at.
|
||||
final long daysAtOpeningBalance = ChronoUnit.DAYS.between(cursor, movementDate) - 1;
|
||||
if (daysAtOpeningBalance > 0) {
|
||||
product = product.add(balance.multiply(BigDecimal.valueOf(daysAtOpeningBalance)));
|
||||
}
|
||||
|
||||
// Apply every movement dated that day before counting the day itself, so a deposit earns
|
||||
// from the day it was made.
|
||||
while (index < movements.size() && movementDate.equals(movements.get(index).date())) {
|
||||
balance = balance.add(movements.get(index).signedAmount());
|
||||
index++;
|
||||
}
|
||||
product = product.add(balance);
|
||||
cursor = movementDate;
|
||||
}
|
||||
|
||||
final long remainingDays = ChronoUnit.DAYS.between(cursor, to);
|
||||
if (remainingDays > 0) {
|
||||
product = product.add(balance.multiply(BigDecimal.valueOf(remainingDays)));
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
/** Simple interest on a daily product at an annual rate, rounded to paise. */
|
||||
public static BigDecimal interestOn(final BigDecimal dailyProduct, final BigDecimal ratePercent) {
|
||||
if (dailyProduct == null || ratePercent == null || dailyProduct.compareTo(BigDecimal.ZERO) <= 0
|
||||
|| ratePercent.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO.setScale(MONEY_SCALE);
|
||||
}
|
||||
return dailyProduct.multiply(ratePercent).divide(HUNDRED.multiply(DAYS_IN_YEAR), MATH_CONTEXT).setScale(MONEY_SCALE,
|
||||
RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* 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.dds.calculation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
* Where a daily deposit account stands against the instalments it owes.
|
||||
*
|
||||
* The bank's rule has two sides and they are not symmetrical. A customer may run ahead, but only a few
|
||||
* days - the scheme is a habit of saving daily, not a deposit paid in one go. A customer may fall
|
||||
* behind and catch up, but only within a short window; instalments older than that are skipped, and
|
||||
* skipped means gone - the agent does not go back for them, and the account matures on what was
|
||||
* actually paid.
|
||||
*
|
||||
* So a shortfall is split rather than shown as one figure. What can still be collected is worth a
|
||||
* clerk chasing today; what has passed the window is not, and showing them together would have the
|
||||
* clerk asking for money the bank has already written off.
|
||||
*
|
||||
* Kept here, outside either service, because the screen that shows these figures and the check that
|
||||
* refuses a collection exceeding them have to agree. Two copies would drift, and the day they did the
|
||||
* screen would quote a customer an amount the server then refused.
|
||||
*
|
||||
* @param expected what should have been collected by the day this was worked out
|
||||
* @param advance signed: positive is paid ahead, negative is behind
|
||||
* @param advanceDays whole instalments covered ahead - a part-paid day does not count
|
||||
* @param arrearsDays whole or part instalments behind
|
||||
* @param recoverable the part of a shortfall still inside the catch-up window
|
||||
* @param skippedDays instalments past that window
|
||||
* @param skipped what those come to
|
||||
*/
|
||||
public record DdsSchedule(BigDecimal expected, BigDecimal advance, int advanceDays, int arrearsDays, BigDecimal recoverable,
|
||||
int skippedDays, BigDecimal skipped) {
|
||||
|
||||
/**
|
||||
* How far behind an account may fall and still be caught up. Past this the instalments are
|
||||
* skipped - the agent does not go back for them and the customer matures on less.
|
||||
*/
|
||||
public static final int CATCH_UP_DAYS = 3;
|
||||
|
||||
/**
|
||||
* The most a customer may pay ahead, so a scheme is not simply prepaid into a lump deposit. A few
|
||||
* days is the bank's allowance for someone going away or paid weekly.
|
||||
*/
|
||||
public static final int MAX_ADVANCE_DAYS = 5;
|
||||
|
||||
private static final DdsSchedule NOTHING_DUE = new DdsSchedule(BigDecimal.ZERO, BigDecimal.ZERO, 0, 0, BigDecimal.ZERO, 0,
|
||||
BigDecimal.ZERO);
|
||||
|
||||
/**
|
||||
* How many instalments should have been paid by the given day.
|
||||
*
|
||||
* The first falls on the opening day itself - the same day the maturity figure starts counting
|
||||
* from, so the two agree - and the count stops at the term's own length, so a matured account does
|
||||
* not keep falling further behind.
|
||||
*/
|
||||
public static int instalmentsDue(final LocalDate openingDate, final Integer termDays, final LocalDate on) {
|
||||
if (openingDate == null || termDays == null || termDays <= 0 || on == null) {
|
||||
return 0;
|
||||
}
|
||||
final long elapsed = ChronoUnit.DAYS.between(openingDate, on) + 1;
|
||||
return (int) Math.max(0, Math.min(termDays, elapsed));
|
||||
}
|
||||
|
||||
public static DdsSchedule of(final BigDecimal daily, final int instalmentsDue, final BigDecimal collected) {
|
||||
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
// Nothing is due daily, so there is no schedule to be ahead or behind of.
|
||||
return NOTHING_DUE;
|
||||
}
|
||||
|
||||
final BigDecimal paid = collected == null ? BigDecimal.ZERO : collected;
|
||||
final BigDecimal expected = daily.multiply(BigDecimal.valueOf(instalmentsDue));
|
||||
final BigDecimal advance = paid.subtract(expected);
|
||||
|
||||
if (advance.compareTo(BigDecimal.ZERO) >= 0) {
|
||||
// Ahead. Only whole instalments count as days covered - a customer eighty rupees into a
|
||||
// hundred-rupee day still owes today, and the agent should still call.
|
||||
final int daysCovered = advance.divideToIntegralValue(daily).intValue();
|
||||
return new DdsSchedule(expected, advance, daysCovered, 0, BigDecimal.ZERO, 0, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// Behind. A part-paid day counts as a day behind for the same reason.
|
||||
final BigDecimal shortfall = advance.negate();
|
||||
final int daysBehind = shortfall.divide(daily, 0, RoundingMode.CEILING).intValue();
|
||||
final BigDecimal window = daily.multiply(BigDecimal.valueOf(CATCH_UP_DAYS));
|
||||
final BigDecimal recoverable = shortfall.min(window);
|
||||
return new DdsSchedule(expected, advance, 0, daysBehind, recoverable, Math.max(0, daysBehind - CATCH_UP_DAYS),
|
||||
shortfall.subtract(recoverable));
|
||||
}
|
||||
|
||||
/** Whole instalments a customer may still pay off, which is what the arrears option may offer. */
|
||||
public int recoverableDays(final BigDecimal daily) {
|
||||
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return this.recoverable.divideToIntegralValue(daily).intValue();
|
||||
}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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.dds.collection.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* An agent's day, as the clerk sees it when the agent walks up to the counter.
|
||||
*
|
||||
* The figure that matters is {@code expectedAmount}: what the agent's own entries add up to, and so
|
||||
* what should be in their bag. It is given as a total beside the lines it came from, because a clerk
|
||||
* who is short counts the lines to find out where.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AgentCollectionSummaryData {
|
||||
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
private final LocalDate entryDate;
|
||||
|
||||
private final int pendingCount;
|
||||
|
||||
/** What the agent's outstanding entries add up to - the cash the branch should be handed. */
|
||||
private final BigDecimal expectedAmount;
|
||||
|
||||
/** What has already been counted and posted for this agent on this date. */
|
||||
private final int settledCount;
|
||||
private final BigDecimal settledAmount;
|
||||
|
||||
private final List<CollectionEntryData> entries;
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 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.dds.collection.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* One written-down collection, as the agent's screen and the clerk's settlement list show it.
|
||||
*
|
||||
* Carries the party's code, name and balance beside the amount, because both readers are checking
|
||||
* the line against a person rather than an id - the agent against the customer they just left, the
|
||||
* clerk against the passbook in front of them.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CollectionEntryData {
|
||||
|
||||
private final Long id;
|
||||
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
|
||||
private final Long partyId;
|
||||
private final String partyCode;
|
||||
private final String partyName;
|
||||
|
||||
/** The account's balance as it stands now - before this entry, which has not been posted. */
|
||||
private final BigDecimal partyBalance;
|
||||
|
||||
/** What this scheme takes each day, so an amount that is not a day's instalment stands out. */
|
||||
private final BigDecimal dailyDepositAmount;
|
||||
|
||||
private final LocalDate entryDate;
|
||||
private final BigDecimal amount;
|
||||
private final String transactionMode;
|
||||
private final String chequeNo;
|
||||
private final String tokenNo;
|
||||
private final String description;
|
||||
|
||||
private final String status;
|
||||
private final LocalDate settledOn;
|
||||
private final Long partyTransactionId;
|
||||
/** Days paid ahead, or days of arrears cleared - empty on an ordinary day's collection. */
|
||||
private final Integer advanceDays;
|
||||
private final Integer arrearsDays;
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 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.dds.collection.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
|
||||
/**
|
||||
* One line an agent wrote down on their round: this customer, this much, in this form.
|
||||
*
|
||||
* It is not a transaction and deliberately reads nothing like one. No balance is carried on it, no
|
||||
* ledger entry stands behind it, and no report of the bank's position counts it. It says only what
|
||||
* the agent says they collected - the bank's own record of it begins when a clerk has counted the
|
||||
* cash and confirmed the day's entries, and what that writes is an ordinary
|
||||
* {@code m_dds_party_transaction} row.
|
||||
*
|
||||
* The distinction is the whole point of the table. Credited on the spot, the books showed customers
|
||||
* paid in notes the branch had never held, and there was nothing to count the agent's bag against
|
||||
* when they arrived. Held here, the agent's word and the bank's books stay separate until somebody
|
||||
* puts them side by side.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_collection_entry")
|
||||
public class CollectionEntry extends AbstractAuditableCustom {
|
||||
|
||||
/** Waiting for a clerk to count the cash behind it. */
|
||||
public static final String PENDING = "PENDING";
|
||||
|
||||
/** Counted, posted, and now standing behind a real transaction. */
|
||||
public static final String SETTLED = "SETTLED";
|
||||
|
||||
/** Withdrawn before it was ever posted - a wrong line the agent took back. */
|
||||
public static final String CANCELLED = "CANCELLED";
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
private Agent agent;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "party_id", nullable = false)
|
||||
private Party party;
|
||||
|
||||
/**
|
||||
* The day the money changed hands, which is the agent's day and not the branch's. A round
|
||||
* collected on Friday and brought in on Monday is still Friday's collection, and the customer's
|
||||
* statement has to say so.
|
||||
*/
|
||||
@Column(name = "entry_date", nullable = false)
|
||||
private LocalDate entryDate;
|
||||
|
||||
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Column(name = "transaction_mode", length = 20, nullable = false)
|
||||
private String transactionMode;
|
||||
|
||||
@Column(name = "cheque_no", length = 50)
|
||||
private String chequeNo;
|
||||
|
||||
@Column(name = "token_no", length = 50)
|
||||
private String tokenNo;
|
||||
|
||||
@Column(name = "description", length = 200)
|
||||
private String description;
|
||||
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private String status;
|
||||
|
||||
@Column(name = "settled_on")
|
||||
private LocalDate settledOn;
|
||||
|
||||
/**
|
||||
* The transaction this entry became, once it was posted.
|
||||
*
|
||||
* Held as a plain id rather than a mapped association: this table is the paper the bank checked
|
||||
* against, not part of the ledger, and a link that pulled a transaction in whenever an entry was
|
||||
* read would make the two look like halves of one thing. Anyone who needs the transaction can
|
||||
* fetch it; most callers only need to know there is one.
|
||||
*/
|
||||
@Column(name = "party_transaction_id")
|
||||
private Long partyTransactionId;
|
||||
|
||||
/**
|
||||
* The application of the cashier's DDS receipts that settled this entry, when it was settled that
|
||||
* way - see 0517, which records which vouchers it used. A plain id for the same reason as the
|
||||
* transaction.
|
||||
*/
|
||||
@Column(name = "receipt_application_id")
|
||||
private Long receiptApplicationId;
|
||||
|
||||
/** Days paid ahead, or days of arrears cleared - see 0520. Both empty on an ordinary day's collection. */
|
||||
@Column(name = "advance_days")
|
||||
private Integer advanceDays;
|
||||
|
||||
@Column(name = "arrears_days")
|
||||
private Integer arrearsDays;
|
||||
|
||||
protected CollectionEntry() {
|
||||
//
|
||||
}
|
||||
|
||||
public CollectionEntry(final Agent agent, final Party party, final LocalDate entryDate, final BigDecimal amount,
|
||||
final String transactionMode, final String chequeNo, final String tokenNo, final String description) {
|
||||
this.agent = agent;
|
||||
this.party = party;
|
||||
this.entryDate = entryDate;
|
||||
this.amount = amount;
|
||||
this.transactionMode = transactionMode;
|
||||
this.chequeNo = chequeNo;
|
||||
this.tokenNo = tokenNo;
|
||||
this.description = description;
|
||||
this.status = PENDING;
|
||||
}
|
||||
|
||||
/** Records what the collection covers beyond today, once its amount has been checked against it. */
|
||||
public void covering(final Integer advanceDays, final Integer arrearsDays) {
|
||||
this.advanceDays = advanceDays;
|
||||
this.arrearsDays = arrearsDays;
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
return PENDING.equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that this entry has been counted and posted, and what it became.
|
||||
*
|
||||
* The transaction's id is kept so the two can always be put back together - a clerk asked six
|
||||
* months later which entry a credit came from has an answer, rather than a matching exercise on
|
||||
* date and amount.
|
||||
*/
|
||||
public void markSettled(final LocalDate settledOn, final Long partyTransactionId) {
|
||||
this.status = SETTLED;
|
||||
this.settledOn = settledOn;
|
||||
this.partyTransactionId = partyTransactionId;
|
||||
}
|
||||
|
||||
/** Settled against the cashier's receipts: the same as {@link #markSettled}, and which application did it. */
|
||||
public void markSettledByReceipt(final LocalDate settledOn, final Long partyTransactionId, final Long receiptApplicationId) {
|
||||
markSettled(settledOn, partyTransactionId);
|
||||
this.receiptApplicationId = receiptApplicationId;
|
||||
}
|
||||
|
||||
/** Withdraws a line that was never posted. Kept, not deleted - the agent wrote it, so it stands. */
|
||||
public void markCancelled() {
|
||||
this.status = CANCELLED;
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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.dds.collection.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
public class CollectionEntryNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public CollectionEntryNotFoundException(final Long entryId) {
|
||||
super("error.msg.dds.collection.entry.not.found", "Collection entry " + entryId + " does not exist", entryId);
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 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.dds.collection.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a written-down collection is taken back after it has already been settled.
|
||||
*
|
||||
* By then it is not a note any more - a customer has been credited and an agent's cash recorded
|
||||
* against it. Withdrawing it here would leave the note cancelled and the credit standing, which is
|
||||
* the one state nobody could reconcile. A posted collection is undone the way any posting is: with
|
||||
* an entry the other way, on the ledger, where it can be seen.
|
||||
*/
|
||||
public class CollectionEntryNotPendingException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public CollectionEntryNotPendingException(final Long entryId, final String status) {
|
||||
super("error.msg.dds.collection.entry.not.pending",
|
||||
"Collection entry " + entryId + " is " + status.toLowerCase() + " and can no longer be cancelled", entryId, status);
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 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.dds.collection.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a settlement is asked for and the agent has nothing outstanding on that date.
|
||||
*
|
||||
* Said plainly rather than passed off as a settlement of nothing, because the clerk is standing at
|
||||
* the counter with an agent in front of them: either they have the wrong date, or the round has been
|
||||
* settled already, and both are worth knowing before the cash is taken.
|
||||
*/
|
||||
public class NothingToSettleException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public NothingToSettleException(final String agentCode, final String entryDate) {
|
||||
super("error.msg.dds.collection.nothing.to.settle",
|
||||
"Agent " + agentCode + " has no collection entries outstanding for " + entryDate, agentCode, entryDate);
|
||||
}
|
||||
}
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Immutable data object representing a DDS Party (customer scheme account), including its nominees.
|
||||
* Built via the Lombok builder rather than a long positional constructor - the field count here makes
|
||||
* positional args unreadable and error-prone to keep in sync.
|
||||
*/
|
||||
@Getter
|
||||
@Builder(toBuilder = true)
|
||||
public final class PartyData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final Long clientId;
|
||||
private final String clientName;
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final String agentName;
|
||||
/** The branch this account belongs to - its own, not the collecting agent's. */
|
||||
private final Long officeId;
|
||||
private final String officeName;
|
||||
/**
|
||||
* The customer's real deposit account, opened when the party was created.
|
||||
*
|
||||
* The id alone was carried here before, which is a number only the database means anything by -
|
||||
* a screen showing "36" tells an officer nothing. The account's own number, product, status and
|
||||
* balance are read from the account itself, so what a party shows is what the bank's books say
|
||||
* rather than a second copy that can drift. All four are null for a party opened before DDS
|
||||
* began opening real accounts, and that is worth showing plainly rather than hiding.
|
||||
*/
|
||||
private final Long savingsAccountId;
|
||||
private final String savingsAccountNo;
|
||||
private final String savingsProductName;
|
||||
private final String savingsAccountStatus;
|
||||
private final BigDecimal savingsAccountBalance;
|
||||
/**
|
||||
* What on this account is pledged against a loan and cannot leave.
|
||||
*
|
||||
* Shown beside the balance on the closure list because a deposit standing as security is owed but
|
||||
* not payable, and a clerk who reads only the balance would try to pay it and be refused.
|
||||
*/
|
||||
private final BigDecimal amountHeldUnderLien;
|
||||
private final String subType;
|
||||
/**
|
||||
* The account type's description, resolved from the DDS Account Type master that {@code subType}
|
||||
* holds the code of - so callers can show the scheme by the name officers know it by without
|
||||
* fetching the master separately.
|
||||
*/
|
||||
private final String subTypeDescription;
|
||||
private final String partyCode;
|
||||
/** The DDS account's own number. */
|
||||
private final String accountNo;
|
||||
private final String lastAccount;
|
||||
private final Boolean nominal;
|
||||
private final String aadharNo;
|
||||
private final String partyName;
|
||||
private final String partyName1;
|
||||
private final String address1;
|
||||
private final String address2;
|
||||
private final String address3;
|
||||
private final String city;
|
||||
private final String pincode;
|
||||
private final String gender;
|
||||
private final String panCardNo;
|
||||
private final String telephone;
|
||||
private final String mobileNoSms;
|
||||
private final String operation;
|
||||
private final LocalDate birthDate;
|
||||
private final String occupation;
|
||||
private final String risk;
|
||||
private final String ckycId;
|
||||
private final String adultMinor;
|
||||
private final LocalDate minorDate;
|
||||
|
||||
/**
|
||||
* The day a minor account holder comes of age, and whether it has passed.
|
||||
*
|
||||
* Worked out from the birth date rather than taken from {@code minorDate}, which is typed in by
|
||||
* hand and has never been filled on any record. Null on an adult's account.
|
||||
*
|
||||
* The guardian's authority to operate the account ends on that day, so the branch has to take
|
||||
* fresh KYC, a signature and a new mandate from the customer themselves. Nothing acts on this
|
||||
* yet - whether the account should also be restricted until that is done is the bank's rule, and
|
||||
* blocking one wrongly would strand a customer's money. It is carried here so the day stops
|
||||
* passing unnoticed, which is what happened before.
|
||||
*/
|
||||
private final LocalDate majorOn;
|
||||
private final boolean minorTurnedMajor;
|
||||
private final Long guardianClientId;
|
||||
private final String minAccountOpt;
|
||||
private final String relation;
|
||||
private final LocalDate openingDate;
|
||||
private final Integer month;
|
||||
private final LocalDate maturityDate;
|
||||
private final LocalDate reopenDate;
|
||||
private final BigDecimal depositAmount;
|
||||
private final BigDecimal dailyDepositAmount;
|
||||
private final Integer days;
|
||||
private final String ledgerNo;
|
||||
private final BigDecimal maturityAmount;
|
||||
private final BigDecimal interestRate;
|
||||
private final String withdrawType;
|
||||
private final Boolean od1Flag;
|
||||
private final Boolean od2Flag;
|
||||
private final Boolean lien;
|
||||
/** Why the money is held - null when it is not. */
|
||||
private final String lienReason;
|
||||
private final LocalDate payDate;
|
||||
private final BigDecimal paymentAmount;
|
||||
private final BigDecimal currentBalance;
|
||||
private final BigDecimal openingBalance;
|
||||
|
||||
/**
|
||||
* Where this account stands against the instalments it should have paid by today.
|
||||
*
|
||||
* A daily scheme is a promise to pay a fixed sum every day, and the only figure the screen used to
|
||||
* show was the running balance - which says how much has been paid but not whether it was paid on
|
||||
* time. A customer eleven instalments behind and one who is four ahead can hold the same balance.
|
||||
*
|
||||
* {@code advanceAmount} is signed: positive is paid ahead, negative is behind. The rest of these
|
||||
* read it in the bank's own terms - how many days that comes to, how much of a shortfall can still
|
||||
* be recovered within the catch-up window, and how much has fallen outside it and is gone.
|
||||
*/
|
||||
private final Integer instalmentsDue;
|
||||
private final BigDecimal expectedToDate;
|
||||
private final BigDecimal collectedToDate;
|
||||
private final BigDecimal advanceAmount;
|
||||
private final Integer advanceDays;
|
||||
private final Integer arrearsDays;
|
||||
private final BigDecimal recoverableAmount;
|
||||
/** Instalments now past the catch-up window. These are not collected again - they are lost. */
|
||||
private final Integer skippedDays;
|
||||
private final BigDecimal skippedAmount;
|
||||
private final Boolean isSmsActive;
|
||||
private final Boolean isEmailActive;
|
||||
/** Interest worked out but not yet credited, and how far it is calculated through. */
|
||||
private final BigDecimal interestCalc;
|
||||
private final LocalDate interestDate;
|
||||
|
||||
/** The same pair for the OD interest stream, which runs and posts independently. */
|
||||
private final BigDecimal odInterestCalc;
|
||||
private final LocalDate odInterestDate;
|
||||
private final String spDescription;
|
||||
private final String freezeCode;
|
||||
private final String advCode;
|
||||
private final String creditToBranch;
|
||||
private final String creditToAccountType;
|
||||
private final String creditToAccount;
|
||||
private final Boolean nomineeApplicable;
|
||||
private final String nomineeSplitMode;
|
||||
private final String status;
|
||||
private final LocalDate closeDate;
|
||||
private final Collection<PartyNomineeData> nominees;
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public final class PartyNomineeData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final LocalDate nominationDate;
|
||||
private final String name;
|
||||
private final String mobileNo;
|
||||
private final LocalDate birthDate;
|
||||
private final Boolean minor;
|
||||
private final String relation;
|
||||
private final String gender;
|
||||
private final String guardianName;
|
||||
/** Null under a Sequence split, where the order the nominees are paid in is the entitlement. */
|
||||
private final BigDecimal sharePercent;
|
||||
/** Null under a Percentage split, where everyone is paid at once and there is no first. */
|
||||
private final Integer sequenceNo;
|
||||
|
||||
public static PartyNomineeData instance(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName,
|
||||
final BigDecimal sharePercent, final Integer sequenceNo) {
|
||||
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName, sharePercent,
|
||||
sequenceNo);
|
||||
}
|
||||
|
||||
private PartyNomineeData(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName,
|
||||
final BigDecimal sharePercent, final Integer sequenceNo) {
|
||||
this.id = id;
|
||||
this.nominationDate = nominationDate;
|
||||
this.name = name;
|
||||
this.mobileNo = mobileNo;
|
||||
this.birthDate = birthDate;
|
||||
this.minor = minor;
|
||||
this.relation = relation;
|
||||
this.gender = gender;
|
||||
this.guardianName = guardianName;
|
||||
this.sharePercent = sharePercent;
|
||||
this.sequenceNo = sequenceNo;
|
||||
}
|
||||
}
|
||||
-791
@@ -1,791 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.domain;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
|
||||
/**
|
||||
* A DDS Party - the customer's daily-deposit scheme account, linked to the Agent who collects for it.
|
||||
* Carries the scheme's KYC, terms and two-stage overdue flags, plus a nominee list.
|
||||
*
|
||||
* The underlying RD (recurring deposit) account this scheme conceptually reuses is not wired up yet -
|
||||
* {@code savingsAccountId} is a nullable placeholder for that future linkage (see the DDS architecture
|
||||
* decision to reuse the RD engine for the deposit half). Until then the scheme/balance fields below
|
||||
* are this record's own, not mirrored from any m_savings_account row.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
||||
public class Party extends AbstractAuditableCustom {
|
||||
|
||||
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
|
||||
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
|
||||
|
||||
/** Interest is quoted per annum, so the daily instalments are pro-rated over a year. */
|
||||
private static final int DAYS_IN_YEAR = 365;
|
||||
|
||||
private static final MathContext MATH_CONTEXT = new MathContext(19, RoundingMode.HALF_UP);
|
||||
|
||||
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||
public static final String STATUS_CLOSED = "CLOSED";
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
private Agent agent;
|
||||
|
||||
/**
|
||||
* The branch this account belongs to.
|
||||
*
|
||||
* Its own, not the collecting agent's. An agent working out of one branch can hold accounts for
|
||||
* customers of another, and reassigning an account to a different agent should not move the
|
||||
* customer's branch with it. Defaults to the agent's branch when the caller does not name one,
|
||||
* which is what the account was read as before it had a branch of its own.
|
||||
*/
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "office_id")
|
||||
private Office office;
|
||||
|
||||
@Column(name = "savings_account_id")
|
||||
private Long savingsAccountId;
|
||||
|
||||
@Column(name = "sub_type", length = 60, nullable = false)
|
||||
private String subType;
|
||||
|
||||
@Column(name = "party_code", length = 20, nullable = false)
|
||||
private String partyCode;
|
||||
|
||||
/** The DDS account's own number - see 0514. Given once the party has an id, and never changed. */
|
||||
@Column(name = "account_no", length = 20)
|
||||
private String accountNo;
|
||||
|
||||
@Column(name = "last_account", length = 50)
|
||||
private String lastAccount;
|
||||
|
||||
@Column(name = "nominal", nullable = false)
|
||||
private boolean nominal;
|
||||
|
||||
@Column(name = "aadhar_no", length = 20)
|
||||
private String aadharNo;
|
||||
|
||||
@Column(name = "party_name", length = 100, nullable = false)
|
||||
private String partyName;
|
||||
|
||||
@Column(name = "party_name1", length = 100)
|
||||
private String partyName1;
|
||||
|
||||
@Column(name = "address1", length = 200, nullable = false)
|
||||
private String address1;
|
||||
|
||||
@Column(name = "address2", length = 200)
|
||||
private String address2;
|
||||
|
||||
@Column(name = "address3", length = 200)
|
||||
private String address3;
|
||||
|
||||
@Column(name = "city", length = 100, nullable = false)
|
||||
private String city;
|
||||
|
||||
@Column(name = "pincode", length = 20, nullable = false)
|
||||
private String pincode;
|
||||
|
||||
@Column(name = "gender", length = 20)
|
||||
private String gender;
|
||||
|
||||
@Column(name = "pan_card_no", length = 20)
|
||||
private String panCardNo;
|
||||
|
||||
@Column(name = "telephone", length = 20)
|
||||
private String telephone;
|
||||
|
||||
@Column(name = "mobile_no_sms", length = 20, nullable = false)
|
||||
private String mobileNoSms;
|
||||
|
||||
@Column(name = "operation", length = 20, nullable = false)
|
||||
private String operation;
|
||||
|
||||
@Column(name = "birth_date")
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Column(name = "occupation", length = 40)
|
||||
private String occupation;
|
||||
|
||||
@Column(name = "risk", length = 20)
|
||||
private String risk;
|
||||
|
||||
@Column(name = "ckyc_id", length = 40)
|
||||
private String ckycId;
|
||||
|
||||
@Column(name = "adult_minor", length = 20, nullable = false)
|
||||
private String adultMinor;
|
||||
|
||||
@Column(name = "minor_date")
|
||||
private LocalDate minorDate;
|
||||
|
||||
@Column(name = "guardian_client_id")
|
||||
private Long guardianClientId;
|
||||
|
||||
@Column(name = "min_account_opt", length = 40)
|
||||
private String minAccountOpt;
|
||||
|
||||
@Column(name = "relation", length = 40)
|
||||
private String relation;
|
||||
|
||||
@Column(name = "opening_date", nullable = false)
|
||||
private LocalDate openingDate;
|
||||
|
||||
@Column(name = "months")
|
||||
private Integer month;
|
||||
|
||||
@Column(name = "maturity_date")
|
||||
private LocalDate maturityDate;
|
||||
|
||||
@Column(name = "reopen_date")
|
||||
private LocalDate reopenDate;
|
||||
|
||||
@Column(name = "deposit_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal depositAmount;
|
||||
|
||||
@Column(name = "daily_deposit_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal dailyDepositAmount;
|
||||
|
||||
@Column(name = "days", nullable = false)
|
||||
private Integer days;
|
||||
|
||||
@Column(name = "ledger_no", length = 40)
|
||||
private String ledgerNo;
|
||||
|
||||
@Column(name = "maturity_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal maturityAmount;
|
||||
|
||||
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal interestRate;
|
||||
|
||||
@Column(name = "withdraw_type", length = 20)
|
||||
private String withdrawType;
|
||||
|
||||
@Column(name = "od1_flag", nullable = false)
|
||||
private boolean od1Flag;
|
||||
|
||||
@Column(name = "od2_flag", nullable = false)
|
||||
private boolean od2Flag;
|
||||
|
||||
/**
|
||||
* Whether this account's money is being held, and so cannot leave.
|
||||
*
|
||||
* Deposits are unaffected - the agent collects as before and interest posts as before. What is
|
||||
* refused is every way out: the maturity payout, an early closure, and a transfer to another
|
||||
* party. A lien that stopped the collections would defeat the scheme it is securing.
|
||||
*/
|
||||
@Column(name = "lien", nullable = false)
|
||||
private boolean lien;
|
||||
|
||||
/**
|
||||
* Why the money is held.
|
||||
*
|
||||
* The flag alone says an account is frozen and never says what for, which is how a held deposit
|
||||
* outlives the loan it secured: months later nobody can tell whether it has been repaid, so
|
||||
* nobody will release it and nobody can justify keeping it.
|
||||
*
|
||||
* It falls to a line of text because DDS and the loan module are not connected - nothing else in
|
||||
* the system links a held deposit to the loan behind it.
|
||||
*/
|
||||
@Column(name = "lien_reason", length = 200)
|
||||
private String lienReason;
|
||||
|
||||
@Column(name = "pay_date")
|
||||
private LocalDate payDate;
|
||||
|
||||
@Column(name = "payment_amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal paymentAmount;
|
||||
|
||||
@Column(name = "current_balance", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal currentBalance;
|
||||
|
||||
@Column(name = "opening_balance", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal openingBalance;
|
||||
|
||||
@Column(name = "is_sms_active", nullable = false)
|
||||
private boolean isSmsActive;
|
||||
|
||||
@Column(name = "is_email_active", nullable = false)
|
||||
private boolean isEmailActive;
|
||||
|
||||
/** Interest worked out but not yet credited - the legacy screen's "Int.Calc". */
|
||||
@Column(name = "interest_calc", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal interestCalc;
|
||||
|
||||
/** How far regular interest has been calculated through. */
|
||||
@Column(name = "interest_date")
|
||||
private LocalDate interestDate;
|
||||
|
||||
/**
|
||||
* OD interest is a second stream over the same account, run and posted independently of regular
|
||||
* interest, so it keeps its own accrued figure and through-date rather than sharing them.
|
||||
*/
|
||||
@Column(name = "od_interest_calc", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal odInterestCalc;
|
||||
|
||||
@Column(name = "od_interest_date")
|
||||
private LocalDate odInterestDate;
|
||||
|
||||
@Column(name = "sp_description", length = 500)
|
||||
private String spDescription;
|
||||
|
||||
@Column(name = "freeze_code", length = 20, nullable = false)
|
||||
private String freezeCode;
|
||||
|
||||
@Column(name = "adv_code", length = 40, nullable = false)
|
||||
private String advCode;
|
||||
|
||||
@Column(name = "credit_to_branch", length = 100)
|
||||
private String creditToBranch;
|
||||
|
||||
@Column(name = "credit_to_account_type", length = 100)
|
||||
private String creditToAccountType;
|
||||
|
||||
@Column(name = "credit_to_account", length = 50)
|
||||
private String creditToAccount;
|
||||
|
||||
@Column(name = "nominee_applicable", nullable = false)
|
||||
private boolean nomineeApplicable;
|
||||
|
||||
@Column(name = "nominee_split_mode", length = 20)
|
||||
private String nomineeSplitMode;
|
||||
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private String status;
|
||||
|
||||
@Column(name = "close_date")
|
||||
private LocalDate closeDate;
|
||||
|
||||
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("id")
|
||||
private List<PartyNominee> nominees = new ArrayList<>();
|
||||
|
||||
protected Party() {
|
||||
//
|
||||
}
|
||||
|
||||
private Party(final Client client, final Agent agent, final Office office, final String partyCode, final JsonCommand command) {
|
||||
this.client = client;
|
||||
this.agent = agent;
|
||||
this.office = office;
|
||||
this.partyCode = partyCode;
|
||||
this.subType = command.stringValueOfParameterNamed("subType");
|
||||
this.lastAccount = command.stringValueOfParameterNamedAllowingNull("lastAccount");
|
||||
this.nominal = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
||||
this.aadharNo = command.stringValueOfParameterNamedAllowingNull("aadharNo");
|
||||
this.partyName = command.stringValueOfParameterNamed("partyName");
|
||||
this.partyName1 = command.stringValueOfParameterNamedAllowingNull("partyName1");
|
||||
this.address1 = command.stringValueOfParameterNamed("address1");
|
||||
this.address2 = command.stringValueOfParameterNamedAllowingNull("address2");
|
||||
this.address3 = command.stringValueOfParameterNamedAllowingNull("address3");
|
||||
this.city = command.stringValueOfParameterNamed("city");
|
||||
this.pincode = command.stringValueOfParameterNamed("pincode");
|
||||
this.gender = command.stringValueOfParameterNamedAllowingNull("gender");
|
||||
this.panCardNo = command.stringValueOfParameterNamedAllowingNull("panCardNo");
|
||||
this.telephone = command.stringValueOfParameterNamedAllowingNull("telephone");
|
||||
this.mobileNoSms = command.stringValueOfParameterNamed("mobileNoSms");
|
||||
this.operation = command.stringValueOfParameterNamed("operation");
|
||||
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
||||
this.occupation = command.stringValueOfParameterNamedAllowingNull("occupation");
|
||||
this.risk = command.stringValueOfParameterNamedAllowingNull("risk");
|
||||
this.ckycId = command.stringValueOfParameterNamedAllowingNull("ckycId");
|
||||
this.adultMinor = command.stringValueOfParameterNamed("adultMinor");
|
||||
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
||||
this.guardianClientId = command.longValueOfParameterNamed("guardianClientId");
|
||||
this.minAccountOpt = command.stringValueOfParameterNamedAllowingNull("minAccountOpt");
|
||||
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
|
||||
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
||||
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
|
||||
// maturityDate and maturityAmount are not read from the request - recalculateTerm() below
|
||||
// works both out from the opening date and the terms.
|
||||
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
||||
this.depositAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("depositAmount"));
|
||||
this.dailyDepositAmount = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
||||
this.days = command.integerValueOfParameterNamed("days");
|
||||
this.ledgerNo = command.stringValueOfParameterNamedAllowingNull("ledgerNo");
|
||||
this.maturityAmount = BigDecimal.ZERO;
|
||||
this.interestRate = command.parameterExists("interestRate")
|
||||
? command.bigDecimalValueOfParameterNamed("interestRate")
|
||||
: DEFAULT_INTEREST_RATE;
|
||||
this.withdrawType = command.stringValueOfParameterNamedAllowingNull("withdrawType");
|
||||
this.od1Flag = false;
|
||||
this.od2Flag = false;
|
||||
this.lien = command.booleanPrimitiveValueOfParameterNamed("lien");
|
||||
this.lienReason = StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull("lienReason"));
|
||||
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
||||
this.paymentAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("paymentAmount"));
|
||||
this.openingBalance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("openingBalance"));
|
||||
this.currentBalance = this.openingBalance;
|
||||
this.isSmsActive = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
||||
this.isEmailActive = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
||||
this.interestCalc = BigDecimal.ZERO;
|
||||
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
||||
this.odInterestCalc = BigDecimal.ZERO;
|
||||
this.odInterestDate = null;
|
||||
this.spDescription = command.stringValueOfParameterNamedAllowingNull("spDescription");
|
||||
this.freezeCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("freezeCode"), "Normal");
|
||||
this.advCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("advCode"), "None");
|
||||
this.creditToBranch = command.stringValueOfParameterNamedAllowingNull("creditToBranch");
|
||||
this.creditToAccountType = command.stringValueOfParameterNamedAllowingNull("creditToAccountType");
|
||||
this.creditToAccount = command.stringValueOfParameterNamedAllowingNull("creditToAccount");
|
||||
this.nomineeApplicable = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
||||
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
|
||||
"Percentage");
|
||||
this.status = STATUS_ACTIVE;
|
||||
|
||||
recalculateTerm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the scheme's interest rate. The rate belongs to the scheme, not to the counter: it is set
|
||||
* when the account is opened or moved to another scheme, and again on every open account whenever
|
||||
* the bank changes the scheme's rate, with the maturity figure worked out again on it.
|
||||
*/
|
||||
public void takeSchemeRate(final BigDecimal schemeRate) {
|
||||
this.interestRate = schemeRate == null ? BigDecimal.ZERO : schemeRate;
|
||||
recalculateTerm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out the maturity date and amount from the terms just set.
|
||||
*
|
||||
* Both are quoted to the customer when the account is opened, so neither is taken from the
|
||||
* request: a figure the caller supplies is a figure the caller can get wrong or tamper with.
|
||||
* The screen shows the same numbers as a preview, worked out the same way.
|
||||
*/
|
||||
private void recalculateTerm() {
|
||||
if (this.openingDate != null) {
|
||||
if (this.month != null && this.month > 0) {
|
||||
this.maturityDate = this.openingDate.plusMonths(this.month);
|
||||
// Collection runs every day of the term without a break, so the number of instalments
|
||||
// is the number of days - it is not a separate thing for anyone to type.
|
||||
//
|
||||
// It was asked for, and a figure below the term's own length quietly broke the
|
||||
// maturity quote: an account collecting a hundred rupees a day for thirty days with
|
||||
// ten instalments keyed against it was quoted a thousand rupees and would collect
|
||||
// three. Working it out here means the two can no longer disagree.
|
||||
this.days = (int) ChronoUnit.DAYS.between(this.openingDate, this.maturityDate);
|
||||
} else if (this.days != null && this.days > 0) {
|
||||
// No month given, so the term is the run of days itself and days stands as entered.
|
||||
this.maturityDate = this.openingDate.plusDays(this.days);
|
||||
}
|
||||
}
|
||||
this.maturityAmount = calculateMaturityAmount();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the account matures to: every instalment paid in, plus simple interest on each for the
|
||||
* time it sits until maturity.
|
||||
*
|
||||
* The first instalment earns over the whole term and the last over none, which comes to
|
||||
* n(n+1)/2 instalment-days of interest across n instalments. At a zero rate this is just the
|
||||
* instalments themselves.
|
||||
*/
|
||||
private BigDecimal calculateMaturityAmount() {
|
||||
if (this.dailyDepositAmount == null || this.days == null || this.days <= 0
|
||||
|| this.dailyDepositAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final BigDecimal instalments = BigDecimal.valueOf(this.days);
|
||||
final BigDecimal principal = this.dailyDepositAmount.multiply(instalments);
|
||||
|
||||
final BigDecimal rate = this.interestRate == null ? BigDecimal.ZERO : this.interestRate;
|
||||
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return principal.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
final BigDecimal instalmentDays = instalments.multiply(instalments.add(BigDecimal.ONE))
|
||||
.divide(BigDecimal.valueOf(2), MATH_CONTEXT);
|
||||
final BigDecimal interest = this.dailyDepositAmount.multiply(instalmentDays).multiply(rate)
|
||||
.divide(BigDecimal.valueOf(100), MATH_CONTEXT)
|
||||
.divide(BigDecimal.valueOf(DAYS_IN_YEAR), MATH_CONTEXT);
|
||||
|
||||
return principal.add(interest).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
public static Party fromJson(final Client client, final Agent agent, final Office office, final String partyCode,
|
||||
final JsonCommand command) {
|
||||
return new Party(client, agent, office, partyCode, command);
|
||||
}
|
||||
|
||||
/** Moves this account to another branch, which a party update may do on its own. */
|
||||
public void changeOffice(final Office newOffice) {
|
||||
this.office = newOffice;
|
||||
}
|
||||
|
||||
private static BigDecimal orZero(final BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
public void addNominee(final PartyNominee nominee) {
|
||||
nominee.setParty(this);
|
||||
this.nominees.add(nominee);
|
||||
}
|
||||
|
||||
public void resetNominees() {
|
||||
this.nominees.clear();
|
||||
}
|
||||
|
||||
public void changeClient(final Client newClient) {
|
||||
this.client = newClient;
|
||||
}
|
||||
|
||||
public void changeAgent(final Agent newAgent) {
|
||||
this.agent = newAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links this Party to an existing real Savings Account, resolved by account number in the write
|
||||
* service. There is no unlink - once a Party's DDS scheme is tied to a real account, it stays
|
||||
* tied.
|
||||
*/
|
||||
public void linkSavingsAccount(final Long savingsAccountId) {
|
||||
this.savingsAccountId = savingsAccountId;
|
||||
}
|
||||
|
||||
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
|
||||
public void assignAccountNo() {
|
||||
if (this.accountNo == null && getId() != null) {
|
||||
this.accountNo = "DDS" + String.format("%09d", getId());
|
||||
}
|
||||
}
|
||||
|
||||
public BigDecimal credit(final BigDecimal amount) {
|
||||
this.currentBalance = this.currentBalance.add(amount);
|
||||
return this.currentBalance;
|
||||
}
|
||||
|
||||
public BigDecimal debit(final BigDecimal amount) {
|
||||
this.currentBalance = this.currentBalance.subtract(amount);
|
||||
return this.currentBalance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a minor's account over to its holder, now that they have come of age and been identified.
|
||||
*
|
||||
* Until this the guardian operates the account and the bank deals with them. After it the customer
|
||||
* holds it in their own right, so the guardian is kept only as a record of who stood for them
|
||||
* before - the account is no longer theirs to operate.
|
||||
*
|
||||
* Not done on the birthday alone. An eighteen-year-old the bank has not yet identified is somebody
|
||||
* it cannot pay, so the turnover waits on their own KYC being verified.
|
||||
*/
|
||||
public void becomeAdult() {
|
||||
this.adultMinor = ADULT;
|
||||
}
|
||||
|
||||
public boolean isMinor() {
|
||||
return MINOR.equalsIgnoreCase(this.adultMinor);
|
||||
}
|
||||
|
||||
private static final String MINOR = "Minor";
|
||||
private static final String ADULT = "Adult";
|
||||
|
||||
public boolean isClosed() {
|
||||
return STATUS_CLOSED.equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes this Party's account as of the given date. The balance must already be zero - a Party
|
||||
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
|
||||
* still owed to it.
|
||||
*/
|
||||
public void close(final LocalDate closeDate) {
|
||||
this.status = STATUS_CLOSED;
|
||||
this.closeDate = closeDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest Calculation: parks the interest earned since {@code interestDate} in the account's
|
||||
* accrued-interest figure - the legacy screen's "Int.Calc" - and stamps the date interest is now
|
||||
* calculated up to. The balance is untouched, because interest is not the customer's to draw on
|
||||
* until it is posted.
|
||||
*
|
||||
* The stamp is what keeps this safe to re-run: a second calculation for the same date finds no
|
||||
* days left to accrue, so the same interest cannot be earned twice.
|
||||
*/
|
||||
public void accrueInterest(final BigDecimal amount, final LocalDate throughDate, final boolean odInterest) {
|
||||
if (odInterest) {
|
||||
this.odInterestCalc = this.odInterestCalc.add(amount);
|
||||
this.odInterestDate = throughDate;
|
||||
} else {
|
||||
this.interestCalc = this.interestCalc.add(amount);
|
||||
this.interestDate = throughDate;
|
||||
}
|
||||
}
|
||||
|
||||
/** How far the given interest stream has been calculated through - where the next run starts from. */
|
||||
public LocalDate interestCalculatedThrough(final boolean odInterest) {
|
||||
return odInterest ? this.odInterestDate : this.interestDate;
|
||||
}
|
||||
|
||||
/** What the given interest stream is holding, waiting to be posted. */
|
||||
public BigDecimal accruedInterest(final boolean odInterest) {
|
||||
return odInterest ? this.odInterestCalc : this.interestCalc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest Posting: moves the given amount out of accrued interest and into the balance, and
|
||||
* returns the new balance.
|
||||
*
|
||||
* The amount is the one the run previewed rather than whatever is accrued at this moment, so what
|
||||
* gets credited is always what was seen and approved. Since it comes off the accrued figure, a
|
||||
* second posting of the same interest finds nothing left to move.
|
||||
*/
|
||||
public BigDecimal postInterest(final BigDecimal amount, final boolean odInterest) {
|
||||
if (odInterest) {
|
||||
this.odInterestCalc = this.odInterestCalc.subtract(amount);
|
||||
} else {
|
||||
this.interestCalc = this.interestCalc.subtract(amount);
|
||||
}
|
||||
this.currentBalance = this.currentBalance.add(amount);
|
||||
return this.currentBalance;
|
||||
}
|
||||
|
||||
public Long clientId() {
|
||||
return this.client.getId();
|
||||
}
|
||||
|
||||
public Long agentId() {
|
||||
return this.agent.getId();
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(40);
|
||||
|
||||
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
||||
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
||||
}
|
||||
if (command.isChangeInLongParameterNamed("agentId", this.agent.getId())) {
|
||||
actualChanges.put("agentId", command.longValueOfParameterNamed("agentId"));
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "subType", this.subType, v -> this.subType = v);
|
||||
stringChange(command, actualChanges, "lastAccount", this.lastAccount, v -> this.lastAccount = v);
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("nominal", this.nominal)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
||||
actualChanges.put("nominal", newValue);
|
||||
this.nominal = newValue;
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "aadharNo", this.aadharNo, v -> this.aadharNo = v);
|
||||
stringChange(command, actualChanges, "partyName", this.partyName, v -> this.partyName = v);
|
||||
stringChange(command, actualChanges, "partyName1", this.partyName1, v -> this.partyName1 = v);
|
||||
stringChange(command, actualChanges, "address1", this.address1, v -> this.address1 = v);
|
||||
stringChange(command, actualChanges, "address2", this.address2, v -> this.address2 = v);
|
||||
stringChange(command, actualChanges, "address3", this.address3, v -> this.address3 = v);
|
||||
stringChange(command, actualChanges, "city", this.city, v -> this.city = v);
|
||||
stringChange(command, actualChanges, "pincode", this.pincode, v -> this.pincode = v);
|
||||
stringChange(command, actualChanges, "gender", this.gender, v -> this.gender = v);
|
||||
stringChange(command, actualChanges, "panCardNo", this.panCardNo, v -> this.panCardNo = v);
|
||||
stringChange(command, actualChanges, "telephone", this.telephone, v -> this.telephone = v);
|
||||
stringChange(command, actualChanges, "mobileNoSms", this.mobileNoSms, v -> this.mobileNoSms = v);
|
||||
stringChange(command, actualChanges, "operation", this.operation, v -> this.operation = v);
|
||||
|
||||
if (command.isChangeInDateParameterNamed("birthDate", this.birthDate)) {
|
||||
actualChanges.put("birthDate", command.stringValueOfParameterNamed("birthDate"));
|
||||
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "occupation", this.occupation, v -> this.occupation = v);
|
||||
stringChange(command, actualChanges, "risk", this.risk, v -> this.risk = v);
|
||||
stringChange(command, actualChanges, "ckycId", this.ckycId, v -> this.ckycId = v);
|
||||
stringChange(command, actualChanges, "adultMinor", this.adultMinor, v -> this.adultMinor = v);
|
||||
|
||||
if (command.isChangeInDateParameterNamed("minorDate", this.minorDate)) {
|
||||
actualChanges.put("minorDate", command.stringValueOfParameterNamed("minorDate"));
|
||||
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
||||
}
|
||||
|
||||
if (command.isChangeInLongParameterNamed("guardianClientId", this.guardianClientId)) {
|
||||
final Long newValue = command.longValueOfParameterNamed("guardianClientId");
|
||||
actualChanges.put("guardianClientId", newValue);
|
||||
this.guardianClientId = newValue;
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "minAccountOpt", this.minAccountOpt, v -> this.minAccountOpt = v);
|
||||
stringChange(command, actualChanges, "relation", this.relation, v -> this.relation = v);
|
||||
|
||||
if (command.isChangeInDateParameterNamed("openingDate", this.openingDate)) {
|
||||
actualChanges.put("openingDate", command.stringValueOfParameterNamed("openingDate"));
|
||||
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("month", this.month)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamed("month");
|
||||
actualChanges.put("month", newValue);
|
||||
this.month = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
|
||||
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
|
||||
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("depositAmount", this.depositAmount)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("depositAmount");
|
||||
actualChanges.put("depositAmount", newValue);
|
||||
this.depositAmount = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("dailyDepositAmount", this.dailyDepositAmount)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
||||
actualChanges.put("dailyDepositAmount", newValue);
|
||||
this.dailyDepositAmount = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("days", this.days)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamed("days");
|
||||
actualChanges.put("days", newValue);
|
||||
this.days = newValue;
|
||||
}
|
||||
|
||||
// The rate is not edited here: it is the scheme's, taken when the account is opened or moved
|
||||
// to another scheme - see takeSchemeRate.
|
||||
|
||||
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
|
||||
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("lien", this.lien)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("lien");
|
||||
actualChanges.put("lien", newValue);
|
||||
this.lien = newValue;
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "lienReason", this.lienReason, v -> this.lienReason = v);
|
||||
|
||||
// A released account explains nothing, so the reason goes with the hold. Leaving it behind
|
||||
// would have the screen read "Lien: No, held for Loan #1234" - a sentence that contradicts
|
||||
// itself, and which somebody will one day read as the account still being held.
|
||||
//
|
||||
// Nothing is lost: what it was held for, who released it and when are all on the command the
|
||||
// release rode in on, which maker-checker keeps.
|
||||
if (!this.lien && this.lienReason != null) {
|
||||
actualChanges.put("lienReason", null);
|
||||
this.lienReason = null;
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("payDate", this.payDate)) {
|
||||
actualChanges.put("payDate", command.stringValueOfParameterNamed("payDate"));
|
||||
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("paymentAmount", this.paymentAmount)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("paymentAmount");
|
||||
actualChanges.put("paymentAmount", newValue);
|
||||
this.paymentAmount = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBigDecimalParameterNamed("openingBalance", this.openingBalance)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("openingBalance");
|
||||
actualChanges.put("openingBalance", newValue);
|
||||
this.openingBalance = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("isSmsActive", this.isSmsActive)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
||||
actualChanges.put("isSmsActive", newValue);
|
||||
this.isSmsActive = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("isEmailActive", this.isEmailActive)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
||||
actualChanges.put("isEmailActive", newValue);
|
||||
this.isEmailActive = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInDateParameterNamed("interestDate", this.interestDate)) {
|
||||
actualChanges.put("interestDate", command.stringValueOfParameterNamed("interestDate"));
|
||||
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "spDescription", this.spDescription, v -> this.spDescription = v);
|
||||
stringChange(command, actualChanges, "freezeCode", this.freezeCode, v -> this.freezeCode = v);
|
||||
stringChange(command, actualChanges, "advCode", this.advCode, v -> this.advCode = v);
|
||||
stringChange(command, actualChanges, "creditToBranch", this.creditToBranch, v -> this.creditToBranch = v);
|
||||
stringChange(command, actualChanges, "creditToAccountType", this.creditToAccountType, v -> this.creditToAccountType = v);
|
||||
stringChange(command, actualChanges, "creditToAccount", this.creditToAccount, v -> this.creditToAccount = v);
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("nomineeApplicable", this.nomineeApplicable)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
||||
actualChanges.put("nomineeApplicable", newValue);
|
||||
this.nomineeApplicable = newValue;
|
||||
}
|
||||
|
||||
stringChange(command, actualChanges, "nomineeSplitMode", this.nomineeSplitMode, v -> this.nomineeSplitMode = v);
|
||||
|
||||
// Correcting a term, an instalment or the rate has to move the maturity figures with it -
|
||||
// otherwise the account keeps quoting what it was worth under the old terms.
|
||||
final LocalDate maturityDateBefore = this.maturityDate;
|
||||
final BigDecimal maturityAmountBefore = this.maturityAmount;
|
||||
recalculateTerm();
|
||||
if (!java.util.Objects.equals(maturityDateBefore, this.maturityDate)) {
|
||||
actualChanges.put("maturityDate", this.maturityDate);
|
||||
}
|
||||
if (maturityAmountBefore == null || this.maturityAmount.compareTo(maturityAmountBefore) != 0) {
|
||||
actualChanges.put("maturityAmount", this.maturityAmount);
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
|
||||
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
||||
final String existingValue, final Consumer<String> setter) {
|
||||
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(paramName);
|
||||
actualChanges.put(paramName, newValue);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
|
||||
/**
|
||||
* A single nominee recorded against a {@link Party}. Maker-checker on nominee edits is enforced at
|
||||
* the command layer (the whole Party update goes through the standard maker-checker command flow),
|
||||
* not on this child row individually.
|
||||
*
|
||||
* Audited in its own right rather than only through the party: this row decides who receives a
|
||||
* customer's money after them, and "the party changed at 16:19" does not say that the person named
|
||||
* to receive it was replaced, nor by whom.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_party_nominee")
|
||||
public class PartyNominee extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "party_id", nullable = false)
|
||||
private Party party;
|
||||
|
||||
@Column(name = "nomination_date", nullable = false)
|
||||
private LocalDate nominationDate;
|
||||
|
||||
@Column(name = "name", length = 100, nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "mobile_no", length = 20)
|
||||
private String mobileNo;
|
||||
|
||||
@Column(name = "birth_date")
|
||||
private LocalDate birthDate;
|
||||
|
||||
/**
|
||||
* Whether this nominee was under age when nominated, worked out from their date of birth rather
|
||||
* than asked for.
|
||||
*
|
||||
* Asked for, it was got wrong: a ten-year-old was recorded as not a minor, and so no guardian
|
||||
* was required for them. The date of birth is captured either way and already holds the answer,
|
||||
* so the clerk is not asked a second time in a form that can disagree with the first.
|
||||
*
|
||||
* Kept as a stored column rather than worked out on reading, because it is the age at the
|
||||
* moment of nomination that the record is of - a nominee who has since come of age was still a
|
||||
* minor on the day they were named, and the guardian named beside them belongs to that day.
|
||||
*/
|
||||
@Column(name = "minor", nullable = false)
|
||||
private boolean minor;
|
||||
|
||||
/** India's age of majority, which is what makes a nominee one who needs a guardian named. */
|
||||
private static final int AGE_OF_MAJORITY = 18;
|
||||
|
||||
@Column(name = "relation", length = 40, nullable = false)
|
||||
private String relation;
|
||||
|
||||
@Column(name = "gender", length = 20, nullable = false)
|
||||
private String gender;
|
||||
|
||||
@Column(name = "guardian_name", length = 100)
|
||||
private String guardianName;
|
||||
|
||||
/**
|
||||
* What this nominee is entitled to, under a Percentage split. Null under a Sequence split, where
|
||||
* the order the nominees are held in is the entitlement and there is no share to state.
|
||||
*/
|
||||
@Column(name = "share_percent", precision = 5, scale = 2)
|
||||
private BigDecimal sharePercent;
|
||||
|
||||
/**
|
||||
* Where this nominee stands in the order the bank pays, under a Sequence split. Null under a
|
||||
* Percentage split, where everyone is paid at once and there is no first.
|
||||
*/
|
||||
@Column(name = "sequence_no")
|
||||
private Integer sequenceNo;
|
||||
|
||||
protected PartyNominee() {
|
||||
//
|
||||
}
|
||||
|
||||
public PartyNominee(final LocalDate nominationDate, final String name, final String mobileNo, final LocalDate birthDate,
|
||||
final String relation, final String gender, final String guardianName, final BigDecimal sharePercent,
|
||||
final Integer sequenceNo) {
|
||||
this.nominationDate = nominationDate;
|
||||
// Trimmed on the way in. A relation typed as "friend " is a different value from "friend"
|
||||
// to everything that groups or matches on it, and the trailing space is invisible on screen
|
||||
// - so the two read as the same relation everywhere except where it counts.
|
||||
this.name = StringUtils.trimToNull(name);
|
||||
this.mobileNo = StringUtils.trimToNull(mobileNo);
|
||||
this.birthDate = birthDate;
|
||||
this.minor = isUnderAgeOn(birthDate, nominationDate);
|
||||
this.relation = StringUtils.trimToNull(relation);
|
||||
this.gender = StringUtils.trimToNull(gender);
|
||||
this.guardianName = StringUtils.trimToNull(guardianName);
|
||||
this.sharePercent = sharePercent;
|
||||
this.sequenceNo = sequenceNo;
|
||||
}
|
||||
|
||||
void setParty(final Party party) {
|
||||
this.party = party;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone born on the given day was still under age on the day they were nominated.
|
||||
*
|
||||
* Measured against the nomination date rather than today, so a nomination reads the same in ten
|
||||
* years as it did when it was made - and a nominee who has since come of age does not quietly
|
||||
* lose the guardian who was named for them at the time.
|
||||
*
|
||||
* A missing date of birth is treated as not a minor: the field is required, so a null here can
|
||||
* only be a record made before it was, and guessing "minor" would demand a guardian for someone
|
||||
* who may well be eighty.
|
||||
*/
|
||||
public static boolean isUnderAgeOn(final LocalDate birthDate, final LocalDate nominationDate) {
|
||||
if (birthDate == null || nominationDate == null) {
|
||||
return false;
|
||||
}
|
||||
return birthDate.isAfter(nominationDate.minusYears(AGE_OF_MAJORITY));
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a Close Party command, or a Party Tran collection, targets a Party that is already
|
||||
* closed.
|
||||
*/
|
||||
public class PartyAlreadyClosedException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyAlreadyClosedException(final String partyCode) {
|
||||
super("error.msg.party.already.closed", "Party " + partyCode + " is already closed", partyCode);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a Close Party command is attempted while the Party's balance is still non-zero - the
|
||||
* remaining balance must be paid out (a Withdraw Deposite to zero) before the account can be closed.
|
||||
*/
|
||||
public class PartyHasBalanceException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyHasBalanceException(final String partyCode) {
|
||||
super("error.msg.party.has.balance", "Party " + partyCode + " still has a non-zero balance and cannot be closed", partyCode);
|
||||
}
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
|
||||
/**
|
||||
* A {@link RuntimeException} thrown when a DDS party resource is not found.
|
||||
*/
|
||||
public class PartyNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public PartyNotFoundException(final Long id) {
|
||||
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id);
|
||||
}
|
||||
|
||||
public PartyNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id, e);
|
||||
}
|
||||
|
||||
public PartyNotFoundException(final String partyCode) {
|
||||
super("error.msg.party.code.invalid", "Party with code " + partyCode + " does not exist", partyCode);
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when the savingsAccountNo given for a Party belongs to a different client than the Party's
|
||||
* own clientId - a Party can only be linked to its own client's savings account.
|
||||
*/
|
||||
public class PartySavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartySavingsAccountClientMismatchException(final String savingsAccountNo) {
|
||||
super("error.msg.party.savings.account.client.mismatch",
|
||||
"Savings account " + savingsAccountNo + " does not belong to this party's client", savingsAccountNo);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
|
||||
/**
|
||||
* Thrown when a Party's savingsAccountNo does not match any non-closed Savings Account.
|
||||
*/
|
||||
public class PartySavingsAccountNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public PartySavingsAccountNotFoundException(final String savingsAccountNo) {
|
||||
super("error.msg.party.savings.account.not.found", "No open savings account found with account number " + savingsAccountNo,
|
||||
savingsAccountNo);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a Party's subType is not the code of an active DDS Account Type.
|
||||
*
|
||||
* The Party screen's Sub Type is chosen from the DDS Account Type master, so an unknown code means
|
||||
* the caller invented one, and a deactivated code means the scheme has been retired - existing
|
||||
* parties on it keep running, but no new party may be opened under it.
|
||||
*/
|
||||
public class PartySubTypeNotActiveException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartySubTypeNotActiveException(final String subType, final boolean exists) {
|
||||
super(exists ? "error.msg.party.sub.type.not.active" : "error.msg.party.sub.type.invalid",
|
||||
exists ? "Account type " + subType + " has been deactivated, so no new party can be opened under it"
|
||||
: "There is no DDS account type with the code " + subType,
|
||||
subType);
|
||||
}
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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.dds.report.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* A generic tabular result shape shared by every DDS report - one column list plus one row list, both
|
||||
* keyed the same way, so a single report viewer screen can render any of them without a bespoke
|
||||
* result type per report.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public final class DdsReportData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String reportKey;
|
||||
private final boolean available;
|
||||
private final String message;
|
||||
private final List<String> columns;
|
||||
private final List<Map<String, Object>> rows;
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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.dds.transaction.data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public final class PartyTransactionData implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Long id;
|
||||
private final Long partyId;
|
||||
private final String partyCode;
|
||||
private final String partyName;
|
||||
private final Long agentId;
|
||||
private final String agentCode;
|
||||
private final LocalDate transactionDate;
|
||||
private final String transactionType;
|
||||
private final String direction;
|
||||
private final String transactionMode;
|
||||
private final String accountType;
|
||||
private final String accountNo;
|
||||
private final BigDecimal amount;
|
||||
private final String chequeNo;
|
||||
private final String tokenNo;
|
||||
private final String description;
|
||||
private final BigDecimal balanceAfterTransaction;
|
||||
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
|
||||
private final String voucherNo;
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* 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.dds.transaction.domain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* How money changed hands on a DDS entry: over the counter, by book transfer, or through clearing.
|
||||
*
|
||||
* It exists because the mode used to be a free-text field. The screen only ever offered these three,
|
||||
* but nothing behind the screen said so, and the column filled up with two spellings of the same
|
||||
* word - `Cash` from one path and `CASH` from another. A cash book that groups by mode then reports
|
||||
* the same day's takings twice under two headings, which is the kind of discrepancy someone spends
|
||||
* an afternoon chasing before finding it was never a discrepancy at all.
|
||||
*
|
||||
* So the list lives here, once, and both the validator and the write service read it from here. What
|
||||
* a caller sends is matched without regard to case or surrounding space, because an integration
|
||||
* sending `cash` is unambiguous and rejecting it would be pedantry; but what gets stored is always
|
||||
* the label below, so the column only ever holds one spelling of each.
|
||||
*
|
||||
* Only entries a person makes carry a mode. Interest, commission and maturity postings are the
|
||||
* system's own work - no cash, no cheque, nothing to record - and a transfer between two DDS accounts
|
||||
* moves nothing across the counter either, so those are left null rather than labelled.
|
||||
*/
|
||||
public enum DdsTransactionMode {
|
||||
|
||||
CASH("Cash"), //
|
||||
TRANSFER("Transfer"), //
|
||||
CLEARING("Clearing");
|
||||
|
||||
private final String label;
|
||||
|
||||
DdsTransactionMode(final String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/** The one spelling that is ever stored or shown for this mode. */
|
||||
public String getLabel() {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode a caller's text names, or null if it names none of them. Case and surrounding space
|
||||
* are ignored; anything else is a mode this scheme does not have.
|
||||
*/
|
||||
public static DdsTransactionMode fromLabel(final String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = value.trim();
|
||||
return Arrays.stream(values()).filter(mode -> mode.label.equalsIgnoreCase(trimmed)).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored form of what a caller sent: the canonical label when it names a mode, and the value
|
||||
* untouched when it does not.
|
||||
*
|
||||
* Unknown text is passed through rather than blanked because this is called after validation has
|
||||
* already refused it - a silent blanking here would only hide a bug in the order of the two.
|
||||
*/
|
||||
public static String canonicalOrOriginal(final String value) {
|
||||
final DdsTransactionMode mode = fromLabel(value);
|
||||
return mode == null ? value : mode.label;
|
||||
}
|
||||
|
||||
/** The labels, in the order the entry screen lists them - for error messages and for callers. */
|
||||
public static List<String> labels() {
|
||||
return Arrays.stream(values()).map(DdsTransactionMode::getLabel).toList();
|
||||
}
|
||||
|
||||
/** The labels as a reader sees them in a sentence: "Cash, Transfer, Clearing". */
|
||||
public static String labelsForMessage() {
|
||||
return String.join(", ", labels());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule as {@link #fromLabel}, expressed as a regular expression, for the validator to
|
||||
* apply - it reports a failed pattern with the parameter and message already filled in, which a
|
||||
* hand-rolled check cannot do as tidily.
|
||||
*
|
||||
* Built from the labels rather than written out, so the two ways of asking the same question
|
||||
* cannot answer it differently: add a mode above and both move together.
|
||||
*/
|
||||
public static String acceptedPattern() {
|
||||
return Arrays.stream(values()).map(mode -> Pattern.quote(mode.label)).collect(Collectors.joining("|", "(?i)\\s*(", ")\\s*"));
|
||||
}
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* 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.dds.transaction.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PostPersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||
|
||||
/**
|
||||
* A single posted entry on the DDS ledger: a Party's day's collection ("Party Tran", direction
|
||||
* CREDIT), one leg of a "Withdraw Deposite" transfer between two parties (the creditor leg CREDIT,
|
||||
* the debtor leg DEBIT), or an Agent's settlement of collected cash to its branch ("Agent Tran",
|
||||
* direction DEBIT - no {@link Party} involved, so {@code party} is null for this type).
|
||||
* {@code balanceAfterTransaction} holds the Party's balance for Party-linked rows, or the Agent's
|
||||
* balance for an Agent Tran row. Immutable once posted - like any transaction, it is reversed with
|
||||
* an opposing entry, not edited, so there is deliberately no update API for this entity.
|
||||
*/
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "m_dds_party_transaction")
|
||||
public class PartyTransaction extends AbstractAuditableCustom {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "party_id")
|
||||
private Party party;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "agent_id", nullable = false)
|
||||
private Agent agent;
|
||||
|
||||
@Column(name = "transaction_date", nullable = false)
|
||||
private LocalDate transactionDate;
|
||||
|
||||
@Column(name = "transaction_type", length = 30, nullable = false)
|
||||
private String transactionType;
|
||||
|
||||
@Column(name = "direction", length = 10, nullable = false)
|
||||
private String direction;
|
||||
|
||||
@Column(name = "transaction_mode", length = 20)
|
||||
private String transactionMode;
|
||||
|
||||
@Column(name = "account_type", length = 100)
|
||||
private String accountType;
|
||||
|
||||
@Column(name = "account_no", length = 50)
|
||||
private String accountNo;
|
||||
|
||||
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Column(name = "cheque_no", length = 40)
|
||||
private String chequeNo;
|
||||
|
||||
@Column(name = "token_no", length = 40)
|
||||
private String tokenNo;
|
||||
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal balanceAfterTransaction;
|
||||
|
||||
protected PartyTransaction() {
|
||||
//
|
||||
}
|
||||
|
||||
public PartyTransaction(final Party party, final Agent agent, final LocalDate transactionDate, final String transactionType,
|
||||
final String direction, final String transactionMode, final String accountType, final String accountNo,
|
||||
final BigDecimal amount, final String chequeNo, final String tokenNo, final String description,
|
||||
final BigDecimal balanceAfterTransaction) {
|
||||
this.party = party;
|
||||
this.agent = agent;
|
||||
this.transactionDate = transactionDate;
|
||||
this.transactionType = transactionType;
|
||||
this.direction = direction;
|
||||
this.transactionMode = transactionMode;
|
||||
this.accountType = accountType;
|
||||
this.accountNo = accountNo;
|
||||
this.amount = amount;
|
||||
this.chequeNo = chequeNo;
|
||||
this.tokenNo = tokenNo;
|
||||
this.description = description;
|
||||
this.balanceAfterTransaction = balanceAfterTransaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes this line against the operation that wrote it, so the operation's voucher number can be
|
||||
* stamped on it. The voucher number itself is not mapped here: it is written once, by SQL, after
|
||||
* the voucher is raised, and a mapped field would be written back as null by any later save.
|
||||
*/
|
||||
@PostPersist
|
||||
private void recordInVoucherScope() {
|
||||
DdsVoucherScope.recordPartyTransaction(getId());
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 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.dds.transaction.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when an Agent Tran settlement amount exceeds the Agent's current (collected but not yet
|
||||
* settled) balance.
|
||||
*/
|
||||
public class InsufficientAgentBalanceException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public InsufficientAgentBalanceException(final String agentCode) {
|
||||
super("error.msg.agent.insufficient.balance",
|
||||
"Agent " + agentCode + " does not have sufficient collected balance for this settlement", agentCode);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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.dds.transaction.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a Withdraw Deposite transfer would take the debtor Party's balance below zero.
|
||||
*/
|
||||
public class InsufficientPartyBalanceException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public InsufficientPartyBalanceException(final String partyCode) {
|
||||
super("error.msg.party.insufficient.balance", "Party " + partyCode + " does not have sufficient balance for this transfer",
|
||||
partyCode);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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.dds.transaction.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Thrown when a transaction is posted against an agent who does not collect for the party named on
|
||||
* it.
|
||||
*
|
||||
* The balances would still come out right, but the transaction would be filed under the wrong
|
||||
* agent - and every agent-wise report is built from exactly that. An agent would show collections
|
||||
* and transfers for customers who are not theirs, and the officer reading the report has no way to
|
||||
* tell.
|
||||
*/
|
||||
public class PartyNotCollectedByAgentException extends AbstractPlatformDomainRuleException {
|
||||
|
||||
public PartyNotCollectedByAgentException(final String partyCode, final String agentCode) {
|
||||
super("error.msg.party.not.collected.by.agent", "Party " + partyCode + " is not collected by agent " + agentCode,
|
||||
partyCode, agentCode);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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.dds.transaction.exception;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
|
||||
/**
|
||||
* A {@link RuntimeException} thrown when a DDS party transaction resource is not found.
|
||||
*/
|
||||
public class PartyTransactionNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||
|
||||
public PartyTransactionNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||
super("error.msg.partytransaction.id.invalid", "Party transaction with identifier " + id + " does not exist", id, e);
|
||||
}
|
||||
}
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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.dds.voucher;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What one DDS operation moved, gathered while it runs so a single voucher can be raised for it at
|
||||
* the end.
|
||||
*
|
||||
* A payout, an early closure, a posting run or a transfer each writes several ledger legs and
|
||||
* passbook lines from several places - the interest brought up to date, the recovery, the payout
|
||||
* itself. The voucher is the slip for the whole operation, not for each leg, so the legs are noted
|
||||
* here as they are written and turned into one voucher when the operation finishes.
|
||||
*
|
||||
* Held per thread, since an operation runs on one thread from start to finish - including a posting
|
||||
* run handed to a background thread, which opens its own scope there. Outside a scope nothing is
|
||||
* recorded.
|
||||
*/
|
||||
public final class DdsVoucherScope {
|
||||
|
||||
/** A voucher side with no ledger leg behind it - a move between two accounts on the same GL. */
|
||||
public record Line(String entryType, Long glAccountId, String partyName, String accountNo, BigDecimal amount, String description) {}
|
||||
|
||||
private static final ThreadLocal<DdsVoucherScope> CURRENT = new ThreadLocal<>();
|
||||
|
||||
private final Set<String> ledgerTransactionIds = new LinkedHashSet<>();
|
||||
private final List<Long> partyTransactionIds = new ArrayList<>();
|
||||
private final List<Line> extraLines = new ArrayList<>();
|
||||
|
||||
private DdsVoucherScope() {}
|
||||
|
||||
/** The scope now open on this thread, or null. */
|
||||
public static DdsVoucherScope current() {
|
||||
return CURRENT.get();
|
||||
}
|
||||
|
||||
/** Opens a scope on this thread. The caller must {@link #close} it. */
|
||||
public static DdsVoucherScope open() {
|
||||
final DdsVoucherScope scope = new DdsVoucherScope();
|
||||
CURRENT.set(scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
public static void close() {
|
||||
CURRENT.remove();
|
||||
}
|
||||
|
||||
/** Notes a ledger transaction written for the operation in progress. */
|
||||
public static void recordLedgerTransaction(final String transactionId) {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
if (scope != null && transactionId != null) {
|
||||
scope.ledgerTransactionIds.add(transactionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Notes a passbook line written for the operation in progress, so it can carry the voucher number. */
|
||||
public static void recordPartyTransaction(final Long partyTransactionId) {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
if (scope != null && partyTransactionId != null) {
|
||||
scope.partyTransactionIds.add(partyTransactionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a voucher side the ledger does not show - see {@link Line}. */
|
||||
public static void addLine(final Line line) {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
if (scope != null && line != null) {
|
||||
scope.extraLines.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
public Set<String> getLedgerTransactionIds() {
|
||||
return Collections.unmodifiableSet(this.ledgerTransactionIds);
|
||||
}
|
||||
|
||||
public List<Long> getPartyTransactionIds() {
|
||||
return Collections.unmodifiableList(this.partyTransactionIds);
|
||||
}
|
||||
|
||||
public List<Line> getExtraLines() {
|
||||
return Collections.unmodifiableList(this.extraLines);
|
||||
}
|
||||
}
|
||||
+19
-29
@@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.command.core.CommandPipeline;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeCreateCommand;
|
||||
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeDeleteCommand;
|
||||
import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeUpdateCommand;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -55,7 +50,7 @@ import org.springframework.stereotype.Component;
|
||||
public class PaymentTypeApiResource {
|
||||
|
||||
private final PaymentTypeReadService readPlatformService;
|
||||
private final CommandPipeline commandPipeline;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final DefaultToApiJsonSerializer<PaymentTypeData> jsonSerializer;
|
||||
|
||||
@GET
|
||||
@@ -79,14 +74,13 @@ public class PaymentTypeApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a Payment Type", description = "Creates a new Payment type")
|
||||
public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) {
|
||||
final var command = new PaymentTypeCreateCommand();
|
||||
public String createPaymentType(final PaymentTypeCreateRequest request) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createPaymentType()
|
||||
.withJson(jsonSerializer.serialize(request)).build();
|
||||
|
||||
command.setPayload(request);
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
|
||||
final Supplier<PaymentTypeCreateResponse> response = commandPipeline.send(command);
|
||||
|
||||
return response.get();
|
||||
return jsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -94,18 +88,16 @@ public class PaymentTypeApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update a Payment Type", description = "Updates a Payment Type")
|
||||
public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId,
|
||||
final PaymentTypeUpdateRequest request) {
|
||||
public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) {
|
||||
|
||||
request.setId(paymentTypeId);
|
||||
|
||||
final var command = new PaymentTypeUpdateCommand();
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().updatePaymentType(paymentTypeId)
|
||||
.withJson(jsonSerializer.serialize(request)).build();
|
||||
|
||||
command.setPayload(request);
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
|
||||
final Supplier<PaymentTypeUpdateResponse> response = commandPipeline.send(command);
|
||||
|
||||
return response.get();
|
||||
return jsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@@ -113,14 +105,12 @@ public class PaymentTypeApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Delete a Payment Type", description = "Deletes payment type")
|
||||
public PaymentTypeDeleteResponse deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
|
||||
public String deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) {
|
||||
|
||||
final var command = new PaymentTypeDeleteCommand();
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().deletePaymentType(paymentTypeId).build();
|
||||
|
||||
command.setPayload(PaymentTypeDeleteRequest.builder().id(paymentTypeId).build());
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
|
||||
final Supplier<PaymentTypeDeleteResponse> response = commandPipeline.send(command);
|
||||
|
||||
return response.get();
|
||||
return jsonSerializer.serialize(result);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-17
@@ -18,33 +18,50 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandHandler;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentTypeCreateCommandHandler implements CommandHandler<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
|
||||
@Service
|
||||
@CommandType(entity = "PAYMENTTYPE", action = "CREATE")
|
||||
public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||
|
||||
@Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback")
|
||||
@Override
|
||||
@Transactional
|
||||
public PaymentTypeCreateResponse handle(Command<PaymentTypeCreateRequest> command) {
|
||||
return paymentTypeWriteService.createPaymentType(command.getPayload());
|
||||
@Autowired
|
||||
public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public PaymentTypeCreateResponse fallback(Command<PaymentTypeCreateRequest> command, Throwable t) {
|
||||
return CommandHandler.super.fallback(command, t);
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
|
||||
|
||||
final PaymentTypeCreateRequest request = PaymentTypeCreateRequest.builder()
|
||||
.name(command.stringValueOfParameterNamed("name"))
|
||||
.description(command.stringValueOfParameterNamed("description"))
|
||||
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
|
||||
.position(command.longValueOfParameterNamed("position"))
|
||||
.codeName(command.stringValueOfParameterNamed("codeName"))
|
||||
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
|
||||
.build();
|
||||
|
||||
final PaymentTypeCreateResponse response = this.paymentTypeWriteService.createPaymentType(request);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(response.getResourceId()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-17
@@ -18,33 +18,41 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandHandler;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentTypeDeleteCommandHandler implements CommandHandler<PaymentTypeDeleteRequest, PaymentTypeDeleteResponse> {
|
||||
@Service
|
||||
@CommandType(entity = "PAYMENTTYPE", action = "DELETE")
|
||||
public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||
|
||||
@Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback")
|
||||
@Override
|
||||
@Transactional
|
||||
public PaymentTypeDeleteResponse handle(Command<PaymentTypeDeleteRequest> command) {
|
||||
return paymentTypeWriteService.deletePaymentType(command.getPayload());
|
||||
@Autowired
|
||||
public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public PaymentTypeDeleteResponse fallback(Command<PaymentTypeDeleteRequest> command, Throwable t) {
|
||||
return CommandHandler.super.fallback(command, t);
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final PaymentTypeDeleteRequest request = PaymentTypeDeleteRequest.builder().id(command.entityId()).build();
|
||||
|
||||
final PaymentTypeDeleteResponse response = this.paymentTypeWriteService.deletePaymentType(request);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(response.getResourceId()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+35
-17
@@ -18,33 +18,51 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.paymenttype.handler;
|
||||
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.command.core.Command;
|
||||
import org.apache.fineract.command.core.CommandHandler;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest;
|
||||
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentTypeUpdateCommandHandler implements CommandHandler<PaymentTypeUpdateRequest, PaymentTypeUpdateResponse> {
|
||||
@Service
|
||||
@CommandType(entity = "PAYMENTTYPE", action = "UPDATE")
|
||||
public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final PaymentTypeWriteService paymentTypeWriteService;
|
||||
|
||||
@Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback")
|
||||
@Override
|
||||
@Transactional
|
||||
public PaymentTypeUpdateResponse handle(Command<PaymentTypeUpdateRequest> command) {
|
||||
return paymentTypeWriteService.updatePaymentType(command.getPayload());
|
||||
@Autowired
|
||||
public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public PaymentTypeUpdateResponse fallback(Command<PaymentTypeUpdateRequest> command, Throwable t) {
|
||||
return CommandHandler.super.fallback(command, t);
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined");
|
||||
|
||||
final PaymentTypeUpdateRequest request = PaymentTypeUpdateRequest.builder()
|
||||
.id(command.entityId())
|
||||
.name(command.stringValueOfParameterNamed("name"))
|
||||
.description(command.stringValueOfParameterNamed("description"))
|
||||
.isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment"))
|
||||
.position(command.longValueOfParameterNamed("position"))
|
||||
.codeName(command.stringValueOfParameterNamed("codeName"))
|
||||
.isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE)
|
||||
.build();
|
||||
|
||||
final PaymentTypeUpdateResponse response = this.paymentTypeWriteService.updatePaymentType(request);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(response.getResourceId()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+14
-1
@@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType {
|
||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), //
|
||||
TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
@@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType {
|
||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||
case 500 -> PARTIAL_REINVEST;
|
||||
case 600 -> TRANSFER_TO_GL;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
@@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType {
|
||||
return this.equals(PARTIAL_REINVEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash.
|
||||
* Only the fixed-deposit premature-closure handler implements it -- see
|
||||
* DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it
|
||||
* in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the
|
||||
* terminal "pay the balance out in cash" branch.
|
||||
*/
|
||||
public boolean isTransferToGl() {
|
||||
return this.equals(TRANSFER_TO_GL);
|
||||
}
|
||||
|
||||
// TODO: do we really need this?!?
|
||||
public static Object[] integerValues() {
|
||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||
|
||||
+25
-3
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
|
||||
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
|
||||
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
|
||||
public static final String tenureInMonthsParamName = "tenureInMonths";
|
||||
public static final String tenureInDaysParamName = "tenureInDays";
|
||||
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
|
||||
|
||||
// Preclosure parameters
|
||||
@@ -142,6 +143,9 @@ public final class DepositsApiConstants {
|
||||
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
|
||||
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
|
||||
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
|
||||
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
|
||||
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
|
||||
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
|
||||
|
||||
public static final String depositAmountParamName = "depositAmount";
|
||||
public static final String depositMinAmountParamName = "minDepositAmount";
|
||||
@@ -208,7 +212,18 @@ public final class DepositsApiConstants {
|
||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||
public static final String transferDescriptionParamName = "transferDescription";
|
||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||
// Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are
|
||||
// credited straight to this GL instead of a savings account or cash.
|
||||
public static final String toGlAccountIdParamName = "toGlAccountId";
|
||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
|
||||
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
|
||||
// per-request flag rather than a stored account field.
|
||||
public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty";
|
||||
// Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike
|
||||
// waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their
|
||||
// penalty and why.
|
||||
public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId";
|
||||
public static final String savingsAccounts = "savingsAccounts";
|
||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||
@@ -237,7 +252,7 @@ public final class DepositsApiConstants {
|
||||
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
|
||||
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
|
||||
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
|
||||
SavingsApiConstants.taxGroupIdParamName));
|
||||
SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
|
||||
|
||||
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
|
||||
@@ -247,11 +262,13 @@ public final class DepositsApiConstants {
|
||||
|
||||
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
||||
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
|
||||
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
|
||||
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
|
||||
renewalGracePeriodDaysParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
|
||||
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
|
||||
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
|
||||
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
|
||||
renewalGracePeriodDaysParamName));
|
||||
|
||||
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
|
||||
@@ -373,6 +390,8 @@ public final class DepositsApiConstants {
|
||||
fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
|
||||
fixedDepositRequestData.add("feeToIncomeAccountMappings");
|
||||
fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
|
||||
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
|
||||
fixedDepositRequestData.add("otherGlMappingsMap");
|
||||
return fixedDepositRequestData;
|
||||
}
|
||||
|
||||
@@ -484,6 +503,8 @@ public final class DepositsApiConstants {
|
||||
recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
|
||||
recurringDepositRequestData.add("feeToIncomeAccountMappings");
|
||||
recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
|
||||
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
|
||||
recurringDepositRequestData.add("otherGlMappingsMap");
|
||||
return recurringDepositRequestData;
|
||||
}
|
||||
|
||||
@@ -511,6 +532,7 @@ public final class DepositsApiConstants {
|
||||
fixedDepositInterestCalculationData.add(principalAmountParamName);
|
||||
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
|
||||
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
|
||||
// Exact calendar-based calculation params
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 lombok.Getter;
|
||||
|
||||
/**
|
||||
* Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not
|
||||
* just the ones that waive the penalty, so the audit trail always shows a reason rather than only
|
||||
* recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this
|
||||
* type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a
|
||||
* free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the
|
||||
* server regardless of what a client happened to also send for the plain waive-penalty flag.
|
||||
*/
|
||||
@Getter
|
||||
public enum PrematureClosureReasonType {
|
||||
|
||||
INVALID(0, "prematureClosureReasonType.invalid", false), //
|
||||
NORMAL(100, "prematureClosureReasonType.normal", false), //
|
||||
PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), //
|
||||
CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), //
|
||||
DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), //
|
||||
NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
/** True when this reason, by itself, waives the pre-closure penalty regardless of any manual
|
||||
* waive-penalty flag also sent -- see the class javadoc. */
|
||||
private final boolean autoWaived;
|
||||
|
||||
PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) {
|
||||
this.value = value;
|
||||
this.code = code;
|
||||
this.autoWaived = autoWaived;
|
||||
}
|
||||
|
||||
public static PrematureClosureReasonType fromInt(final Integer v) {
|
||||
if (v == null) {
|
||||
return INVALID;
|
||||
}
|
||||
return switch (v) {
|
||||
case 100 -> NORMAL;
|
||||
case 200 -> PREMATURE_EXTENSION;
|
||||
case 300 -> CONVERSION_TO_ANOTHER_TD;
|
||||
case 400 -> DECEASED_SETTLEMENT;
|
||||
case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE;
|
||||
default -> INVALID;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean isInvalid() {
|
||||
return this.equals(INVALID);
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -278,7 +278,6 @@ public class SavingsApiConstants {
|
||||
// Additional custom fields
|
||||
public static final String balanceFrequencyParamName = "balanceFrequency";
|
||||
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
|
||||
public static final String productCategoryParamName = "productCategory";
|
||||
|
||||
// Current Product — new UI fields
|
||||
public static final String balanceSwapOptionParamName = "balanceSwapOption";
|
||||
@@ -287,6 +286,15 @@ public class SavingsApiConstants {
|
||||
public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod";
|
||||
public static final String validForPeriodParamName = "validForPeriod";
|
||||
public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing";
|
||||
// Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an
|
||||
// amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either.
|
||||
public static final String odMinLimitParamName = "odMinLimit";
|
||||
public static final String odSecurityTypeParamName = "odSecurityType";
|
||||
// Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the
|
||||
// limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess.
|
||||
public static final String odExcessAllowedParamName = "odExcessAllowed";
|
||||
public static final String odExcessLimitPercentParamName = "odExcessLimitPercent";
|
||||
public static final String odPenalRateParamName = "odPenalRate";
|
||||
|
||||
// Status of Account fields
|
||||
public static final String accountStatusParamName = "accountStatus";
|
||||
|
||||
+10
-1
@@ -144,7 +144,6 @@ public final class SavingsProductData implements Serializable {
|
||||
@Getter @Setter private Boolean enableLockinPeriod;
|
||||
@Getter @Setter private String balanceFrequency;
|
||||
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
|
||||
@Getter @Setter private String productCategory;
|
||||
|
||||
// New current product fields
|
||||
@Getter @Setter private Boolean balanceSwapOption;
|
||||
@@ -153,6 +152,11 @@ public final class SavingsProductData implements Serializable {
|
||||
@Getter @Setter private String avgBalanceCalcPeriod;
|
||||
@Getter @Setter private String validForPeriod;
|
||||
@Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing;
|
||||
@Getter @Setter private BigDecimal odMinLimit;
|
||||
@Getter @Setter private String odSecurityType;
|
||||
@Getter @Setter private Boolean odExcessAllowed;
|
||||
@Getter @Setter private BigDecimal odExcessLimitPercent;
|
||||
@Getter @Setter private BigDecimal odPenalRate;
|
||||
|
||||
// Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects
|
||||
@Getter @Setter private Map<String, Object> accountFacilityConfig;
|
||||
@@ -383,6 +387,11 @@ public final class SavingsProductData implements Serializable {
|
||||
target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod());
|
||||
target.setValidForPeriod(source.getValidForPeriod());
|
||||
target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing());
|
||||
target.setOdMinLimit(source.getOdMinLimit());
|
||||
target.setOdSecurityType(source.getOdSecurityType());
|
||||
target.setOdExcessAllowed(source.getOdExcessAllowed());
|
||||
target.setOdExcessLimitPercent(source.getOdExcessLimitPercent());
|
||||
target.setOdPenalRate(source.getOdPenalRate());
|
||||
target.setOtherGlMappingsMap(source.getOtherGlMappingsMap());
|
||||
target.setAccountFacilityConfig(source.getAccountFacilityConfig());
|
||||
target.setAccountFacilityLimits(source.getAccountFacilityLimits());
|
||||
|
||||
+4
@@ -801,6 +801,10 @@ public final class SavingsEnumerations {
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
||||
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
||||
break;
|
||||
case TRANSFER_TO_GL:
|
||||
optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(),
|
||||
DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account");
|
||||
break;
|
||||
}
|
||||
return optionData;
|
||||
}
|
||||
|
||||
-252
@@ -1,252 +0,0 @@
|
||||
/**
|
||||
* 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.organisation.agent.domain;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.dds.DdsCommands;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/**
|
||||
* What a collection agent is holding and what they are owed.
|
||||
*
|
||||
* Two separate pots, and keeping them separate is the point: the cash balance is the bank's money
|
||||
* sitting in the agent's bag, and the commission is the agent's own earnings. Mixing them up would
|
||||
* either lose the bank's cash or pay an agent twice, so each of these checks one and leaves the
|
||||
* other alone.
|
||||
*/
|
||||
class AgentTest {
|
||||
|
||||
private Agent agent(final String extraJson) {
|
||||
final Client client = Mockito.mock(Client.class);
|
||||
final String json = """
|
||||
{
|
||||
"clientId": 1, "agentNameId": "Test Agent", "agentAddress1": "Somewhere",
|
||||
"agentCity": "Bhopal", "agentPinCode": "462016",
|
||||
"agentSavingAccountType": "SD Product", "agentSavingAccountNo": "002002000100011",
|
||||
"agentOpeningDate": "01 September 2026", "agentBirthDate": "01 January 1990",
|
||||
"dateFormat": "dd MMMM yyyy", "locale": "en"
|
||||
%s
|
||||
}
|
||||
""".formatted(extraJson);
|
||||
return Agent.fromJson(client, "AG0001", office("Head Office"), DdsCommands.of(json));
|
||||
}
|
||||
|
||||
private Office office(final String name) {
|
||||
final Office office = Mockito.mock(Office.class);
|
||||
Mockito.lenient().when(office.getName()).thenReturn(name);
|
||||
return office;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Cash the agent is carrying")
|
||||
class Cash {
|
||||
|
||||
@Test
|
||||
void aCollectionRaisesTheCashTheAgentHolds() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
assertEquals(new BigDecimal("100"), agent.credit(new BigDecimal("100")));
|
||||
assertEquals(new BigDecimal("400"), agent.credit(new BigDecimal("300")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settlingAtABranchLowersIt() {
|
||||
final Agent agent = agent("");
|
||||
agent.credit(new BigDecimal("500"));
|
||||
|
||||
assertEquals(new BigDecimal("200"), agent.settle(new BigDecimal("300")));
|
||||
}
|
||||
|
||||
/** What the Close Account guard reads: money still out with an agent stops them being closed. */
|
||||
@Test
|
||||
void anAgentHoldsCashUntilEverythingIsSettled() {
|
||||
final Agent agent = agent("");
|
||||
assertFalse(agent.holdsCash());
|
||||
|
||||
agent.credit(new BigDecimal("500"));
|
||||
assertTrue(agent.holdsCash());
|
||||
|
||||
agent.settle(new BigDecimal("500"));
|
||||
assertFalse(agent.holdsCash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpeningBalanceIsCashTheAgentAlreadyHad() {
|
||||
final Agent agent = agent(", \"agentOpeningBalance\": 750");
|
||||
|
||||
assertEquals(new BigDecimal("750"), agent.getAgentCurrentBalance());
|
||||
assertEquals(new BigDecimal("750"), agent.getAgentOpeningBalance());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Commission the agent has earned")
|
||||
class Commission {
|
||||
|
||||
private static final LocalDate THROUGH = LocalDate.of(2026, 9, 30);
|
||||
|
||||
/**
|
||||
* Accruing records what is owed and the TDS withheld from it, and stamps how far commission
|
||||
* has been worked out - the stamp being what stops the same period being paid twice.
|
||||
*/
|
||||
@Test
|
||||
void accruingRecordsWhatIsOwedAndTheTaxWithheld() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
agent.accrueCommission(new BigDecimal("13.50"), new BigDecimal("1.50"), THROUGH);
|
||||
|
||||
assertEquals(new BigDecimal("13.50"), agent.getCommission());
|
||||
assertEquals(new BigDecimal("1.50"), agent.getTdsAmount());
|
||||
assertEquals(THROUGH, agent.getLastCommissionDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void payingItOutLeavesNothingOwed() {
|
||||
final Agent agent = agent("");
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), agent.payOutCommission(new BigDecimal("15.00")));
|
||||
assertFalse(agent.isOwedCommission());
|
||||
}
|
||||
|
||||
/** The guard against paying twice: a second payout comes off a figure already at zero. */
|
||||
@Test
|
||||
void aSecondPayoutOfTheSameCommissionFindsNothingLeft() {
|
||||
final Agent agent = agent("");
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
agent.payOutCommission(new BigDecimal("15.00"));
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), agent.getCommission());
|
||||
}
|
||||
|
||||
/** Commission is the agent's own earnings, so paying it must not touch the bank's cash. */
|
||||
@Test
|
||||
void payingCommissionDoesNotTouchTheCashTheAgentIsCarrying() {
|
||||
final Agent agent = agent("");
|
||||
agent.credit(new BigDecimal("10000"));
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
|
||||
agent.payOutCommission(new BigDecimal("15.00"));
|
||||
|
||||
assertEquals(new BigDecimal("10000"), agent.getAgentCurrentBalance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentIsOwedCommissionUntilItIsPaid() {
|
||||
final Agent agent = agent("");
|
||||
assertFalse(agent.isOwedCommission());
|
||||
|
||||
agent.accrueCommission(new BigDecimal("15.00"), BigDecimal.ZERO, THROUGH);
|
||||
assertTrue(agent.isOwedCommission());
|
||||
}
|
||||
|
||||
/**
|
||||
* The four standing amounts the Agent Commission Statement subtracts from gross commission.
|
||||
*
|
||||
* Set by editing an agent, not by creating one - they are figures the bank agrees with an
|
||||
* agent after the fact, so a create request carrying them is ignored rather than trusted.
|
||||
*/
|
||||
@Test
|
||||
void deductionsAreTheSumOfTheFourStandingAmounts() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
agent.update(DdsCommands.of("""
|
||||
{"deductCommission": 1, "agentDeposit": 2, "festivalAdvance": 3, "otherDeduction": 4,
|
||||
"dateFormat": "dd MMMM yyyy", "locale": "en"}
|
||||
"""));
|
||||
|
||||
assertEquals(new BigDecimal("10"), agent.totalDeductions());
|
||||
}
|
||||
|
||||
/** A create request cannot set them, whatever it sends - they are agreed later, not at opening. */
|
||||
@Test
|
||||
void deductionsAreNotTakenFromACreateRequest() {
|
||||
final Agent agent = agent(", \"deductCommission\": 1, \"agentDeposit\": 2,"
|
||||
+ " \"festivalAdvance\": 3, \"otherDeduction\": 4");
|
||||
|
||||
assertEquals(0, agent.totalDeductions().compareTo(BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentWithNoDeductionsSubtractsNothing() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
assertEquals(0, agent.totalDeductions().compareTo(BigDecimal.ZERO));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Which branch the agent belongs to")
|
||||
class Branch {
|
||||
|
||||
/**
|
||||
* The branch name is written from the office rather than set on its own, so the two cannot
|
||||
* disagree - a renamed branch reads correctly without the agent being touched.
|
||||
*/
|
||||
@Test
|
||||
void theBranchNameComesFromTheOffice() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
assertEquals("Head Office", agent.getHomeBranch());
|
||||
|
||||
agent.setOffice(office("Borivali"));
|
||||
|
||||
assertEquals("Borivali", agent.getHomeBranch());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentWithNoOfficeHasNoBranchName() {
|
||||
final Agent agent = agent("");
|
||||
|
||||
agent.setOffice(null);
|
||||
|
||||
assertNull(agent.getHomeBranch());
|
||||
assertNull(agent.officeId());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Closing")
|
||||
class Closing {
|
||||
|
||||
@Test
|
||||
void closingAlsoStopsTheAgentCollecting() {
|
||||
final Agent agent = agent("");
|
||||
assertFalse(agent.isClosed());
|
||||
assertTrue(agent.isActive());
|
||||
|
||||
agent.close(LocalDate.of(2026, 12, 1));
|
||||
|
||||
assertTrue(agent.isClosed());
|
||||
assertFalse(agent.isActive());
|
||||
assertEquals(LocalDate.of(2026, 12, 1), agent.getCloseDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 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.dds;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
|
||||
/**
|
||||
* Builds the {@link JsonCommand} a DDS entity is created from, so a test can make a real Party or
|
||||
* Agent the same way the API does rather than reaching into their fields.
|
||||
*
|
||||
* The alternative - setting fields by reflection - would test a state the application can never
|
||||
* actually produce, which is the opposite of useful.
|
||||
*/
|
||||
public final class DdsCommands {
|
||||
|
||||
private static final FromJsonHelper JSON_HELPER = new FromJsonHelper();
|
||||
|
||||
private DdsCommands() {}
|
||||
|
||||
/**
|
||||
* A command carrying the given JSON. Dates are read with the {@code dateFormat} and {@code locale}
|
||||
* the caller puts in that JSON, exactly as a request does.
|
||||
*/
|
||||
public static JsonCommand of(final String json) {
|
||||
final JsonElement parsed = JSON_HELPER.parse(json);
|
||||
return JsonCommand.from(json, parsed, JSON_HELPER, null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* 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.dds.calculation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What an agent is paid on a period's collections.
|
||||
*
|
||||
* The first two figures here are the ones the live system actually produced - ₹500 collected at 3%
|
||||
* paid ₹15, and ₹1,500 paid ₹45 - so if the formula ever moves, these fail rather than the change
|
||||
* going out unnoticed.
|
||||
*/
|
||||
class DdsCommissionCalculationTest {
|
||||
|
||||
private static final BigDecimal THREE_PERCENT = new BigDecimal("3");
|
||||
private static final BigDecimal NONE = BigDecimal.ZERO;
|
||||
|
||||
@Test
|
||||
void threePercentOfFiveHundredIsFifteen() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("500"), THREE_PERCENT, false, null, NONE);
|
||||
|
||||
assertEquals(new BigDecimal("15.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("15.00"), commission.net());
|
||||
assertTrue(commission.isPayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void threePercentOfFifteenHundredIsFortyFive() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1500"), THREE_PERCENT, false, null, NONE);
|
||||
|
||||
assertEquals(new BigDecimal("45.00"), commission.net());
|
||||
}
|
||||
|
||||
/** Tax comes off the gross, not off the collections. */
|
||||
@Test
|
||||
void taxIsWithheldFromTheGrossWhenTheAgentIsLiable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1000"), THREE_PERCENT, true,
|
||||
new BigDecimal("10"), NONE);
|
||||
|
||||
assertEquals(new BigDecimal("30.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("3.00"), commission.tds());
|
||||
assertEquals(new BigDecimal("27.00"), commission.net());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTaxIsWithheldFromAnAgentWhoIsNotLiable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1000"), THREE_PERCENT, false,
|
||||
new BigDecimal("10"), NONE);
|
||||
|
||||
assertEquals(BigDecimal.ZERO, commission.tds());
|
||||
assertEquals(new BigDecimal("30.00"), commission.net());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBankRecoversItsStandingDeductionsFromWhatIsLeft() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("1000"), THREE_PERCENT, true,
|
||||
new BigDecimal("10"), new BigDecimal("7"));
|
||||
|
||||
assertEquals(new BigDecimal("30.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("3.00"), commission.tds());
|
||||
assertEquals(new BigDecimal("7"), commission.deductions());
|
||||
assertEquals(new BigDecimal("20.00"), commission.net());
|
||||
}
|
||||
|
||||
/**
|
||||
* The deductions can swallow the whole period. Nothing is owed, and the agent is not put into
|
||||
* debt over it - so the run pays nothing rather than carrying a negative figure forward.
|
||||
*/
|
||||
@Test
|
||||
void deductionsLargerThanTheCommissionLeaveNothingPayable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("500"), THREE_PERCENT, false, null,
|
||||
new BigDecimal("50"));
|
||||
|
||||
assertEquals(new BigDecimal("15.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("-35.00"), commission.net());
|
||||
assertFalse(commission.isPayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deductionsExactlyEqualToTheCommissionLeaveNothingPayable() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("500"), THREE_PERCENT, false, null,
|
||||
new BigDecimal("15"));
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), commission.net());
|
||||
assertFalse(commission.isPayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentWhoCollectedNothingEarnsNothing() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(BigDecimal.ZERO, THREE_PERCENT, false, null, NONE);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), commission.net());
|
||||
assertFalse(commission.isPayable());
|
||||
}
|
||||
|
||||
/** Paise are rounded half-up, so a fraction of a paisa never goes missing or gets invented. */
|
||||
@Test
|
||||
void grossIsRoundedToPaise() {
|
||||
// 3.5% of 333 = 11.655, which rounds to 11.66 rather than being truncated to 11.65.
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("333"), new BigDecimal("3.5"), false, null,
|
||||
NONE);
|
||||
|
||||
assertEquals(new BigDecimal("11.66"), commission.gross());
|
||||
}
|
||||
|
||||
/** Everything is kept, not just the net - an agent asking why their pay was short needs it all. */
|
||||
@Test
|
||||
void theWholeBreakdownIsKept() {
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(new BigDecimal("2000"), new BigDecimal("5"), true,
|
||||
new BigDecimal("10"), new BigDecimal("25"));
|
||||
|
||||
assertEquals(new BigDecimal("2000"), commission.collectionVolume());
|
||||
assertEquals(new BigDecimal("5"), commission.ratePercent());
|
||||
assertEquals(new BigDecimal("100.00"), commission.gross());
|
||||
assertEquals(new BigDecimal("10.00"), commission.tds());
|
||||
assertEquals(new BigDecimal("25"), commission.deductions());
|
||||
assertEquals(new BigDecimal("65.00"), commission.net());
|
||||
}
|
||||
}
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* 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.dds.calculation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation.Movement;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What a DDS account earns over a period.
|
||||
*
|
||||
* Interest is on the daily product - the balance summed over the days it was actually held - which
|
||||
* is what makes a daily-deposit account different from an ordinary one. Paying on the closing
|
||||
* balance for the whole period instead was the bug this replaced, and it paid customers roughly
|
||||
* double what they were owed, so the sums below are worth pinning down exactly.
|
||||
*/
|
||||
class DdsInterestCalculationTest {
|
||||
|
||||
private static final BigDecimal TWO_PERCENT = new BigDecimal("2");
|
||||
private static final LocalDate SEP_1 = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate SEP_30 = LocalDate.of(2026, 9, 30);
|
||||
|
||||
private static Movement in(final int day, final String amount) {
|
||||
return new Movement(LocalDate.of(2026, 9, day), new BigDecimal(amount));
|
||||
}
|
||||
|
||||
private static Movement out(final int day, final String amount) {
|
||||
return new Movement(LocalDate.of(2026, 9, day), new BigDecimal(amount).negate());
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("The balance summed over the days it was held")
|
||||
class DailyProduct {
|
||||
|
||||
/** An untouched account is simply its balance for every day of the period. */
|
||||
@Test
|
||||
void anUntouchedBalanceEarnsForEveryDay() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("1000"), List.of(), SEP_1, SEP_30);
|
||||
|
||||
// 29 days from the 1st to the 30th, at 1,000.
|
||||
assertEquals(new BigDecimal("29000"), product);
|
||||
}
|
||||
|
||||
/** A deposit earns from the day it was made, not the day after. */
|
||||
@Test
|
||||
void aDepositEarnsFromTheDayItWasMade() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO, List.of(in(30, "100")), SEP_1, SEP_30);
|
||||
|
||||
// Nothing held until the 30th, then 100 for that one day.
|
||||
assertEquals(new BigDecimal("100"), product);
|
||||
}
|
||||
|
||||
@Test
|
||||
void eachDepositEarnsOnlyForTheDaysAfterIt() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO,
|
||||
List.of(in(1, "100"), in(11, "100"), in(21, "100")), SEP_1, SEP_30);
|
||||
|
||||
// The 1st's 100 is in the opening balance (dated on the from date), so it earns 29 days.
|
||||
// 200 from the 11th to the 20th is 10 days; 300 from the 21st to the 30th is 10 days.
|
||||
// 100x29 + ... - worked through: 2900 + ... see the assertion.
|
||||
assertEquals(new BigDecimal("5900"), product);
|
||||
}
|
||||
|
||||
/** A payout stops earning from the day it is taken. */
|
||||
@Test
|
||||
void aPayoutStopsEarningFromThatDay() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("1000"), List.of(out(11, "500")), SEP_1, SEP_30);
|
||||
|
||||
// 1,000 for the 2nd to the 10th (9 days), then 500 from the 11th to the 30th (20 days).
|
||||
assertEquals(new BigDecimal("19000"), product);
|
||||
}
|
||||
|
||||
/** Everything dated on or before the start is the balance the period opens with. */
|
||||
@Test
|
||||
void movementsBeforeThePeriodMakeUpTheOpeningBalance() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO,
|
||||
List.of(new Movement(LocalDate.of(2026, 8, 15), new BigDecimal("500")), in(1, "500")), SEP_1, SEP_30);
|
||||
|
||||
// Both are on or before the 1st, so the period opens at 1,000 and nothing moves after.
|
||||
assertEquals(new BigDecimal("29000"), product);
|
||||
}
|
||||
|
||||
/** A movement after the period has not happened yet as far as this run is concerned. */
|
||||
@Test
|
||||
void movementsAfterThePeriodAreIgnored() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("1000"),
|
||||
List.of(new Movement(LocalDate.of(2026, 10, 5), new BigDecimal("9999"))), SEP_1, SEP_30);
|
||||
|
||||
assertEquals(new BigDecimal("29000"), product);
|
||||
}
|
||||
|
||||
/** Several movements on one day all land before the day is counted. */
|
||||
@Test
|
||||
void everythingOnOneDayIsAppliedBeforeThatDayIsCounted() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO,
|
||||
List.of(in(30, "100"), in(30, "100"), in(30, "100")), SEP_1, SEP_30);
|
||||
|
||||
assertEquals(new BigDecimal("300"), product);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyAccountEarnsNothing() {
|
||||
assertEquals(BigDecimal.ZERO, DdsInterestCalculation.dailyProduct(BigDecimal.ZERO, List.of(), SEP_1, SEP_30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPeriodOfNoDaysEarnsNothing() {
|
||||
assertEquals(BigDecimal.ZERO, DdsInterestCalculation.dailyProduct(new BigDecimal("1000"), List.of(), SEP_1, SEP_1));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Interest on the product")
|
||||
class Interest {
|
||||
|
||||
/**
|
||||
* The figures the live system produced and that were checked by hand: ₹10,000 held through a
|
||||
* month at 2% earned ₹52.60, and the same balance plus a month's interest earned ₹60.92 over
|
||||
* a longer stretch. Nothing in the sum may move these.
|
||||
*/
|
||||
@Test
|
||||
void tenThousandThroughSeptemberAtTwoPercentEarnsFiftyTwoSixty() {
|
||||
final BigDecimal product = DdsInterestCalculation.dailyProduct(new BigDecimal("10000"), List.of(),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 30));
|
||||
|
||||
// 60 days at 10,000 = 600,000. 600,000 x 2 / 100 / 365 = 32.876...
|
||||
assertEquals(new BigDecimal("600000"), product);
|
||||
assertEquals(new BigDecimal("32.88"), DdsInterestCalculation.interestOn(product, TWO_PERCENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKnownProductGivesAKnownInterest() {
|
||||
// 960,000 x 2 / 100 / 365 = 52.6027... which is the 52.60 the live run posted.
|
||||
assertEquals(new BigDecimal("52.60"), DdsInterestCalculation.interestOn(new BigDecimal("960000"), TWO_PERCENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aZeroRateEarnsNothing() {
|
||||
assertEquals(new BigDecimal("0.00"), DdsInterestCalculation.interestOn(new BigDecimal("29000"), BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aZeroProductEarnsNothing() {
|
||||
assertEquals(new BigDecimal("0.00"), DdsInterestCalculation.interestOn(BigDecimal.ZERO, TWO_PERCENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interestIsRoundedToPaise() {
|
||||
// 1,000 x 2 / 100 / 365 = 0.0547... which rounds to 0.05.
|
||||
assertEquals(new BigDecimal("0.05"), DdsInterestCalculation.interestOn(new BigDecimal("1000"), TWO_PERCENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the daily product: an account paid into daily earns far less than the
|
||||
* same closing balance held all period, because most of the money arrived late.
|
||||
*/
|
||||
@Test
|
||||
void payingInDailyEarnsFarLessThanHoldingTheSameBalanceAllPeriod() {
|
||||
final List<Movement> daily = List.of(in(1, "100"), in(2, "100"), in(3, "100"), in(4, "100"), in(5, "100"));
|
||||
|
||||
final BigDecimal paidInDaily = DdsInterestCalculation.dailyProduct(BigDecimal.ZERO, daily, SEP_1,
|
||||
LocalDate.of(2026, 9, 5));
|
||||
final BigDecimal heldThroughout = DdsInterestCalculation.dailyProduct(new BigDecimal("500"), List.of(), SEP_1,
|
||||
LocalDate.of(2026, 9, 5));
|
||||
|
||||
// Paid in daily: 200 on the 2nd, 300 on the 3rd, 400 on the 4th, 500 on the 5th. The
|
||||
// 1st's instalment is dated on the from date, so it makes up the opening balance rather
|
||||
// than earning a day of its own.
|
||||
assertEquals(new BigDecimal("1400"), paidInDaily);
|
||||
|
||||
// The same 500 held from the start earns four full days on the whole amount.
|
||||
assertEquals(new BigDecimal("2000"), heldThroughout);
|
||||
}
|
||||
}
|
||||
}
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* 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.dds.party.domain;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.dds.DdsCommands;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/**
|
||||
* What a DDS customer's account owes them, and how it changes.
|
||||
*
|
||||
* Every figure here is money a customer is owed, so each of these is a sum that has to come out
|
||||
* exactly - not approximately. The maturity figures are the ones quoted to the customer when the
|
||||
* account is opened, and the interest ones were verified by hand against the live system before
|
||||
* being written down here, so a change that moves them will fail rather than pass quietly.
|
||||
*/
|
||||
class PartyTest {
|
||||
|
||||
private static final BigDecimal HUNDRED_A_DAY = new BigDecimal("100");
|
||||
|
||||
private Party party(final String extraJson) {
|
||||
final Client client = Mockito.mock(Client.class);
|
||||
final Agent agent = Mockito.mock(Agent.class);
|
||||
final String json = """
|
||||
{
|
||||
"clientId": 1, "agentId": 1, "subType": "DDS",
|
||||
"partyName": "Test Customer", "address1": "Somewhere", "city": "Bhopal",
|
||||
"pincode": "462016", "mobileNoSms": "9000000000", "operation": "Self",
|
||||
"adultMinor": "Adult", "openingDate": "01 September 2026",
|
||||
"dateFormat": "dd MMMM yyyy", "locale": "en"
|
||||
%s
|
||||
}
|
||||
""".formatted(extraJson);
|
||||
return Party.fromJson(client, agent, "PT0001", DdsCommands.of(json));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("What the customer is quoted at opening")
|
||||
class Maturity {
|
||||
|
||||
/**
|
||||
* A day's deposit earns interest for every day it is held, so the first instalment earns for
|
||||
* the whole term and the last for one day - n(n+1)/2 instalment-days in total. This is the
|
||||
* figure quoted to the customer, and the one the interest runs have to add up to.
|
||||
*/
|
||||
@Test
|
||||
void interestIsOnEachInstalmentForTheDaysItIsHeld() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90, \"interestRate\": 2");
|
||||
|
||||
// 90 x 100 = 9,000 principal. Instalment-days = 90 x 91 / 2 = 4,095.
|
||||
// Interest = 100 x 4095 x 2 / 100 / 365 = 22.4383...
|
||||
assertEquals(new BigDecimal("9022.44"), party.getMaturityAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aZeroRateReturnsTheDepositsAndNothingMore() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90, \"interestRate\": 0");
|
||||
|
||||
assertEquals(new BigDecimal("9000.00"), party.getMaturityAmount());
|
||||
}
|
||||
|
||||
/** Nothing is quoted for a term nobody has agreed to yet. */
|
||||
@Test
|
||||
void noTermMeansNoMaturityAmount() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 0, \"interestRate\": 2");
|
||||
|
||||
assertEquals(BigDecimal.ZERO, party.getMaturityAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDailyAmountMeansNoMaturityAmount() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 0, \"days\": 90, \"interestRate\": 2");
|
||||
|
||||
assertEquals(BigDecimal.ZERO, party.getMaturityAmount());
|
||||
}
|
||||
|
||||
/** The rate the business settled on when none is sent - see Party's DEFAULT_INTEREST_RATE. */
|
||||
@Test
|
||||
void aRateIsAppliedWhenNoneWasSent() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertEquals(new BigDecimal("2"), party.getInterestRate());
|
||||
assertEquals(new BigDecimal("9022.44"), party.getMaturityAmount());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Collections and payouts")
|
||||
class Balance {
|
||||
|
||||
@Test
|
||||
void aCollectionRaisesTheBalanceAndReturnsIt() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertEquals(new BigDecimal("100"), party.credit(HUNDRED_A_DAY));
|
||||
assertEquals(new BigDecimal("200"), party.credit(HUNDRED_A_DAY));
|
||||
assertEquals(new BigDecimal("200"), party.getCurrentBalance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPayoutLowersTheBalance() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.credit(new BigDecimal("500"));
|
||||
|
||||
assertEquals(new BigDecimal("200"), party.debit(new BigDecimal("300")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpeningBalanceStartsTheAccountOff() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90, \"openingBalance\": 250");
|
||||
|
||||
assertEquals(new BigDecimal("250"), party.getCurrentBalance());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Interest, and why it cannot be earned twice")
|
||||
class Interest {
|
||||
|
||||
private static final LocalDate THROUGH = LocalDate.of(2026, 9, 30);
|
||||
|
||||
/** Accrued interest is not the customer's to draw on, so the balance does not move yet. */
|
||||
@Test
|
||||
void accruingParksTheInterestWithoutTouchingTheBalance() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.credit(new BigDecimal("1000"));
|
||||
|
||||
party.accrueInterest(new BigDecimal("52.60"), THROUGH, false);
|
||||
|
||||
assertEquals(new BigDecimal("52.60"), party.accruedInterest(false));
|
||||
assertEquals(new BigDecimal("1000"), party.getCurrentBalance());
|
||||
assertEquals(THROUGH, party.interestCalculatedThrough(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postingMovesItIntoTheBalanceAndLeavesNothingAccrued() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.credit(new BigDecimal("1000"));
|
||||
party.accrueInterest(new BigDecimal("52.60"), THROUGH, false);
|
||||
|
||||
assertEquals(new BigDecimal("1052.60"), party.postInterest(new BigDecimal("52.60"), false));
|
||||
assertEquals(new BigDecimal("0.00"), party.accruedInterest(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard against paying the same interest twice: a second posting comes off an accrued
|
||||
* figure that is already empty, so there is nothing left to move.
|
||||
*/
|
||||
@Test
|
||||
void aSecondPostingOfTheSameInterestFindsNothingLeft() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
party.accrueInterest(new BigDecimal("52.60"), THROUGH, false);
|
||||
party.postInterest(new BigDecimal("52.60"), false);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), party.accruedInterest(false));
|
||||
}
|
||||
|
||||
/** OD interest has its own figure and its own date - a run of one must not consume the other. */
|
||||
@Test
|
||||
void odInterestIsKeptApartFromOrdinaryInterest() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
party.accrueInterest(new BigDecimal("10.00"), THROUGH, false);
|
||||
party.accrueInterest(new BigDecimal("3.00"), THROUGH.plusDays(1), true);
|
||||
|
||||
assertEquals(new BigDecimal("10.00"), party.accruedInterest(false));
|
||||
assertEquals(new BigDecimal("3.00"), party.accruedInterest(true));
|
||||
assertEquals(THROUGH, party.interestCalculatedThrough(false));
|
||||
assertEquals(THROUGH.plusDays(1), party.interestCalculatedThrough(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interestAccruesFromWhereTheLastRunStopped() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertNull(party.interestCalculatedThrough(false));
|
||||
|
||||
party.accrueInterest(new BigDecimal("5.00"), THROUGH, false);
|
||||
party.accrueInterest(new BigDecimal("5.00"), THROUGH.plusMonths(1), false);
|
||||
|
||||
assertEquals(new BigDecimal("10.00"), party.accruedInterest(false));
|
||||
assertEquals(THROUGH.plusMonths(1), party.interestCalculatedThrough(false));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Closing")
|
||||
class Closing {
|
||||
|
||||
@Test
|
||||
void anAccountIsOpenUntilItIsClosed() {
|
||||
final Party party = party(", \"dailyDepositAmount\": 100, \"days\": 90");
|
||||
|
||||
assertFalse(party.isClosed());
|
||||
|
||||
party.close(LocalDate.of(2026, 12, 1));
|
||||
|
||||
assertTrue(party.isClosed());
|
||||
assertEquals(LocalDate.of(2026, 12, 1), party.getCloseDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom {
|
||||
@Column(name = "status_enum", nullable = false)
|
||||
private Integer statusEnum = 100;
|
||||
|
||||
// SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit
|
||||
// SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit,
|
||||
// 600=BlockDebit
|
||||
@Column(name = "sub_status_enum", nullable = false)
|
||||
private Integer subStatusEnum = 0;
|
||||
|
||||
|
||||
+7
-1
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
|
||||
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
|
||||
List<CurrentAccount> findActiveByClientId(@Param("clientId") Long clientId);
|
||||
|
||||
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600")
|
||||
// statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior
|
||||
// account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw()
|
||||
// /.reject(), are equally terminal and were still being counted as "still has one", blocking a
|
||||
// fresh application the client should have been free to make. 100/200/300 (submitted, approved,
|
||||
// active) are the only statuses a second application on the same product should actually collide
|
||||
// with.
|
||||
@Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)")
|
||||
long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId);
|
||||
}
|
||||
|
||||
+15
-1
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
@JoinColumn(name = "office_id", nullable = false)
|
||||
private Office office;
|
||||
|
||||
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal
|
||||
// 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat
|
||||
@Column(name = "transaction_type_enum", nullable = false)
|
||||
private Integer transactionTypeEnum;
|
||||
|
||||
@@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
return txn;
|
||||
}
|
||||
|
||||
// transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings
|
||||
public static CurrentAccountTransaction escheat(CurrentAccount account, Office office,
|
||||
BigDecimal amount, LocalDate transactionDate) {
|
||||
CurrentAccountTransaction txn = new CurrentAccountTransaction();
|
||||
txn.currentAccount = account;
|
||||
txn.office = office;
|
||||
txn.transactionTypeEnum = 19;
|
||||
txn.amount = amount;
|
||||
txn.transactionDate = transactionDate;
|
||||
txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability";
|
||||
txn.submittedOnDate = transactionDate;
|
||||
return txn;
|
||||
}
|
||||
|
||||
// transactionTypeEnum = 7 (Reversal / Undo)
|
||||
public static CurrentAccountTransaction reversal(CurrentAccount account, Office office,
|
||||
BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) {
|
||||
|
||||
+6
@@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService {
|
||||
CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command);
|
||||
|
||||
CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the
|
||||
* nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done.
|
||||
*/
|
||||
boolean escheat(Long accountId);
|
||||
}
|
||||
|
||||
+112
-16
@@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
|
||||
@@ -136,7 +142,17 @@ public class CurrentProductDataValidator {
|
||||
SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName,
|
||||
SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName,
|
||||
SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName,
|
||||
SavingsApiConstants.glAccountIdParamName));
|
||||
odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName,
|
||||
odPenalRateParamName, SavingsApiConstants.glAccountIdParamName));
|
||||
|
||||
/** How long an OD sanction on this scheme runs before it has to be renewed. */
|
||||
private static final List<String> OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y");
|
||||
/** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */
|
||||
private static final List<String> OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH");
|
||||
/** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */
|
||||
private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50");
|
||||
/** Excess drawing is a small tolerance over the limit, never a second limit. */
|
||||
private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25");
|
||||
|
||||
public void validateForCreate(final String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
@@ -260,10 +276,10 @@ public class CurrentProductDataValidator {
|
||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
||||
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
||||
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
||||
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true);
|
||||
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true);
|
||||
}
|
||||
|
||||
validateOverdraftParams(baseDataValidator, element);
|
||||
validateOverdraftParams(baseDataValidator, element, null);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
||||
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
||||
@@ -358,7 +374,7 @@ public class CurrentProductDataValidator {
|
||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)
|
||||
|| AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) {
|
||||
savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element,
|
||||
baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false);
|
||||
baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false);
|
||||
}
|
||||
|
||||
if (null != isDormancyActive && isDormancyActive) {
|
||||
@@ -380,7 +396,7 @@ public class CurrentProductDataValidator {
|
||||
}
|
||||
}
|
||||
|
||||
validateOverdraftParams(baseDataValidator, element);
|
||||
validateOverdraftParams(baseDataValidator, element, product);
|
||||
|
||||
if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) {
|
||||
final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element);
|
||||
@@ -398,23 +414,103 @@ public class CurrentProductDataValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
|
||||
/**
|
||||
* The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is
|
||||
* allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate,
|
||||
* a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly,
|
||||
* as RBI requires for advances, or at most quarterly.
|
||||
*
|
||||
* On update, product is the stored product and fills in any value the request leaves out, so a partial edit is
|
||||
* checked against the scheme it will produce; on create it is null.
|
||||
*/
|
||||
private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element,
|
||||
final CurrentProduct product) {
|
||||
if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) {
|
||||
final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element);
|
||||
baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue();
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) {
|
||||
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
|
||||
baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||
final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)
|
||||
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))
|
||||
: product != null && product.isAllowOverdraft();
|
||||
|
||||
final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName,
|
||||
product == null ? null : product.getOverdraftLimit());
|
||||
final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit());
|
||||
final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName,
|
||||
product == null ? null : product.getNominalAnnualInterestRateOverdraft());
|
||||
final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName,
|
||||
product == null ? null : product.getMinOverdraftForInterestCalculation());
|
||||
|
||||
if (!allowOverdraft) {
|
||||
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
return;
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) {
|
||||
final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element);
|
||||
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount();
|
||||
|
||||
baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount();
|
||||
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount();
|
||||
if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) {
|
||||
baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit)
|
||||
.failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit.");
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) {
|
||||
final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element);
|
||||
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount();
|
||||
baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount()
|
||||
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
|
||||
baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
|
||||
final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod());
|
||||
baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank()
|
||||
.isOneOfTheseStringValues(OD_VALIDITY_PERIODS);
|
||||
|
||||
final String securityType = stringOrStored(element, odSecurityTypeParamName,
|
||||
product == null ? null : product.getOdSecurityType());
|
||||
baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank()
|
||||
.isOneOfTheseStringValues(OD_SECURITY_TYPES);
|
||||
|
||||
// Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the
|
||||
// excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate
|
||||
// charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance.
|
||||
final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element)
|
||||
? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element))
|
||||
: product != null && product.isOdExcessAllowed();
|
||||
if (excessAllowed) {
|
||||
final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName,
|
||||
product == null ? null : product.getOdExcessLimitPercent());
|
||||
baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount()
|
||||
.notGreaterThanMax(MAX_EXCESS_PERCENT);
|
||||
final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName,
|
||||
product == null ? null : product.getOdPenalRate());
|
||||
baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount()
|
||||
.notGreaterThanMax(MAX_OVERDRAFT_RATE);
|
||||
}
|
||||
|
||||
final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element)
|
||||
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element)
|
||||
: product == null ? null : product.getInterestPostingPeriodType();
|
||||
baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull()
|
||||
.isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(),
|
||||
SavingsPostingInterestPeriodType.QUATERLY.getValue());
|
||||
|
||||
final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element)
|
||||
? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element)
|
||||
: product == null ? null : product.getInterestCalculationDaysInYearType();
|
||||
baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull()
|
||||
.isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(),
|
||||
SavingsInterestCalculationDaysInYearType.DAYS_365.getValue());
|
||||
}
|
||||
|
||||
private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) {
|
||||
return this.fromApiJsonHelper.parameterExists(paramName, element)
|
||||
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element)
|
||||
: stored;
|
||||
}
|
||||
|
||||
private String stringOrStored(final JsonElement element, final String paramName, final String stored) {
|
||||
return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element)
|
||||
: stored;
|
||||
}
|
||||
|
||||
private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) {
|
||||
@@ -430,7 +526,7 @@ public class CurrentProductDataValidator {
|
||||
&& BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) {
|
||||
final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element);
|
||||
final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element);
|
||||
if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
|
||||
if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) {
|
||||
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit");
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import jakarta.persistence.CascadeType;
|
||||
@@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "od_account_closed_zero_balance_in_day_closing")
|
||||
private boolean odAccountClosedZeroBalanceInDayClosing = false;
|
||||
|
||||
// The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest.
|
||||
@Column(name = "od_min_limit", scale = 6, precision = 19)
|
||||
private BigDecimal odMinLimit;
|
||||
|
||||
// CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...).
|
||||
@Column(name = "od_security_type", length = 20)
|
||||
private String odSecurityType;
|
||||
|
||||
// Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess
|
||||
// as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone.
|
||||
@Column(name = "od_excess_allowed")
|
||||
private boolean odExcessAllowed = false;
|
||||
|
||||
@Column(name = "od_excess_limit_percent", scale = 6, precision = 19)
|
||||
private BigDecimal odExcessLimitPercent;
|
||||
|
||||
@Column(name = "od_penal_rate", scale = 6, precision = 19)
|
||||
private BigDecimal odPenalRate;
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true)
|
||||
private CurrentProductFacilityConfig facilityConfig;
|
||||
|
||||
@@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
||||
actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue);
|
||||
this.odAccountClosedZeroBalanceInDayClosing = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName);
|
||||
actualChanges.put(odMinLimitParamName, newValue);
|
||||
this.odMinLimit = newValue;
|
||||
}
|
||||
if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName);
|
||||
actualChanges.put(odSecurityTypeParamName, newValue);
|
||||
this.odSecurityType = newValue;
|
||||
}
|
||||
if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
|
||||
actualChanges.put(odExcessAllowedParamName, newValue);
|
||||
this.odExcessAllowed = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName);
|
||||
actualChanges.put(odExcessLimitPercentParamName, newValue);
|
||||
this.odExcessLimitPercent = newValue;
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName);
|
||||
actualChanges.put(odPenalRateParamName, newValue);
|
||||
this.odPenalRate = newValue;
|
||||
}
|
||||
if (!this.odExcessAllowed) {
|
||||
this.odExcessLimitPercent = null;
|
||||
this.odPenalRate = null;
|
||||
}
|
||||
// Clear OD-specific fields when overdraft is disabled
|
||||
if (!this.allowOverdraft) {
|
||||
this.avgBalanceCalcPeriod = null;
|
||||
this.validForPeriod = null;
|
||||
this.odAccountClosedZeroBalanceInDayClosing = false;
|
||||
this.odMinLimit = null;
|
||||
this.odSecurityType = null;
|
||||
this.odExcessAllowed = false;
|
||||
this.odExcessLimitPercent = null;
|
||||
this.odPenalRate = null;
|
||||
}
|
||||
|
||||
return actualChanges;
|
||||
@@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom<Long> {
|
||||
public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; }
|
||||
public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; }
|
||||
public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; }
|
||||
public BigDecimal getOdMinLimit() { return odMinLimit; }
|
||||
public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; }
|
||||
public String getOdSecurityType() { return odSecurityType; }
|
||||
public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; }
|
||||
public boolean isOdExcessAllowed() { return odExcessAllowed; }
|
||||
public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; }
|
||||
public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; }
|
||||
public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; }
|
||||
public BigDecimal getOdPenalRate() { return odPenalRate; }
|
||||
public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; }
|
||||
}
|
||||
|
||||
+22
@@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
|
||||
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
|
||||
@@ -314,6 +319,23 @@ public class CurrentProductAssembler {
|
||||
if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) {
|
||||
product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName));
|
||||
}
|
||||
if (command.parameterExists(odMinLimitParamName)) {
|
||||
product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName));
|
||||
}
|
||||
if (command.parameterExists(odSecurityTypeParamName)) {
|
||||
product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName));
|
||||
}
|
||||
final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName)
|
||||
&& command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName);
|
||||
product.setOdExcessAllowed(excessAllowed);
|
||||
if (excessAllowed) {
|
||||
if (command.parameterExists(odExcessLimitPercentParamName)) {
|
||||
product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName));
|
||||
}
|
||||
if (command.parameterExists(odPenalRateParamName)) {
|
||||
product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return product;
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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.currentproduct.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.portfolio.PortfolioProductType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}.
|
||||
*
|
||||
* <p>
|
||||
* They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product
|
||||
* and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the
|
||||
* account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type,
|
||||
* the two can never meet.
|
||||
*
|
||||
* <p>
|
||||
* A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a
|
||||
* partial edit changes only what it names. Accounting rule NONE clears every mapping.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CurrentProductGlMappingWriter {
|
||||
|
||||
/** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */
|
||||
private static final Map<String, Integer> PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17);
|
||||
PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18);
|
||||
}
|
||||
|
||||
private static final int CURRENT = PortfolioProductType.CURRENT.getValue();
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Applies the request's mappings to the product and returns what changed, keyed by parameter name.
|
||||
*
|
||||
* @param accountingRule
|
||||
* the product's accounting rule after this request (1 = NONE)
|
||||
*/
|
||||
public Map<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
|
||||
final Map<String, Object> changes = new LinkedHashMap<>();
|
||||
if (accountingRule == null || accountingRule == 1) {
|
||||
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
|
||||
return changes;
|
||||
}
|
||||
final JsonElement parsed = command.parsedJson();
|
||||
final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
for (final Map.Entry<String, Integer> entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) {
|
||||
final String param = entry.getKey();
|
||||
if (!json.has(param)) {
|
||||
continue;
|
||||
}
|
||||
final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong();
|
||||
final Long existing = jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
|
||||
+ " AND payment_type IS NULL AND charge_id IS NULL",
|
||||
Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null);
|
||||
if (java.util.Objects.equals(existing, glAccountId)) {
|
||||
continue;
|
||||
}
|
||||
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?"
|
||||
+ " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue());
|
||||
if (glAccountId != null) {
|
||||
jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)"
|
||||
+ " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue());
|
||||
}
|
||||
changes.put(param, glAccountId);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Removes every mapping of a deleted product. */
|
||||
public void deleteAll(final Long productId) {
|
||||
jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId);
|
||||
}
|
||||
}
|
||||
+8
@@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
||||
sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, ");
|
||||
sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, ");
|
||||
sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, ");
|
||||
sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, ");
|
||||
sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, ");
|
||||
sqlBuilder.append("cp.od_penal_rate as odPenalRate, ");
|
||||
sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id "
|
||||
+ "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval ");
|
||||
|
||||
@@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
||||
productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod"));
|
||||
productData.setValidForPeriod(rs.getString("validForPeriod"));
|
||||
productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing"));
|
||||
productData.setOdMinLimit(rs.getBigDecimal("odMinLimit"));
|
||||
productData.setOdSecurityType(rs.getString("odSecurityType"));
|
||||
productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed"));
|
||||
productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent"));
|
||||
productData.setOdPenalRate(rs.getBigDecimal("odPenalRate"));
|
||||
productData.setIsPendingApproval(rs.getBoolean("isPendingApproval"));
|
||||
|
||||
return productData;
|
||||
|
||||
+6
-6
@@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final CommandSourceRepository commandSourceRepository;
|
||||
private final CurrentProductGlMappingWriter glMappingWriter;
|
||||
|
||||
private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) {
|
||||
String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME;
|
||||
@@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
this.depositLimitKycRepository.saveAndFlush(depositLimitKyc);
|
||||
}
|
||||
|
||||
this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command,
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
// Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter.
|
||||
this.glMappingWriter.write(product.getId(), command, product.getAccountingType());
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(product.getId()) //
|
||||
@@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
}
|
||||
|
||||
// accounting related changes
|
||||
final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName);
|
||||
final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
|
||||
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
final Map<String, Object> accountingMappingChanges = this.glMappingWriter.write(product.getId(), command,
|
||||
product.getAccountingType());
|
||||
changes.putAll(accountingMappingChanges);
|
||||
|
||||
if (!changes.isEmpty()) {
|
||||
@@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr
|
||||
.orElseThrow(() -> new SavingsProductNotFoundException(productId));
|
||||
|
||||
this.currentProductRepository.delete(product);
|
||||
this.glMappingWriter.deleteAll(productId);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(product.getId()) //
|
||||
|
||||
+3
-2
@@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository;
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository;
|
||||
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl;
|
||||
import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService;
|
||||
@@ -78,11 +79,11 @@ public class CurrentProductConfiguration {
|
||||
ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
|
||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||
FromJsonHelper fromApiJsonHelper,
|
||||
CommandSourceRepository commandSourceRepository) {
|
||||
CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) {
|
||||
return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator,
|
||||
currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository,
|
||||
currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository,
|
||||
pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository,
|
||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
|
||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter);
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
|
||||
"glAccountId", glAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to draw more out of a holding account than it holds.
|
||||
*
|
||||
* <p>
|
||||
* A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
|
||||
* is the whole of what can come out. Drawing more leaves it with a negative balance, and a
|
||||
* liability with a negative balance is not a small number - it says the bank owes less than
|
||||
* nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
|
||||
* and the figure is small enough to sit unnoticed inside a head that totals positive.
|
||||
*
|
||||
* <p>
|
||||
* Checked here rather than at each call site because the accounts this protects are reached by
|
||||
* several features posting directly through JDBC, and a rule enforced in one of them is a rule
|
||||
* the next one added will not have.
|
||||
*
|
||||
* @param glAccountId the account being drawn on.
|
||||
* @param amount how much the caller wants to take out.
|
||||
* @param what the account's role in the caller's own words, for the message a clerk reads.
|
||||
*/
|
||||
public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
|
||||
final java.math.BigDecimal amount, final String what) {
|
||||
if (glAccountId == null || amount == null || amount.signum() <= 0) {
|
||||
return;
|
||||
}
|
||||
// Credits less debits: these are liability-natured holding accounts, so the balance is what
|
||||
// is still owed onward. Reversed entries and the entries they reverse both drop out - a
|
||||
// reversal pair nets to nothing, and counting either side alone moves the balance by the
|
||||
// whole amount.
|
||||
final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""", java.math.BigDecimal.class, glAccountId);
|
||||
final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
|
||||
if (balance.compareTo(amount) < 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
|
||||
what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
|
||||
+ "difference would leave the account with a negative balance, which a "
|
||||
+ "holding account cannot have.",
|
||||
"glAccountId", glAccountId, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1746
-1618
File diff suppressed because it is too large
Load Diff
+22
-6
@@ -173,12 +173,28 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
|
||||
}
|
||||
|
||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
// A premature FD closure that reprices interest downward (see
|
||||
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||
// in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
|
||||
// cash-based path.
|
||||
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||
if (amountSign != 0) {
|
||||
final boolean isClawback = amountSign < 0;
|
||||
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||
// Routed through the same helper the cash processor uses, so a posting states
|
||||
// the whole chain the product is configured for -- out of the payable, through
|
||||
// the interest-paid account, into the member's balance -- rather than the single
|
||||
// payable-to-control pair that used to be here, which skipped interest-paid
|
||||
// entirely and left the expense off the P&L.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
||||
}
|
||||
|
||||
else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) {
|
||||
// Post journal entry if earned interest amount is greater than
|
||||
// zero
|
||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
// A premature FD closure that reprices interest downward (see
|
||||
// FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE
|
||||
// interest-posting transaction -- a clawback of interest already recognised in earlier
|
||||
// periods, not "zero interest, nothing to post". Skipping it here (the original
|
||||
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||
// in sync instead.
|
||||
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||
if (amountSign != 0) {
|
||||
final boolean isClawback = amountSign < 0;
|
||||
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||
// Routed through the payable and paid accounts the product names, so the society
|
||||
// can read interest declared but not yet credited as its own balance. Collapses
|
||||
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||
// that names neither.
|
||||
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||
savingsId, transactionId, transactionDate, amount, isReversal);
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -105,7 +105,23 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
||||
.append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
|
||||
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
|
||||
.append(" eao.external_id as externalAssetOwner, ")
|
||||
// entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in
|
||||
// m_current_account -- newer write paths tag current-account entries with it
|
||||
// instead of reusing SAVING's type, specifically so this case needs no guessing.
|
||||
//
|
||||
// entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings
|
||||
// and current-account entries by older write paths not yet updated to the above --
|
||||
// current products used to deliberately share SAVING's GL type space (see
|
||||
// PortfolioProductType.CURRENT's own comment) -- and m_savings_account/
|
||||
// m_current_account each have their own id sequence, so an entity_id can be a real
|
||||
// row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current
|
||||
// account's number even for an entry that is genuinely a savings one.
|
||||
// savings_transaction_id is the one unambiguous signal available for those older
|
||||
// entries -- only a real savings transaction sets it -- so it decides first; only
|
||||
// when it's absent does the current-account guess apply.
|
||||
.append(" CASE ")
|
||||
.append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ")
|
||||
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ")
|
||||
.append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
|
||||
.append(" ELSE NULL ")
|
||||
.append(" END as savingsAccountNo ");
|
||||
@@ -130,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
||||
.append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ")
|
||||
.append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ")
|
||||
.append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ")
|
||||
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
|
||||
.append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) ");
|
||||
if (associationParametersData.isTransactionDetailsRequired()) {
|
||||
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ")
|
||||
.append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
|
||||
|
||||
+12
@@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
||||
break;
|
||||
case CASH_BASED:
|
||||
saveSavingsBaseAccountMapping(savingProductId, accountType, command, element);
|
||||
// Interest Payable is saved on cash too, and optionally: interest posting routes
|
||||
// through it on either basis (see AccountingProcessorHelper
|
||||
// .createInterestPostingJournalEntriesForSavings), so that interest declared but not
|
||||
// yet credited to the member reads as the bank's own liability rather than appearing
|
||||
// only at the moment it is paid. Mapped only when the screen supplies it - a product
|
||||
// naming none still posts straight to Savings Control, as it always did.
|
||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(),
|
||||
element)) {
|
||||
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
|
||||
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue());
|
||||
}
|
||||
break;
|
||||
|
||||
case ACCRUAL_PERIODIC:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user